En artículos anteriores hemos utilizado shellcodes como un conjunto de bytes que copiábamos en ciertas posiciones de memoria donde mas tarde hariamos que el EIP terminase apuntando, pero ha llegado el momento de comprender qué son esos bytes y cómo podemos construir nuestras propias shellcodes. Una shellcode normalmente sirve para lanzar una shell (de ahí su nombre), aunque en realidad podemos hacer cualquier cosa con ella.
Una shellcode no es mas que el conjunto de opcodes (instrucciones en hexadecimal) que ejecutará el procesador para realizar un acción en concreto, las shellcodes suelen estar escritas en ensamblador ya que nos permite un control total sobre el proceso de ejecución además de un tamaño inferior de la shellcode.
Antes de nada se debe aclarar que el software accede al hardware a través del kernel, los porgramas realizan llamadas al sistema para ejecutar ciertas acciones, estas son aprobadas o denegadas por el kernel dependiendo del nivel de privilegios del proceso y la acción a ejecutar. Para poder realizar una llamada al sistema se deben asignar determinados valores a ciertos registros, una vez preaparado el escenario de ejecución se llamada a la interrupción 0x80, podemos ver las diferentes llamadas al sistema con:
El número de la syscall se asignará al registro EAX y los parámetros en EBX, ECX, EDX, ESI y EDI.
Alguién seguro que está pensando, porque no programar en C la función a ejecutar y obtener de esta los opcodes?, la respuesta es sencilla, los compiladores meten “basura” en el código y a nosotros nos interesa que la shellcode sea lo mas pequeña posible. A modo de ejemplo vamos a programar un exit(0) en C y ASM para poder comparar las instrucciones en ASM en cada uno de ellos.
#include <stdlib.h>
void main() {
exit(0);
}
Compilamos el binario de forma estática(el código de las librerias incluidas es copiado en nuestro programa) para poder desensamblar desde gdb el código de las funciones definidas en las librerias.
gdb salir
(gdb) set disassembly-flavor intel
ASM del main, llama a la función exit:
Dump of assembler code for function main:
0x08048254 <+0>: push ebp
0x08048255 <+1>: mov ebp,esp
0x08048257 <+3>: and esp,0xfffffff0
0x0804825a <+6>: sub esp,0x10
0x0804825d <+9>: mov DWORD PTR [esp],0x0
0x08048264 <+16>: call 0x8048b30 <exit>
End of assembler dump.
ASM de la función exit, como podemos ver se hacen dos llamadas al sistema(int 0x80) cuando nosotros solo necesitamos la última de ellas:
Dump of assembler code for function _exit:
0x0804f730 <+0>: mov ebx,DWORD PTR [esp+0x4]
0x0804f734 <+4>: mov eax,0xfc
0x0804f739 <+9>: int 0x80
0x0804f73b <+11>: mov eax,0x1
0x0804f740 <+16>: int 0x80
0x0804f742 <+18>: hlt
End of assembler dump.
Nuestra shellcode podría funcionar simplemente con:
mov ebx,DWORD PTR [esp+0x4] --> Pone a 0 EBX(parámetro de la función)
mov eax,0x1 --> Se debe ejecutar la INT 1
int 0x80 --> Ejecuta syscall
Para programar en ASM necesitaremos un ensamblador(convierte código ASM a código máquina):
vi salir.asm
section .text
global _start
_start:
xor eax, eax ; EAX --> 0
xor ebx, ebx ; EBX(parametro funcion) --> 0
mov eax, 0x01 ; EAX --> 1
int 0x80 ; Ejecuta SYSCALL
Ensamblamos el código:
ld salir.o -o salir
Comprobamos que se realiza la llamada al sistema mediante strace:
execve("./salir", ["./salir"], [/* 16 vars */]) = 0
_exit(0) = ?
Obtenemos los opcodes:
salir: file format elf32-i386
Disassembly of section .text:
08048060 <_start>:
8048060: 31 c0 xor eax,eax
8048062: 31 db xor ebx,ebx
8048064: b8 01 00 00 00 mov eax,0x1
8048069: cd 80 int 0x80
NOTA: Hay que tener en cuenta que una shellcode NO puede tener NULLs ya que esto indicaría un fin en la variable provocando así que no se continue con el resto de los opcodes, dejando la shellcode a medio ejecutar.
La shellcode anterior quedaría: x31xc0x31xdbxb8x01x00x00x00xcdx80 como vemos hay carácteres nulos!!
Algunos trucos para evitar NULLs en las shellcodes son:
- Asignar 0 a un registro: xor REG,REG
- Resetear registro completo mediante XOR REG,REG y luego utilizar versiones reducidas del registro para asignarle el valor final ya que 00000000 00000001 == 00000001:
xor eax, eax
Sustituimos:
mov eax, 0x01 --> mov al, 0x01
Aplicando estos “trucos” quedaría:
section .text
global _start
_start:
xor eax, eax ; EAX --> 0
xor ebx, ebx ; EBX(parametro funcion) --> 0
mov al, 0x01 ; EAX --> 1
int 0x80 ; Ejecuta SYSCALL
Reensamblamos:
kr0m@reversedbox:~$ objdump -M intel-mnemonic -d salir
salir: file format elf32-i386
Disassembly of section .text:
08048060 <_start>:
8048060: 31 c0 xor eax,eax
8048062: 31 db xor ebx,ebx
8048064: b0 01 mov al,0x1
8048066: cd 80 int 0x80
Como podemos observar ya no hay NULLs, la shellcode se ha reducido en tamaño y el resultado de su ejecución es exactamente el mismo:
execve("./salir", ["./salir"], [/* 16 vars */]) = 0
_exit(0) = ?
También es posible realizar la operación inversa, es decir sacar el código ASM a partir de la shellcode:
00000000 31C0 xor eax,eax
00000002 31DB xor ebx,ebx
00000004 B001 mov al,0x1
00000006 CD80 int 0x80
En este enlace dejo una tabla muy útil sobre instrucciones en x86.
Esto es solo una pequeña introducción al funcionamiento de las shellcodes, su funcionamiento y algunos aspectos a tener en cuenta a la hora de codearlas, realmente una shellcode que ejecuta un exit(0) no resulta muy útil que digamos, en próximos capitulos comenzaremos con shellcodes mas curradas ;)