SYSCALL Y SHELLCODE
En este artículo nos centramos en desarrollar conceptos muy importantes a la hora de entender el funcionamiento de un Buffer Overflow como son los syscall y los shellcode.
Artículo copiado de la Fundación Sadosky escrito por Teresa Alberto.
INTRODUCCIÓN
En esta instancia el objetivo será aprovecharse de una vulnerabilidad de un programa para ejecutar código malicioso que no está en el binario original. En otras palabras, lo que se busca ejecutar es un shellcode.
Un shellcode es un código que se inyecta en la memoria de un programa vulnerable bajo la forma de un string de bytes. El nombre shellcode se refería históricamente a inyectar un programa shell que permite ejecutar cualquier otro comando, no obstante hoy el término se usa de manera general para hablar de la inyección de código malicioso. Es posible programar un shellcode para que haga cualquier cosa que se nos ocurra dado que en última instancia es en sí mismo un programa.
Un programa en C que utiliza funciones como printf()
o write()
de la biblioteca libc
, usa esta biblioteca para realizar llamadas al sistema operativo que es el encargado de manejar cuestiones como la escritura, lectura y ejecución de programas. Hay que tener en cuenta que el shellcode no se va a cargar en memoria por el sistema operativo, sino que directamente es copiado a la memoria del programa vulnerable como una cadena de caracteres, aprovechando funciones como strcpy()
y gets()
.
Es por ello que, si nuestro shellcode utiliza una función como write()
(lo más probable es que necesitemos que lo haga) esas llamadas al sistema operativo deben ser manejadas directamente. Es necesario entonces comprender el funcionamiento de las llamadas al sistema antes de continuar con la creación de un shellcode en sí mismo.
LLAMADAS AL SISTEMA
A la hora de planear estrategias de ataque se usarán frecuentemente llamadas al sistema.
Los programas que corren en el espacio de usuario cuando requieren interactuar con el sistema operativo deben realizar llamadas al sistema para que el sistema operativo realice las operaciones en su nombre.
La manera en que se hace esta llamada es diferente para cada arquitectura, en el caso de x86 los programas de usuario pueden hacer una llamada al sistema con una interrupción por software con la instrucción int 0x80
.
En Linux cada llamada toma sus argumentos de los registros según el siguiente criterio:
Para saber el número que corresponde a cada syscall se puede consultar: /usr/include/asm/unistd_32.h
.
Según la syscall en cuestión será necesario definir también los valores de otros registros.
Con strace
es posible rastrear las llamadas a sistema que suceden cuando tenemos un programa en C tan simple como:
De este modo es posible rastrear la llamada al systema write
que es la encargada de efectivamente imprimir el string.
Syscall write:
Para imprimir por salida estándar (stdout
) es necesaria la syscall write.
Con los registros seteados de la siguiente manera:
Ejemplo de una syscall write
en assembler:
Syscall exit:
Para finalizar un proceso se usa el syscall exit:
Los registros deben estar seteados de la siguiente manera:
Si quisieramos ejecutar exit(0)
, el código assembler necesario sería:
Syscall execve:
Para ejecutar un programa se usa el syscall execve, que corresponde al syscall 11.
Para ejecutar execve
los registros deben estar seteados de la siguiente manera:
Para simplificar el código en assembler, buscamos ejecutar execve("/bin/sh", NULL, NULL)
(con argv[]
y envp[]
en NULL
). Para ello los registros deben estar seteados de la siguiente manera:
Y el código en assembler sería:
Ejemplo tomado de Shell storm.
Consideraciones: el uso de una doble barra de /bin//sh
es por el problema de la escritura de caracteres nulos en el shellcode. La barra extra permite alinear el string /bin//sh
en dos double-words (de 4 bytes cada una), quedando el \0
final en la siguiente word o palabra. Este alineamiento permite generar ese \0
final con un xor
, sin tener el problema de la escritura de caracteres nulos. Si en cambio se elimina la doble barra, el \0
formaría parte de la segunda double-word y con un xor
no lograríamos incluirlo en el string y un mecanismo más complejo sería necesario. La cuestión de los caracteres especiales en el shellcode se desarrolla más adelante.
Ensamblar y linkear
Cuando compilamos un programa en C, gcc
se ocupa de ensamblar el programa y linkearlo para lograr el archivo ELF ejecutable. De manera similar cuando partimos de un programa en assembler y queremos obtener un archivo ELF ejecutable podemos ensamblar el programa con nasm -f ELF
y linkearlo con ld
.
Para indicarle al linker dónde comienzan las instrucciones en assembler se agrega la línea global _start
.
Creamos un programa holaMundo.asm
en assembler con las llamadas write()
y exit()
:
Consideraciones: es importante tener en mente que el string se almacenó en la sección .data. En el siguiente apartado se retomará este punto y se explicará pórque no es posible conducirse de ese modo y se planteará una estrategia alternativa.
Lo ensamblamos:
nasm
con el argumento -f elf
ensambla el programa en un archivo objeto preparado para ser linkeado como un binario ELF. Como resultado genera el archivo objeto holaMundo.o
.
Linkeamos ese archivo objeto:
ld
va a crear un binario ELF a partir del archivo objeto.
Ejecutamos y vemos la salida estándar y la finalización del programa:
SHELLCODE
Cuando se trata de un exploit que incluye un shellcode existen 3 instancias importantes:
La programación del shellcode.
Su inyección en memoria como string de bytes.
Lograr su ejecución.
El shellcode no es un programa ejecutable como cualquier otro, sus instrucciones deben ser autocontenidas para lograr su ejecución por parte del procesador sin importar el estado actual del programa vulnerable. El shellcode no va a ser linkeado ni va a ser cargado en memoria como un proceso por el sistema operativo. Es por ello que los ejemplos de llamadas al sistema deben ser retocados para cumplir ciertos criterios:
No disponemos del segmento data: no es posible utilizar el segmento de datos en el código assembler del shellcode como se hizo con “Hola mundo” en el ejemplo anterior
holaMundo.asm
. El shellcode no se ejecutará como un programa corriente ni sus segmentos serán cargados en memoria por el sistema operativo. Es por ello que veremos maneras de manipular un string sin recurrir a la sección .data.Evitar caracteres especiales: el shellcode no debe tener caracteres especiales como
\x00
entre sus bytes porque se copia en memoria con funciones que manipulan strings comostrcpy()
. Usarlos provocaría que el shellcode quede truncado. (Es posible por prueba y error detectar qué caracter finaliza el copiado del shellcode en memoria, según la función vulnerable de la que se trate).Mínima longitud: el shellcode debe tener la mínima longitud posible porque en la mayoría de los casos no contamos con demasiado espacio en el búfer para almacenarlo.
Programar un shellcode
Es recomendable comenzar programando unx mismx los shellcodes más sencillos para comprender su funcionamiento y acercarse a la potencia de crear shellcodes ad-hoc. No obstante existen repositorios de shellcodes a disposición en Shell storm o exploit-db. Es importante manipular estos binarios con extrema precaución, por ejemplo trabajando con un entorno virtualizado como buena práctica.
Estrategias para crear un shellcode inyectable dentro de un programa vulnerable:
Suponiendo que queremos con nuestro shellcode imprimir un mensaje por salida estándar, similar al siguiente programa en C:
Seguimos la directivas indicadas anteriormente.
No disponemos del segmento data: el string “you win” debe ser almacenado en la pila directamente para evitar el uso del segmento de datos. Al hacerlo de esa manera necesitamos un puntero al string para pasarle como argumento a
write()
y lograr que se imprima ese mensaje, ya que no conocemos de antemano su dirección exacta.Para contar con la dirección del string podemos aprovechar que la instrucción
call
en assembler se encarga de almacenar en la pila la dirección que sigue alcall
antes de hacer el salto a la función llamada y de esta manera poder retornar una vez finalizada su ejecución. Agregamos uncall
ad-hoc seguido del string que sólo sirva para almacenar su dirección en un lugar conocido de la pila.shellcode.asm
Una vez almacenada la dirección del string, con la instrucción
pop
lo almacenamos en un registro para su uso posterior.Evitar caracteres especiales: se usan ciertos trucos para que al compilar el código binario no tenga caracteres nulos.
En las llamadas a sistema es frecuente tener que poner en cero un registro (por ejemplo para el status del syscall
exit
). Si en vez de copiar un cero usamos un OR exclusivo (un XOR de un valor con sí mismo siempre da cero) logramos el objetivo sin valores nulos en el código máquina.Cuando una instrucción manipula un registro de 32 bits (como
eax
) y el otro de sus operandos ocupa menos bits (por ejemplo un entero como el número 2) se completa con ceros, es decir 2 pasa a ser0000 0002
y almacenado bajo el formato little endian\x02\x00\x00\x00
como se puede ver en el código máquina de abajo. Para evitar los caracteres nulos finales es posible usar únicamente las partes del registro necesarias para la operación (por ejemplo, conal
se manipulan sólo los 8 bits menos significativos del registro).Al hacer esto, el resto de los bits de
eax
tienen datos desconocidos, lo que puede provocar un funcionamiento inesperado. Por eso es importante antes de estas operaciones poner en 0 el registro.Finalmente, logramos un código máquina resultante
\x31\xc0\xb0\x02
que no tiene caracteres nulos.¿Cómo finalizar el string a imprimir sin usar un caracter especial que trunque el shellcode (como el caracter nulo
\0
, nueva línea\x0a
o retorno de carro\x0d
)? En estos casos usamos un caracter cualquiera para finalizar el string (por ejemplo la letraA
) y después reemplazamos su valor por\0
.Como resultado final tenemos en
ecx
la dirección del stringyou win!\0
.
db es la directiva define byte que permite reservar espacio en memoria para un string, como en db 'you win!A'
Mínima longitud: en muchos casos dos instrucciones en assembler cumplen el mismo objetivo pero una de ellas consume menos bytes. Hay que estar atentxs para optar siempre por la opción menos costosa en espacio. Por ejemplo si un syscall obliga a poner en 1 un registro, existen dos maneras de hacerlo:
¡Usando
inc
en vez demov
ahorramos 8 bits! Parece poco pero es clave cuando almacenamos un shellcode en espacios de memoria reducidos.
Shellcode que imprime “you win!”
Código en assembler:
shellcode.asm
Shellcode como string Existen varias maneras de obtener el código de máquina como cadena de caracteres a partir del código assembler. Con herramientas como Online x86 de Defuse o con pwntools para trabajar en python. Otra manera simple de obtenerlo es usando
hexdump
desde la consola.Y el código de máquina obtenido debe coincidir con el código de máquina mostrado en la segunda columna al desensamblar con
objdump
:Es posible ver como el string es interpretado como instrucciones; es necesario obviarlas y convertir a ASCII el código máquina para verificar el texto “you win!A”
En todos los casos el shellcode como string que vamos a usar en los exploits para imprimir el mensaje “you win!” va a ser:
Shellcode para lograr una shell
El siguiente código de ejemplo fue tomado del libro Hacking the art of exploitation. Suponiendo que queremos obtener una shell con nuestro shellcode, de manera similar al siguiente programa en C:
Código en assembler:
shellcode.asm
Shellcode como string
El shellcode como string que vamos a usar en los exploits para obtener una shell va a ser:
Acá se presentan dos ejemplos de shellcodes, sin dudas existen muchas variantes que logran el mismo objetivo.
Tobogán de NOPs
Una dificultad a la hora de ejecutar el shellcode radica en saber exactamente en qué dirección de memoria se encuentra.
Para evitar errarle por pocos bytes se usa como recurso la instrucción No-Op o NOP (No Operation instruction). Cada NOP ocupa un byte (0x90 en Assembler) y es una instrucción que no hace nada, sólo avanza el contador del programa a la siguiente instrucción a ejecutar.
Si se agregan varias instrucciones NOP (formando un NOP sled
o tobogán de Nops que -sin hacer nada- lleven hacia la ejecución del shellcode) y se modifica el flujo de ejecución para que salte allí, sabemos que eventualmente el shellcode se va a ejecutar.
Esto permite tener margen de error al definir la dirección de retorno y aumenta las chances de ejecutar el shellcode.
REFERENCIAS
[1]. Anley, C., Heasman, J., Linder, F., Richarte, G. (2007). The Shellcoder’s Handbook: Discovering and Exploiting Security Holes. [2]. Erickson, Jon. (2008). Hacking: the art of exploitation. [3]. D’Antoine, Sophia. (2015). Shellcoding: Modern Binary Exploitation CSCI 4968. Disponible en: http://security.cs.rpi.edu/courses/binexp-spring2015/lectures/7/05_lecture.pdf
Última actualización