VJ decompression OOB read on the vjcomp hook: short compressed frames make the kernel read past m_len into stale mbuf data that persists in rstate[] and is re-emitted inside reconstructed TCP/IP headers (kernel heap info leak)
Summary
ng_vjc's vjcomp path pulls up only min(MAX_VJHEADER=19, m_pkthdr.len) bytes but sl_uncompress_tcp_core() walks up to 19 bytes driven by the attacker's change bitmap with no buflen validation: the mandatory checksum read at slcompress.c:524 touches offsets 2-3, the DECODE* macros each consume 1-3 bytes unchecked, and the only length check runs after all reads (slcompress.c:571-575). Frames of 2..18 bytes make the kernel read 1-17 bytes past the packet end into the mbuf's stale contents; the values are written into persistent rstate[cid].cs_ip and re-emitted inside reconstructed TCP/IP headers by later clean frames on the ip hook. VERIFIED on the guest: 2-byte probe wrote stale bytes (0x1111 = prior sync frame's TCP window field) into kernel state in 30/30 rounds; 4-byte probes walk offsets 4-15 (all OOB); clean emit reconstructs the header carrying the leaked bytes out the ip hook. Remote peer of any VJ-compressed link (same surface as DF-3272 sppp VJ). Engine-level fix validated: rebuilt ng_vjc.ko with bounds-checked DECODE macros/checksum read - oracle 30/30 -> 0/30 with legal 19-byte frames still decompressing. Also killed: rmnode reentrancy, maxChannel config ([3,15] - DF-3272 engine overflow NOT reachable from this front-end), SET_CONFIG state machine, GET_STATE padding, engine write paths (all within cs_hdr[128]).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3537 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| vjc_oob_leak.c | β | 11.0 KB | view raw | |
| build.sh | β | 114 B | view raw | |
| run.sh | β | 136 B | view raw | |
| build.log | β | 128 B | view raw | |
| run.log | β | 3.2 KB | view raw | |
| run.2.log | β | 3.2 KB | view raw | |
| fix_run.log | β | 1.0 KB | view raw | |
| fix_build.log | β | 171 B | view raw | |
| leak_sample.txt | β | 1.1 KB | view raw | |
| env.txt | β | 316 B | view raw | |
| fix.diff | β | 1.7 KB | view raw | |
| VERDICT.md | β | 4.6 KB | β raw | |
| verdict.json | β | 3.7 KB | view raw |
DF-3537 VERDICT β reproduced (kernel heap info leak)
Bottom line
Reproduced. An unprivileged frame source for a configured VJ
decompression path (the remote PPP peer in real deployments; root-configured
netgraph sockets in the local lab) makes the kernel read up to 17 bytes past
the end of a 2..18-byte "compressed TCP" frame and persist those bytes in
kernel-resident decompression state, from which they are re-emitted inside
reconstructed TCP/IP headers. Impact class: kernel memory info leak
(stale mbuf contents / cross-packet data disclosure). Not memory corruption;
no write crosses an object boundary (all writes land inside struct cstate).
How it reproduces (guest: DragonFly 6.5-DEVELOPMENT #0, stock INVARIANTS kernel)
Graph: vinj:d <-> vjc:vjcomp, vsyn:d <-> vjc:vjuncomp, vrcv:d <-> vjc:ip,
configured decompression-only (enableDecomp=1).
- Sanity / mirror check β a legal 19-byte frame with checksum bytes
C7 5Dyieldscstate.th_sum = 0xC75DviaNGM_VJC_GET_STATE: the engine's read offsets are exactly as modeled (run.log[sanity]). - Deterministic oracle β a 2-byte frame
[0x40, 0x01]forces the mandatory checksum read (slcompress.c:524) to offsets 2,3 β pastm_len=2. 30/30 rounds (and 30/30 in a second run) wrote stale mbuf bytes intoth_sum(observed0x1111β the window field of the preceding sync packet, i.e. cross-packet residue; earlier diagnostic runs also produced0x6572,0x7995,0x0033/0x0034β see leak_sample.txt). The 2-byte frame itself is dropped (vjlen 4 > buflen 2) but the OOB bytes are already saved in the cstate. - End-to-end exfil β 4-byte probes (
0x67change bitmap) walk offsets 4..15 (all OOB); diagnostic runs corruptedth_urp/th_win/th_ack/ip_idwith earlier-packet residue (diag10:urp=0x33 win+=0x34traced to the sync frame's seq bytes) and a clean emit frame delivered the corrupted header out theiphook (emit output matched the cstate exactly,ip_id+1implicit increment included). The DECODE-field oracle is recycling-dependent (stale bytes often coincide with current state, which self-masks); the 2-byte checksum oracle is deterministic.
Root cause (path:line)
sys/netgraph/vjc/ng_vjc.c:456-461β pullup guarantee is onlymin(MAX_VJHEADER, m->m_pkthdr.len)bytes;sys/net/ppp_layer/slcompress.c:524β mandatory 2-byte checksum read before any length validation;sys/net/ppp_layer/slcompress.c:115-140β DECODE* macros read 1β3 bytes each with nobuflencheck;sys/net/ppp_layer/slcompress.c:571-575β the only length check runs after all reads.
Exploit chain
unprivileged/remote frame source β short vjcomp frame β engine reads stale mbuf bytes past packet end β values persist in rstate[cid].cs_ip β subsequent clean compressed frame carries them inside the reconstructed TCP/IP header to the ip hook (network side) β attacker reads reconstructed stream. Per probe pair: up to 2 bytes (checksum oracle, node-state visible) plus up to ~10 bytes (DECODE fields, network-visible). Repeated at will; leaks whatever previously occupied the mbuf (other sessions' traffic, kernel structures; adjacent-heap reads possible with cluster-end layouts).
Fix validation
fix.diff adds buf+buflen bounds checks to every read in
sl_uncompress_tcp_core() (DECODE macros, changes/CID/checksum reads, and
an ip header minimum for the UNCOMPRESSED path). Rebuilt ng_vjc.ko
(the module compiles slcompress.c) and re-ran the identical PoC:
- baseline (unpatched): oracle 30/30 leaks,
run.log/run.2.log - patched: oracle 0/30 leaks,
[sanity] th_sum = 0xC75D OK(legal 19-byte frames still decompress; no regression),fix_run.log
Same validation performed for the ng7 twin (DF-3538): 30/30 β 0/30.
Threat model / severity notes
Reaching the vjcomp hook requires a configured VJ-compression path β the same precondition class as DF-3272 (sppp IPCP VJ max-slot OOB write, rated High): a PPP peer that controls the compressed stream. The data is unauthenticated link-layer input. Locally, building the graph needs root, so the local lab PoC is a demonstration vehicle, not the threat. Leak-only (no write primitive) β Medium.
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedfix.diff applied to /usr/src/sys/net/ppp_layer/slcompress.c in the guest; ng_vjc.ko (which compiles slcompress.c) rebuilt and reloaded; identical PoC run: 2-byte-probe oracle went from 30/30 leaked stale bytes to 0/30 while the legal-frame sanity check (th_sum=0xC75D) still passes. Same result for the ng7 twin module.
fix_run.log (0/30 + sanity OK), fix_build.log (module relink), compare run.log (30/30)
Confirmed kernel references
Detail
Exploit chain
remote/local frame source -> short vjcomp frame -> sl_uncompress_tcp_core reads 1-17 bytes past m_len (checksum read + DECODE walk, no buflen checks) -> stale mbuf bytes persist in rstate[cid].cs_ip -> next clean compressed frame emits them inside the reconstructed TCP/IP header to the ip hook -> attacker reads reconstructed stream (up to ~10 network-visible bytes per probe pair, unlimited repetitions)
Evidence (decisive lines)
run.log / run.2.log: '[oracle] 30/30 rounds leaked non-zero stale bytes via th_sum'; leak_sample.txt (cross-packet traces incl. 0x6572, 0x7995, urp=0x33/win+=0x34 from a prior sync's seq field); fix_run.log: 0/30 after fix.diff with '[sanity] th_sum = 0xC75D OK'
PoC changes
Seed sketch was fully rewritten: real graph is built with libnetgraph (NGM_MKPEER/CONNECT/NAME + NGM_VJC_SET_CONFIG with binary struct ngm_vjc_config); deterministic oracle is the 2-byte-probe checksum read observed through NGM_VJC_GET_STATE (a struct slcompress mirror verified by a legal 19-byte frame); v1 path addressing requires trailing colons ('vinj:'); EINVAL from NgSendData is the node's synchronous frame-drop, not an error.
Verified recommended fix
Bounds-check every read in sl_uncompress_tcp_core against buf+buflen (DECODE macros, changes/CID/checksum, struct ip minimum) and fail the frame via the existing 'bad' path; see fix.diff.
Verdict
Reproduced on DragonFly 6.5-DEVELOPMENT #0 (stock INVARIANTS kernel): ng_vjc's vjcomp path guarantees only min(19, m_pkthdr.len) bytes but sl_uncompress_tcp_core reads up to 19 bytes unvalidated; a 2-byte frame makes the mandatory th_sum read (slcompress.c:524) cross the packet boundary and 30/30 probes wrote stale mbuf bytes (0x1111 = prior sync frame's window field) into kernel cstate, read back via NGM_VJC_GET_STATE; diagnostic runs additionally showed DECODE-walk corruption of urp/win/ack/id with earlier-packet residue delivered out the ip hook by a clean emit frame. Leak-only (all writes stay inside struct cstate). fix.diff bounds-checks every engine read; rebuilt ng_vjc.ko: 30/30 -> 0/30 with legal frames intact.
No comments yet.