Saltar a contenido

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:

  1. No debe contener variables
  2. No debe referirse a direcciones de memoria directas
  3. 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:

  1. Mover cadenas inmediatas a registros
  2. 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:

  1. Reemplazando con llamadas a etiquetas o direcciones relativas a rip (para calls y loops)
  2. Empujando a la Stack y usando rsp como dirección (para mov 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.