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

Remote peer heap out-of-bounds write via IPCP VJ Max-Slot-Id: sl_compress_init(sp->pp_comp, p[4]) unclamped, tstate[16] loop runs to peer-chosen 255

Summary

The IPCP VJ-compression option's Max-Slot-Id byte p[4] (0..255) is passed unclamped into sl_compress_init(), whose tstate loop writes past struct cstate tstate[MAX_STATES=16]: tstate[16..31] smash rstate[] inside the 4656-byte kmalloc'd slcompress object and tstate[32..255] write up to ~32KB past the heap allocation - 9 attacker-positioned bytes per 144-byte stride (valid kernel-heap pointer at +0, index byte at +10), repeating on every IPCP re-negotiation. Reachable by an unauthenticated PPP peer after a 3-frame LCP handshake (no auth by default; CONF_ENABLE_VJ is the default). VERIFIED on guest with a KLD sync-PPP harness + drive frames: kernel-memory dumps show the pointer &tstate[26] + byte 0x1b at comp+3932 and &tstate[31] + 0x20 written 16 bytes PAST the allocation - into the adjacent same-size-class heap chunk (cross-object corruption, exactly as predicted); two deferred panics in an earlier session consistent with later use of corrupted memory. Direct kernel heap corruption from the wire - at minimum remote DoS, plausibly RCE (valid heap pointers placed into adjacent 4097-5120-class objects); uid0 chain documented as needing a matching victim + grooming (per-offset values fixed by the offset). Fix validated (NAK Max-Slot-Id >= MAX_STATES at both sites + defense-in-depth clamp in sl_compress_init).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3272 Β· 13 files
FileTypeDescriptionSize
README.md β€” 4.2 KB ↓ raw
VERDICT.md β€” 4.0 KB ↓ raw
drive3.c β€” 3.8 KB view raw
poc_3272.c β€” 5.4 KB view raw
sppp_harness.c β€” 6.2 KB view raw
build.sh β€” 104 B view raw
run.sh β€” 126 B view raw
run.log β€” 3.1 KB view raw
run.fixed.log β€” 2.2 KB view raw
env.txt β€” 203 B view raw
fix.diff β€” 1.7 KB view raw
manifest.json β€” 957 B view raw
verdict.json β€” 4.8 KB view raw

DF-3272 β€” Remote peer heap OOB write via IPCP VJ "Max-Slot-Id" (sl_compress_init unclamped)

What

The peer-supplied IPCP compression option's Max-Slot-Id byte (p[4], 0..255) is passed unclamped to sl_compress_init(sp->pp_comp, p[4]) at if_spppsubr.c:2976 (CONF-REQ pass 2) and :3167 (CONF-NAK handler). sl_compress_init() then executes

for (i = max_state; i > 0; --i) {
        tstate[i].cs_id = i;            /* struct cstate tstate[MAX_STATES=16] */
        tstate[i].cs_next = &tstate[i - 1];
}

sizeof(struct cstate) is 144 (8 ptr + 2+1+1 + 128 hdr, padded); entries tstate[16..31] land in rstate[] (inside the allocation) and tstate[32..255] write up to ~32KB past the end of the 4656-byte kmalloc(sizeof(struct slcompress)) heap object β€” 9 attacker-positioned bytes every 144: a valid kernel-heap pointer (&tstate[i-1]) at +0 and the index byte i at +10.

Reachability

