Tras la introducción que vimos el otro dia ahora nos toca hablar de shellcodes. Como dijimos en el post anterior nuestra intención final para sacar el máximo provecho de la vulnerabilidad en el software era la ejecución de código arbitrario, lo que quiere decir que queremos ejecutar código de nuestra propiedad en la aplicacion vulnerable. Pero empecemos por el pricipio.
¿Que es un shellcode?
Un shellcode es un código preparado para ser inyectado y ejecutado directamente en la memoria durante la ejecución de un programa aprovechandonos de alguna vulnerabilidad en el software.
A la hora de programar un shellcode debemos prestar especial atención a su optimización y tamaño ya que el espacio para alojarlo es limitado como veremos mas adelante. A lo largo de este tutorial veremos como programar un shellcode básico para arquitecturas x86 y ejecutaremos nuestro código en un programa vulnerable preparado para la ocasión.
Tipos de shellcode:
Existen dos tipos de shellcodes, locales y remotos, su clasificación es sencilla y se basa en su comportamiento.
- Shellcode local: Un shellcode local es aquel que no establece ninguna conexión, su uso se restringe a la ejecución en la máquina explotada. Un ejemplo de shellcode local sería uno que agregase un usuario root al sistema y permita el acceso como administrador al atacante.
- Shellcode remoto: Cuando hablamos de shellcode remoto hablamos de un código que tras ser ejecutado permite o crea una conexión desde o hacia la máquina del atacante. Un ejemplo sería un shellcode que genera una shell inversa desde la máquina explotada hacia la máquina del atacante.
La navaja suiza del desarrollador de shellcodes son las llamadas al sistema (syscalls) como estamos trabajando en un entorno Linux con arquitectura x86, podemos encontrar las syscalls que tenemos disponibles en el archivo unistd.h el cual podemos localizar utilizando el comando locate de la siguiente manera:
$ updatedb
$locate unistd.h
A lo largo de este ejemplo utilizaremos dos syscalls básicas para representar el proceso de programación de un shellcode exit() (syscall 1) y write() (syscall 4), podéis ver un listado de syscalls en el siguiente enlace: http://docs.cs.up.ac.za/programming/asm/derick_tut/syscalls.html
Debéis saber que cada syscall tiene una serie de parámetros, que deberán ser colocados en los registros antes de ejecutar la llamada al sistema. Veamos el syscall write para conocer como se realiza dicha llamada.
La definición de este syscall es la siguiente:
#define __NR_write 4
write( int fd, char *msg, int len)
Por lo tanto sabemos que debemos pasar el valor 4 al registro eax, el valor del file descriptor a ebx (0 stdin, 1 stdout y 2 stderr), el string a imprimir a ecx y por último la longitud de la cadena a edx. Finalmente la instrucción int 0×80 que indica la ejecución de la llamada al sistema, quedando el código de la siguiente manera.
section .text
global _start
_start:
mov edx,13 ; longitud del string
mov ecx,msg ; string a imprimir
mov ebx,1 ; file descriptor (stdout)
mov eax,4 ; numero de syscall (sys_write, 4)
int 0x80 ; ejecutamos la llamada al syscall
; Estas últimas dos lineas son simplemente para salir del programa
mov eax,1 ; system call number (sys_exit)
int 0x80 ; ejecutamos la llamada al syscall
section .data
msg db 'Hello, world!' ;string a imprimir
Problemas a la hora de programar un shellcode:
Para no tener problemas a la hora de ejecutar nuestro shellcode, ademas de tener en cuenta el espacio que ocupa, debemos atender a dos problemas básicos. Los bytes nulos (Null Byte Problem) y el direccionamiento de memoria (Addressing Problem), a continuación detallaremos cada uno de ellos.
Null Byte Problem: La manera mas común de inyectar nuestro shellcode es a través de funciones usadas para el manejo de strings. Estas funciones finalizan la ejecución si encuentran un byte nulo (0×00), por lo tanto si nuestro shellcode contiene uno de estos bytes la ejecución terminará y no lograremos nuestro objetivo. Para ver esto en funcionamiento crearemos un programa en ensamblador que simplemente realiza una llamada al syscall exit y termina la ejecución.
section .text
global _start
_start:
mov eax, 1 ; Pasamos el numero de la syscall a eax (1)
mov ebx, 0 ; Pasamos el código de salida a ebx
int 0x80 ; Ejecutamos la llamada
Para compilarlo usaremos los siguientes comandos:
nasm -f elf shellcode.asm
ld -o shellcode shellcode.o
Si desensamblamos nuestro programa de ejemplo podemos identificar claramente el problema:
Nuestro código ha generado multitud de bytes nulos por lo que si inyectasemos este shellcode en un programa en ejecución este terminaría al leerlos y nuestro código no se ejecutaría. El motivo de que se creen bytes nulos es que estamos almacenando un valor muy pequeño (0×1) en un registro de 32bits por lo que el resto se rellena con ceros hasta completarlo. Para solucionarlo hay que recordar el post anterior cuando hablábamos de los subregistros (eax 32 bits, ax 16bits, al y ah 8 bits) de esta manera almacenaremos el valor en un registro apropiado para su tamaño evitando que el compilador rellene el resto con ceros. Veamos el código ahora.
section .text
global _start
_start:
mov al, 1 ; Pasamos el numero de la syscall al registro al (1)
mov bl, 0 ; Pasamos el código de salida a bl
int 0x80 ; Ejecutamos la llamada
En este punto hemos reducido en su mayor medida los bytes nulos, pero aún tenemos un byte nulo que necesitamos para ejecutar la salida, en este caso podemos utilizar la instrucción XOR con la que almacenaremos un byte nulo sin generarlo en nuestro código.
section .text
global _start
_start:
mov al, 1 ; Pasamos el numero de la syscall al registro al (1)
xor ebx, ebx ; Usando xor logramos un byte nulo en ebx
int 0x80 ; Ejecutamos la llamada
Y finalmente hemos logrado un código son bytes nulos, que se podría ejecutar en memoria sin que la funcion vulnerable pare la ejecución.
Este proceso se puede complicar a medida que aumenta la complejidad de nuestro código pero utilizaremos este ejemplo a modo de introducción.
Addressing problem:
Este problema se debe a que queremos inyectar nuestro código en un programa durante su ejecución, por lo que debemos conocer de antemano las direcciones de memoria de los elementos que usaremos. Existen dos formas de conocer la dirección de memoria que necesitamos:
- La primera y para mi gusto la mas sencilla es insertar la información directamente en la pila con la instrucción push y posteriormente almacenar el contenido del registro ESP.
- La segunda es utilizar una combinacion de las instrucciones jmp y call para almacenar la dirección de memoria que queremos aprovechando que call almacena la dirección de retorno en el stack, dado que utilizaremos el primer método podéis ver un ejemplo de esto aquí.
Veamos como implementar el primer método en un ejemplo que nos muestre una cadena por pantalla ampliando el código anterior. Como hemos dicho insertaremos la cadena directamente en la pila por lo que si queremos escribir el string “Hola Highsec ; )” debemos pasarlo a hexadecimal e insertar su valor.
Dado que estamos trabajando en un entorno Little Endian los strings deben de ser almacenados en memoria en orden inverso por lo que nuestro ejemplo quedará de la siguiente manera:
Hola Highsec ; ) = 48 6f 6c 61 20 48 69 67 68 73 65 63 20 3b 29
Hola = 48 6f 6c 61
Highsec = 48 69 67 68 73 65 63
; ) = 3b 29
Nota: Los espacios se traducen a 0x20
; ) = 0x20293b20
hsec = 0x63657368
Hig = 0x67694820
Hola = 0x616c6f48
Finalmente juntamos todo en nuestro código y lo compilamos para probar que todo funciona correctamente:
section .text
global _start
_start:
xor eax, eax ; Limpiamos los registros que vamos a usar
xor ebx,ebx
xor ecx, ecx
xor edx, edx
mov al, 4 ; pasamos a al el valor de la syscall write()
mov bl, 1 ; pasamos a bl el valor del file descriptor 1 (stdout)
push 0x20293b20 ; almacenamos el string directamente en el stack
push 0x63657368
push 0x67694820
push 0x616c6f48
mov ecx, esp ; Guardamos en ecx el valor de esp que corresponde al string que acabamos de insertar.
mov dl, 15 ; indicamos el largo de la cadena
int 0x80 ; Ejecutamos la llamada
mov al, 1 ; Pasamos el numero de la syscall al registro al (1)
xor ebx, ebx ; Usando xor logramos un byte nulo en ebx
int 0x80 ; Ejecutamos la llamada
Como se aprecia en la imagen nuestro shellcode no contiene bytes nulos, el string que queremos imprimir se almacena directamente en el stack y una vez compilado la ejecución es satisfactoria, por lo tanto estamos listos para preparar el payload que utilizaremos para la ejecución del exploit en el siguiente post.Generando el payload:
Para generar el payload que utilizaremos en la explotación solamente deberemos pasar los bytes de nuestro programa al formato que veis abajo, para ver los bytes del programa podeis usar el comando objdump utilizando la opcion -d del siguiente modo objdump -d <shellcode_compilado>
\x31\xc0\x31\xdb\x31\xc9\x31\xd2\xb0\x04\xb3\x01\x68\x20\x3b\x29\x20\x68\x68\x73\x65\x63\x68\x20\x48\x69\x67\x68\x48\x6f\x6c\x61\x89\xe1\xb2\x0f\xcd\x80\xb0\x01\x31\xdb
Artículo cortesía de Adrián – adrian@highsec.es – @shellshocklabs