β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3537

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3537 Β· 13 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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).

  1. Sanity / mirror check β€” a legal 19-byte frame with checksum bytes C7 5D yields cstate.th_sum = 0xC75D via NGM_VJC_GET_STATE: the engine's read offsets are exactly as modeled (run.log [sanity]).
  2. Deterministic oracle β€” a 2-byte frame [0x40, 0x01] forces the mandatory checksum read (slcompress.c:524) to offsets 2,3 β€” past m_len=2. 30/30 rounds (and 30/30 in a second run) wrote stale mbuf bytes into th_sum (observed 0x1111 β€” the window field of the preceding sync packet, i.e. cross-packet residue; earlier diagnostic runs also produced 0x6572, 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.
  3. End-to-end exfil β€” 4-byte probes (0x67 change bitmap) walk offsets 4..15 (all OOB); diagnostic runs corrupted th_urp/th_win/th_ack/ip_id with earlier-packet residue (diag10: urp=0x33 win+=0x34 traced to the sync frame's seq bytes) and a clean emit frame delivered the corrupted header out the ip hook (emit output matched the cstate exactly, ip_id+1 implicit 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)

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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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)
↓ fix.diffper-fix-DF-3537

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.