Shellcoding Techniques
Como vimos en la sección anterior, nuestro código en ensamblador Hello World
tuvo que ser modificado para producir un shellcode funcional. En esta sección, repasaremos algunas de las técnicas y trucos que podemos usar para resolver problemas encontrados en nuestro código en ensamblador.
Shellcoding Requirements
Como mencionamos brevemente en la sección anterior, no todos los binarios generan shellcodes funcionales que puedan cargarse directamente en la memoria y ejecutarse. Esto se debe a que hay requisitos específicos que un shellcode debe cumplir. De lo contrario, no se desensamblará correctamente en tiempo de ejecución en sus instrucciones de ensamblador correctas.
Para comprender esto mejor, intentemos desensamblar el shellcode que extraímos en la sección anterior del programa Hello World
, utilizando la misma herramienta pwn disasm
que usamos anteriormente:
$ pwn disasm '48be0020400000000000bf01000000ba12000000b8010000000f05b83c000000bf000000000f05' -c 'amd64'
0: 48 be 00 20 40 00 00 movabs rsi, 0x402000
7: 00 00 00
a: bf 01 00 00 00 mov edi, 0x1
f: ba 12 00 00 00 mov edx, 0x12
14: b8 01 00 00 00 mov eax, 0x1
19: 0f 05 syscall
1b: b8 3c 00 00 00 mov eax, 0x3c
20: bf 00 00 00 00 mov edi, 0x0
25: 0f 05 syscall
Podemos ver que las instrucciones son relativamente similares al código en ensamblador Hello World
que teníamos antes, pero no son idénticas. Vemos que hay una línea vacía de instrucciones, lo que podría potencialmente romper el código. Además, nuestra cadena Hello World
no aparece en ninguna parte. También vemos muchos 00
en rojo, lo cual discutiremos más adelante.
Esto es lo que sucede si nuestro código en ensamblador no es shellcode compliant
y no cumple con los Shellcoding Requirements
. Para producir un shellcode funcional, hay tres requisitos principales (Shellcoding Requirements
) que nuestro código en ensamblador debe cumplir:
- No debe contener variables
- No debe referirse a direcciones de memoria directas
- No debe contener ningún byte NULL
00
Así que, comencemos con el programa Hello World
que vimos en la sección anterior y repasemos cada uno de los puntos anteriores para solucionarlos:
global _start
section .data
message db "Hello HTB Academy!"
section .text
_start:
mov rsi, message
mov rdi, 1
mov rdx, 18
mov rax, 1
syscall
mov rax, 60
mov rdi, 0
syscall
Remove Variables
Un shellcode debe ser directamente ejecutable una vez cargado en memoria, sin cargar datos de otros segmentos de memoria, como .data
o .bss
. Esto se debe a que los segmentos de memoria text
no son writable
, por lo que no podemos escribir ninguna variable. En cambio, el segmento data
no es ejecutable, por lo que no podemos escribir código ejecutable.
Por lo tanto, para ejecutar nuestro shellcode, debemos cargarlo en el segmento de memoria text
y perder la capacidad de escribir cualquier variable. Por lo tanto, todo nuestro shellcode debe estar bajo '.text' en el código en ensamblador.
Nota: Algunas técnicas antiguas de shellcoding (como la técnica jmp-call-pop) ya no funcionan con las protecciones de memoria modernas, ya que muchas de ellas dependen de escribir variables en el segmento de memoria text
, lo cual, como acabamos de discutir, ya no es posible.
Hay muchas técnicas que podemos usar para evitar el uso de variables, como:
- Mover cadenas inmediatas a registros
- Empujar cadenas a la Stack y luego usarlas
En el código anterior, podemos mover nuestra cadena a rsi
, como sigue:
mov rsi, 'Academy!'
Sin embargo, un registro de 64 bits solo puede contener 8 bytes, lo cual puede no ser suficiente para cadenas más grandes. Por lo tanto, nuestra otra opción es confiar en la Stack, empujando nuestra cadena 16 bytes a la vez (en orden inverso) y luego usando rsp
como nuestro puntero de cadena, como sigue:
push 'y!'
push 'B Academ'
push 'Hello HT'
mov rsi, rsp
Sin embargo, esto excedería los límites permitidos de cadenas inmediatas push
, que es un dword
(4 bytes) a la vez. Por lo tanto, en su lugar, moveremos nuestra cadena a rbx
y luego empujaremos rbx
a la Stack, como sigue:
mov rbx, 'y!'
push rbx
mov rbx, 'B Academ'
push rbx
mov rbx, 'Hello HT'
push rbx
mov rsi, rsp
Nota: Siempre que empujamos una cadena a la stack, tenemos que empujar un 00
antes para terminar la cadena. Sin embargo, no tenemos que preocuparnos por eso en este caso, ya que podemos especificar la longitud de impresión para la syscall write
.
Ahora podemos aplicar estos cambios a nuestro código, ensamblarlo y ejecutarlo para ver si funciona:
./assembler.sh helloworld.s
Hello HTB Academy!
Vemos que funciona como se esperaba, sin necesidad de usar variables. Podemos verificarlo con gdb
para ver cómo se ve en el punto de interrupción:
$ gdb -q ./helloworld
─────────────────────────────────────────────────────────────────────────────────────── registers ────
$rax : 0x1
$rbx : 0x5448206f6c6c6548 ("Hello HT"?)
$rcx : 0x0
$rdx : 0x12
$rsp : 0x00007fffffffe3b8 → "Hello HTB Academy!"
$rbp : 0x0
$rsi : 0x00007fffffffe3b8 → "Hello HTB Academy!"
$rdi : 0x1
─────────────────────────────────────────────────────────────────────────────────────────── stack ────
0x00007fffffffe3b8│+0x0000: "Hello HTB Academy!" ← $rsp, $rsi
0x00007fffffffe3c0│+0x0008: "B Academy!"
0x00007fffffffe3c8│+0x0010: 0x0000000000002179 ("y!"?)
───────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
→ 0x40102e <_start+46> syscall
──────────────────────────────────────────────────────────────────────────────────────────────────────
Como podemos notar, la cadena se construyó gradualmente en la Stack, y cuando movimos rsp
a rsi
, contenía nuestra cadena completa.
Remove Addresses
Ahora no estamos usando ninguna dirección en nuestro código anterior, ya que eliminamos la única referencia a direcciones cuando eliminamos nuestra única variable. Sin embargo, podemos ver referencias en muchos casos, especialmente con calls
o loops
y similares. Por lo tanto, debemos asegurarnos de que nuestro shellcode sepa cómo hacer la llamada en cualquier entorno en el que se ejecute.
Para poder hacerlo, no podemos referenciar direcciones de memoria directas (es decir, call 0xffffffffaa8a25ff
), y en su lugar solo hacer llamadas a etiquetas (es decir, call loopFib
) o direcciones de memoria relativas (es decir, call 0x401020
). Discutimos la direccionamiento relativo a rip
en la sección gdb
.
Afortunadamente, a lo largo de este módulo, solo hemos estado haciendo calls
a etiquetas para asegurarnos de aprender a escribir código que sea fácilmente shellcodeable. Si estamos haciendo un call
a una etiqueta, nasm
cambiará automáticamente esta etiqueta a una dirección relativa, lo cual debería funcionar con shellcodes.
Si alguna vez tuviéramos llamadas o referencias a direcciones de memoria directas, podemos solucionarlo:
- Reemplazando con llamadas a etiquetas o direcciones relativas a rip (para
calls
yloops
) - Empujando a la Stack y usando
rsp
como dirección (paramov
y otras instrucciones en ensamblador)
Si somos eficientes al escribir nuestro código en ensamblador, es posible que no tengamos que solucionar estos tipos de problemas.
Remove NULL
Los caracteres NULL (o 0x00
) se utilizan como terminadores de cadena en ensamblador y código máquina, por lo que si se encuentran, pueden causar problemas y llevar al programa a terminar prematuramente. Por lo tanto, debemos asegurarnos de que nuestro shellcode no contenga ningún byte NULL 00
. Si volvemos a la desensamblación de nuestro shellcode Hello World
, notamos muchos 00
en rojo:
$ pwn disasm '48be0020400000000000bf01000000ba12000000b8010000000f05b83c000000bf000000000f05' -c 'amd64'
0: 48 be 00 20 40 00 00 movabs rsi, 0x402000
7: 00 00 00
a: bf 01 00 00 00 mov edi, 0x1
f: ba 12 00 00 00 mov edx, 0x12
14: b8 01 00 00 00 mov eax, 0x1
19: 0f 05 syscall
1b: b8 3c 00 00 00 mov eax, 0x3c
20: bf 00 00 00 00 mov edi, 0x0
25: 0f 05 syscall
Esto ocurre comúnmente al mover un número pequeño a un registro grande, por lo que el número se rellena con un 00
adicional para ajustarse al tamaño del registro más grande.
Por ejemplo, en nuestro código anterior, cuando usamos mov rax, 1
, se moverá 00 00 00 01
en rax
, de modo que el tamaño del número coincida con el tamaño del registro. Podemos ver esto cuando ensamblamos la instrucción anterior:
pwn asm 'mov rax, 1' -c 'amd64'
48c7c001000000
Para evitar estos bytes NULL, debemos usar registros que coincidan con el tamaño de nuestros datos
. Por ejemplo, podemos usar la instrucción más eficiente mov al, 1
. Antes de hacerlo, debemos poner en cero el registro rax
con xor rax, rax
.
pwn asm 'xor rax, rax' -c 'amd64'
4831c0
$ pwn asm 'mov al, 1' -c 'amd64'
b001
Como podemos ver, no solo nuestro nuevo shellcode no contiene bytes NULL, sino que también es más corto, lo cual es algo muy deseado en los shellcodes.
Podemos comenzar con la nueva instrucción que agregamos anteriormente, mov rbx, 'y!'
. Vemos que esta instrucción está moviendo 2 bytes en un registro de 8 bytes. Entonces, para solucionarlo, primero pondremos a cero rbx
y luego usaremos el registro de 2 bytes (es decir, de 16 bits) bx
, de la siguiente manera:
xor rbx, rbx
mov bx, 'y!'
Estas nuevas instrucciones no deben contener bytes NULL en su shellcode. Apliquemos lo mismo al resto de nuestro código, de la siguiente manera:
xor rax, rax
mov al, 1
xor rdi, rdi
mov dil, 1
xor rdx, rdx
mov dl, 18
syscall
xor rax, rax
add al, 60
xor dil, dil
syscall
Vemos que aplicamos esta técnica en tres lugares y usamos el registro de 8 bits para cada uno.
Consejo
Si alguna vez necesitamos mover 0
a un registro, podemos poner ese registro en cero, como hicimos para rdi
arriba. Del mismo modo, si necesitamos hacer un push 0
a la pila (por ejemplo, para la terminación de cadena), podemos poner cualquier registro en cero y luego empujar ese registro a la pila.
Si aplicamos todo lo anterior, deberíamos tener el siguiente código en ensamblador:
global _start
section .text
_start:
xor rbx, rbx
mov bx, 'y!'
push rbx
mov rbx, 'B Academ'
push rbx
mov rbx, 'Hello HT'
push rbx
mov rsi, rsp
xor rax, rax
mov al, 1
xor rdi, rdi
mov dil, 1
xor rdx, rdx
mov dl, 18
syscall
xor rax, rax
add al, 60
xor dil, dil
syscall
Finalmente, podemos ensamblar nuestro código y ejecutarlo:
./assembler.sh helloworld.s
Hello HTB Academy!
Como podemos ver, nuestro código funciona como se esperaba.
Shellcoding
Ahora podemos intentar extraer el shellcode de nuestro nuevo programa helloworld
, utilizando nuestro script shellcoder.py
anterior:
python3 shellcoder.py helloworld
4831db66bb79215348bb422041636164656d5348bb48656c6c6f204854534889e64831c0b0014831ff40b7014831d2b2120f054831c0043c4030ff0f05
Este shellcode se ve mucho mejor. Pero, ¿contiene bytes NULL? Difícil de decir. Entonces, agreguemos la siguiente línea al final de shellcoder.py
, que nos dirá si nuestro código contiene bytes NULL y también nos dirá el tamaño de nuestro shellcode:
print("%d bytes - Found NULL byte" % len(shellcode)) if [i for i in shellcode if i == 0] else print("%d bytes - No NULL bytes" % len(shellcode))
Ejecutemos nuestro script actualizado, para ver si nuestro shellcode contiene bytes NULL:
python3 shellcoder.py helloworld
4831db66bb79215348bb422041636164656d5348bb48656c6c6f204854534889e64831c0b0014831ff40b7014831d2b2120f054831c0043c4030ff0f05
61 bytes - No NULL bytes
Como podemos ver, el mensaje No NULL bytes
nos dice que nuestro shellcode está libre de bytes NULL.
Prueba ejecutar el script en el programa anterior Hello World
para ver si contenía bytes NULL. Finalmente, llegamos al momento de la verdad e intentamos ejecutar nuestro shellcode con nuestro script loader.py
para ver si se ejecuta con éxito:
python3 loader.py '4831db66bb79215348bb422041636164656d5348bb48656c6c6f204854534889e64831c0b0014831ff40b7014831d2b2120f054831c0043c4030ff0f05'
Hello HTB Academy!
Como podemos ver, hemos creado con éxito un shellcode funcional para nuestro programa Hello World
.