Unauthenticated PPP peer. Default config: CONF_ENABLE_VJ is set at attach (if_spppsubr.c:958-959), no auth is configured, so a 3-frame LCP handshake (peer ACKs the kernel's CONF-REQ, sends its own magic-only CONF-REQ) reaches PHASE_NETWORK; then ONE IPCP CONF-REQ frame with option 02 06 00 2d ff 01 (VJ, Max-Slot-Id=255, Comp-Slot-Id=1) detonates it.

Reproduced (baseline, stock INVARIANTS kernel)

Heap corruption proven by before/after kernel-memory dumps of the pp_comp neighbourhood (read back via a harness ioctl β€” introspection only):

  • BEFORE: all zeros (fresh sl_compress_init(comp, -1)).
  • AFTER: at comp+3932 (= &tstate[27]): pointer 0xfffff801185bb4d0 β€” exactly comp+48+144*26 = &tstate[26] β€” followed by byte 0x1b (27).
  • AFTER, 16 bytes past the end of the allocation, at comp+4672 (= &tstate[32]): pointer 0xfffff801185bb7a0 = comp+48+144*31, and byte 0x20 (32) β€” i.e. the neighbouring 4656-byte heap chunk was overwritten with values predicted exactly by the source analysis.

Delayed panics observed in an earlier session (jump to NULL ip=0x0, and a "vm_fault: fault on stack guard" inside sppp_cp_input's ICF clone) are consistent with later use of the corrupted objects, but were not attributably captured in a clean run (the write is sparse: 9 of every 144 bytes), so the verdict rests on the proven corruption itself.

Exploitability (honest ceiling)

  • Primitive: repeatable (every IPCP re-negotiation), attacker-chosen extent (maxslot 17..255 => reach comp+2352 .. comp+36768), sparse stride 144, values = valid kernel heap pointers + small integers. Pointer values are valid kernel addresses, so pointer-validation hardening does not help.
  • Cross-object corruption into the adjacent chunk of the same size class (4097..5120 byte kmallocs) is proven (the tstate[32] write).
  • uid0/remote-RCE chain NOT completed this run: needs a victim kernel object in that size class with an exploitable field at a matching offset (+0/+8 pointer or +10 byte) that the peer can cause to be allocated adjacent β€” a realistic but non-trivial grooming effort on DFly's size-segregated slab allocator. Documented as the escalation path.

Files

  • poc_3272.c β€” original PoC (handshake + weapon + churn + mem dumps)
  • drive3.c β€” the deterministic driver actually used for the recorded run (spray, before-dump, LCP handshake w/ ident re-sync, weapon, after-dump, unspray, churn) β€” compile & run exactly as in run.sh
  • sppp_harness.c β€” PoC harness module (see DF-3273 README)
  • run.log β€” decisive baseline run (corruption visible in AFTER dumps)
  • run.fixed.log β€” same driver on the fix.diff-patched sppp.ko: AFTER == BEFORE (zeros)
  • fix.diff β€” clamp Max-Slot-Id to MAX_STATES-1 (NAK out-of-range), same diff also carries the DF-3273 len<4 guard
  • env.txt β€” guest uname etc.

Build / run (guest, root)

cd /root/poc && make && cc -O2 -o drive3 drive3.c
kldload /root/poc/sppp_harness.ko
./drive3

Expected

Baseline: [+] LCP OPENED then AFTER-dumps show stride-144 pointer+index writes incl. past the end of the allocation (corruption), machine may or may not panic immediately (sparse write). Patched: AFTER-dumps identical to BEFORE (all zero), link negotiates on.

VERDICT.md
↓ download raw

DF-3272 β€” reproduced (remote-peer heap out-of-bounds WRITE, contents verified byte-exact); fix validated (corruption gone).

Root cause (path:line)

  • sys/net/sppp/if_spppsubr.c:2976 β€” sl_compress_init(sp->pp_comp, p[4]) with p[4] = peer's IPCP VJ Max-Slot-Id byte, unclamped (same at :3167).
  • sys/net/ppp_layer/slcompress.c sl_compress_init(): writes tstate[i].cs_id=i; tstate[i].cs_next=&tstate[i-1] for i=max_state..1, but tstate is struct cstate tstate[MAX_STATES=16] (sys/net/slcompress.h:153). sizeof(struct cstate)=144.
  • victim: sp->pp_comp = kmalloc(sizeof(struct slcompress)=4656) (if_spppsubr.c:964). tstate[32..255] => up to ~32KB past the end.

Reproduction (stock INVARIANTS kernel, guest uname in env.txt)

Peer modeled by frame injection (harness module; see DF-3273/README.md β€” sppp code itself stock). driver sequence (drive3.c): 1. read kernel LCP CONF-REQ off the "wire", ACK it (id re-sync loop), 2. send own CONF-REQ with a magic option only -> LCP OPENED, PHASE_NETWORK (no auth configured; CONF_ENABLE_VJ is the default, if_spppsubr.c:958), 3. inject ONE IPCP CONF-REQ carrying 02 06 00 2d ff 01. Kernel-memory dumps around pp_comp before/after (harness ioctl, read-only):

BEFORE: all zeros. AFTER (baseline, run.log): comp+3932 (&tstate[27]): 0xfffff801185bb4d0 (= &tstate[26] = comp+48+144*26) followed by byte 0x1b (27) <- cs_id == i comp+4672: 0xfffff801185bb7a0 (= &tstate[31]) + byte 0x20 (32) -> 16 bytes PAST the 4656-byte allocation: the ADJACENT heap chunk (a sprayed 4656-byte object modeling any same-size-class victim) was overwritten with exactly the predicted pointer+index.

So: unauthenticated peer -> kernel heap corruption, cross-object, repeatable (every IPCP CONF-REQ/NAK re-negotiation re-runs the write). No immediate panic in a clean single-shot run (the write is sparse: 9 bytes per 144-byte stride), consistent with the two deferred panics observed in an earlier session (jump to ip=0x0; "vm_fault: fault on stack guard" in the sppp_cp_input ICF clone) after the corrupted regions were later used.

Exploit chain status

  • Weaponized frame: done (pure peer frames, 4 total).
  • Primitive: attacker-chosen extent (17..255 -> reach comp+2352..comp+36768); at each 144-byte step writes a VALID kernel heap pointer (offset +0) and a small integer (offset +10). Pointer values are live kernel addresses, so the write both corrupts and (if any byte is ever reachable back) leaks kernel-heap layout β€” no KASLR break needed for the write itself.
  • Cross-object corruption into the neighbouring chunk: PROVEN (tstate[32]).
  • uid0 / remote code execution: NOT completed. Hard blocker documented: the primitive's per-offset values are fixed by the offset (pointer = &tstate[i-1], id byte = i), so escalation needs a same-size-class (4097..5120-byte) victim whose pointer/length field sits exactly at a 144n+48 / 144n+58 offset from its chunk start and whose corruption yields control β€” plus heap grooming to place it after pp_comp. On DFly's size-segregated slab allocator that is a research-scale effort; deferred, not impossible.

Fix validation

fix.diff clamps: if (p[4] >= MAX_STATES) { p[4] = sp->ipcp.max_state; break; } (NAK with our value) in sppp_ipcp_RCR, and skips the re-init in sppp_ipcp_RCN_nak. Rebuilt in-tree (cc -Werror clean), installed, rebooted, same driver: AFTER dumps identical to BEFORE (all zeros), link negotiates normally (run.fixed.log). Baseline corruption gone.

Known-findings residual (not re-reported)

DF-0269 (sppp_print_bytes VLA) and DF-0270 (PAP ACK/NAK debug OOB bound) remain present in the audited source; both are debug-path (IFF_DEBUG) issues.

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

With fix.diff applied (p[4] >= MAX_STATES -> NAK with our max_state in RCR; skip re-init in RCN_nak), the identical weapon frame leaves the heap identical to baseline (AFTER dumps all zeros, run.fixed.log) and IPCP continues negotiating. Baseline vs patched differ exactly in the corruption.

['findings/poc/DF-3272/run.log (baseline corruption)', 'findings/poc/DF-3272/run.fixed.log (patched: no corruption)', 'findings/poc/DF-3272/fix.diff']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (sppp.ko rebuilt from patched /usr/src/sys/net/sppp, cc 8.3 -Werror clean)

Confirmed kernel references

Detail

Exploit chain

peer frames only: (1) ACK kernel LCP CONF-REQ (id off the wire), (2) send magic-only CONF-REQ -> LCP OPENED -> PHASE_NETWORK (no auth by default, CONF_ENABLE_VJ default on), (3) IPCP CONF-REQ with option 02 06 00 2d ff 01 -> sl_compress_init(comp, 255) -> OOB write comp+2352..comp+36768, 144-byte stride, ptr at +0 / index byte at +10 -> [proven] adjacent same-size-class heap chunk overwritten -> [not completed] victim-object grooming for control flow / uid0

Evidence (decisive lines)

['findings/poc/DF-3272/run.log: AFTER dump at comp+3932 shows 0xfffff801185bb4d0 (= &tstate[26]) + byte 0x1b(27); dump at comp+4672 (16 bytes PAST the 4656-byte allocation) shows 0xfffff801185bb7a0 (= &tstate[31]) + byte 0x20(32) -- cross-object corruption, contents exactly as predicted', 'findings/poc/DF-3272/run.fixed.log: same driver on fix.diff-patched sppp.ko -- AFTER == BEFORE (all zeros)', 'findings/poc/DF-3272/VERDICT.md: full narrative incl. exploitability ceiling']

PoC changes

Original sketch was a standalone C file with wrong assumptions (no driver feeds sppp on this guest; ng_sppp does not compile). Rewrote as a harness KLD modeling the sync-PPP driver (own struct sppp softc + sppp_attach + if_attach + /dev/spppinj0 write->sppp_input, read->TX capture, SIOCSIFFLAGS passthrough) plus a userland driver (drive3.c) that handshakes LCP with ident re-sync (the 3s retransmit timer bumps confid), sprays the slcompress size class, dumps comp memory before/after via a harness ioctl, then churns. Syscall numbers/struct layouts were derived from the guest's own /usr/src.

Verified recommended fix

Clamp the IPCP VJ Max-Slot-Id to MAX_STATES-1 (NAK out-of-range values) before calling sl_compress_init(), at if_spppsubr.c:2976 and :3167; defense-in-depth: clamp inside sl_compress_init() too.

Verdict

Unauthenticated PPP peer heap out-of-bounds WRITE, reproduced with byte-exact contents: an IPCP CONF-REQ VJ-compression option with Max-Slot-Id=255 reaches sl_compress_init(sp->pp_comp, 255) (if_spppsubr.c:2976, also :3167), whose tstate[i] loop (tstate is cstate[16], stride 144) writes up to ~32KB past the 4656-byte pp_comp allocation. Kernel-memory dumps prove a heap pointer + index byte written into the ADJACENT 4656-byte chunk (tstate[32] at comp+4672, past the end), exactly as predicted from source. No immediate panic in a clean single shot (write is sparse: 9 bytes per 144); two deferred panics in an earlier session are consistent with later use of the corrupted objects. uid0/remote-RCE chain not completed: per-offset values are fixed by the offset (pointer=&tstate[i-1], byte=i), so escalation needs a 4097..5120-byte size-class victim with an exploitable field at a matching offset plus grooming on DFly's size-segregated slab allocator -- documented as the escalation path. fix.diff (clamp Max-Slot-Id to MAX_STATES-1, NAK out-of-range) validated in-guest: corruption gone, negotiation proceeds.