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

sl_compress_init heap overflow via unvalidated max_state from PPP IPCP negotiation

Field Value
ID DF-0677
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CWE CWE-787 Out-of-bounds Write
File sys/net/ppp_layer/slcompress.c
Lines 69, 77-79
Area net/ppp (VJ compression init)
Confidence certain
Discovered 2026-07-02
Reported pending

Summary

sl_compress_init() does not clamp its max_state parameter against MAX_STATES (16). The sppp caller passes p[4] β€” an attacker-controlled byte from the IPCP compression option β€” directly from a remote PPP peer. When max_state > 15, the initialization loop writes tstate[i].cs_id and tstate[i].cs_next out of bounds past the 16-entry tstate[] array, producing a massive heap overflow (up to ~32KB for max_state=255) in the M_TEMP kernel heap zone. This is reachable without authentication when no LCP auth option is negotiated.

Root cause

sl_compress_init() at sys/net/ppp_layer/slcompress.c:63-87:

64: sl_compress_init(struct slcompress *comp, int max_state)
65: {
69:     if (max_state == -1) {
70:         max_state = MAX_STATES - 1;        /* sentinel only */
71:         bzero((char *)comp, sizeof(*comp));
72:     } else {
73:         bzero((char *)comp->tstate, sizeof(comp->tstate));
74:         bzero((char *)comp->rstate, sizeof(comp->rstate));
75:     }
77:     for (i = max_state; i > 0; --i) {
78:         tstate[i].cs_id = i;
79:         tstate[i].cs_next = &tstate[i - 1];
80:     }

Line 69 checks only if (max_state == -1) (sentinel). For any other value, max_state is used as-is. The loop at line 77 writes tstate[i] for every i from max_state down to 1. Since tstate[] has only MAX_STATES=16 entries (indices 0-15), any max_state > 15 causes OOB writes.

The caller sppp_ipcp_RCR at sys/net/sppp/if_spppsubr.c:2976 passes p[4] directly β€” a raw byte (0-255) from the IPCP Configure-Request compression option received from the remote PPP peer. The second caller sppp_ipcp_RCN_nak at if_spppsubr.c:3167 is equally vulnerable.

For max_state=255: i is u_int, iterates 255β†’1. Entries i=16..255 write 240 cstate-sized chunks (144 bytes each = ~34,560 bytes) past the slcompress allocation into adjacent M_TEMP heap. Each write deposits a kernel pointer (cs_next = &tstate[i-1]) and a byte (cs_id = i&0xff).

Threat model & preconditions

  • Attacker: a PPP peer (serial line, PPPoE session, or any link using sppp). In the common no-auth configuration (trusted links, misconfigured PPPoE servers, leased lines), the attacker is unauthenticated β€” sppp transitions to PHASE_NETWORK without authentication when no LCP auth option is negotiated (if_spppsubr.c:2588-2592). VJ compression is enabled by default (CONF_ENABLE_VJ at if_spppsubr.c:959).
  • Trigger: send a single IPCP Configure-Request with option type=2 (compression), protocol=0x002D (VJ), max_state=255, compress_cid=0.
  • Impact: kernel memory corruption β€” reliable panic (DoS) from corrupted heap metadata/objects, and potential kernel code execution (privilege escalation) with heap grooming. The overflow writes are at a known, controllable stride (144 bytes), making heap grooming feasible.

Clamp max_state to [0, MAX_STATES-1] before the loop:

--- a/sys/net/ppp_layer/slcompress.c
+++ b/sys/net/ppp_layer/slcompress.c
@@ -69,6 +69,13 @@
    if (max_state == -1) {
        max_state = MAX_STATES - 1;
        bzero((char *)comp, sizeof(*comp));
+   } else {
+       /* Clamp to prevent heap overflow from malicious callers
+        * (e.g. unvalidated IPCP max_state from remote PPP peer). */
+       if (max_state < 0 || max_state > MAX_STATES - 1)
+           max_state = MAX_STATES - 1;
        bzero((char *)comp->tstate, sizeof(comp->tstate));
        bzero((char *)comp->rstate, sizeof(comp->rstate));
    }

Additionally, validate p[4] in the sppp callers as defense-in-depth.

References

Timeline

  • 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
  • 2026-07-02 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0677 Β· 13 files
FileTypeDescriptionSize
slc_oob.c trigger-source harness: kmalloc slcompress + guard region, call sl_compress_init(comp,255), detect OOB 3.0 KB view raw
Makefile build-config compiles real slcompress.c from /usr/src into the module 332 B ↓ download
build.sh build-script make -> slc_oob.ko 198 B view raw
run.sh run-script kldload ./slc_oob.ko 255 B view raw
build.log build-log final successful module build 6.8 KB view raw
run.log run-log baseline (unfixed): OVERFLOW_DETECTED evidence + hexdump 1.3 KB view raw
fix_run.log run-log fixed: NO_OVERFLOW 923 B view raw
fix.diff suggested-fix clamp max_state to [0,MAX_STATES-1] before the loop 687 B view raw
env.txt environment uname / kern.version / cc version 247 B view raw
VERDICT.md verdict full narrative + primitive characterization + fix validation 5.8 KB ↓ raw
README.md readme build/run/expected 1.6 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme build/run/expected
↓ download raw

DF-0677 PoC β€” sl_compress_init heap overflow

Build (as unprivileged user maxx, or root)

cd findings/poc/DF-0677
./build.sh        # -> slc_oob.ko   (compiles the real slcompress.c into the module)

Run (as root β€” this is a primitive-proof harness for a remote-PPP bug)

./run.sh          # kldload ./slc_oob.ko ; watch dmesg
dmesg | grep DF0677

Expected (bug present, unfixed kernel/module source)

DF0677: RESULT=OVERFLOW_DETECTED  2015 guard bytes clobbered, farthest write at +32123 bytes past struct
DF0677: guard[0..15]: a0 d1 44 18 01 f8 ff ff aa aa 20 aa aa aa aa aa

i.e. sl_compress_init(comp, 255) wrote ~32 KB past the 4656-byte struct slcompress, depositing kernel pointers (cs_next) and a controlled byte (cs_id=0x20) into adjacent kernel heap. (No panic β€” the corruption is silent; on a real sppp link this corrupts M_TEMP neighbor objects.)

Expected (after applying fix.diff to /usr/src/sys/net/ppp_layer/slcompress.c)

DF0677: RESULT=NO_OVERFLOW  guard region untouched (clamp held, max_state=255)

Harness notes

  • slc_oob.c replicates sppp's allocation exactly (kmalloc(sizeof(struct slcompress), M_TEMP), if_spppsubr.c:964) and calls sl_compress_init(comp, 255) (if_spppsubr.c:2976 with p[4]=255).
  • A 64 KB guard region filled with 0xAA sits right after the struct; any byte that is not 0xAA after the call is an OOB write. The first 16 bytes are hexdumped to show the deposited pointer + controlled id.
  • The real slcompress.c is compiled into the module (.PATH: /usr/src/sys/net/ppp_layer), so applying fix.diff to that file and rebuilding this module tests the exact fix.
VERDICT.md verdict full narrative + primitive characterization + fix validation
↓ download raw

DF-0677 β€” sl_compress_init heap overflow via unvalidated max_state

Verdict: REPRODUCED (heap overflow primitive confirmed + fix validated)

sl_compress_init(comp, max_state) (sys/net/ppp_layer/slcompress.c:64-87) does not clamp max_state against MAX_STATES (16). The loop

for (i = max_state; i > 0; --i) {
    tstate[i].cs_id = i;            /* controlled byte */
    tstate[i].cs_next = &tstate[i-1]; /* kernel pointer */
}

writes tstate[16..max_state] out of bounds past the 16-entry tstate[] array embedded in struct slcompress. The sppp caller passes p[4] β€” a raw byte (0..255) taken verbatim from a remote PPP peer's IPCP Configure-Request compression option (sys/net/sppp/if_spppsubr.c:2976, and the NAK path at :3167). sp->pp_comp itself is a kmalloc(sizeof(struct slcompress), M_TEMP) (if_spppsubr.c:964), so the overflow corrupts the adjacent M_TEMP kernel heap.

Primitive characterization (measured on this guest)

property value
allocation overflowed kmalloc(sizeof(struct slcompress)=4656, M_TEMP) β†’ kmalloc-8192 bucket
worst-case overflow max_state=255 β†’ writes tstate[32..255] past the struct = ~32 KB (32123-byte span)
bytes actually written 2015 bytes, in scattered 9-byte chunks (8-byte pointer + 1-byte id) at a 144-byte stride
content control per chunk: a kernel pointer (cs_next=&tstate[i-1], heap-relative, predictable w/ KASLR off) and a controlled byte (cs_id = i & 0xff, attacker-chosen via max_state)
immediate effect no panic β€” overflow lands in mapped slab/kmem pages, corruption planted silently

Decisive harness evidence (dmesg after kldload slc_oob.ko, baseline/unfixed):

DF0677: RESULT=OVERFLOW_DETECTED  2015 guard bytes clobbered, farthest write at +32123 bytes past struct
DF0677: guard[0..15]: a0 d1 44 18 01 f8 ff ff aa aa 20 aa aa aa aa aa
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^         ^^
                       kernel pointer 0xfffff8011844d1a0   cs_id=0x20 (=32, attacker-controlled)

Reachability & threat model

  • Trigger path (as filed): remote, unauthenticated PPP peer. sppp reaches PHASE_NETWORK (and thus IPCP) without authentication when no LCP auth option is negotiated (if_spppsubr.c:2588-2592); VJ compression is enabled by default (CONF_ENABLE_VJ, if_spppsubr.c:959). A single crafted IPCP Configure-Request (option type=2 IPCP compression, proto=0x002d VJ, max_state=255) reaches the sink. Common on serial PPP, PPPoE, leased lines.
  • Local reachability: the same sl_compress_init is also called from the netgraph ng_vjc node (sys/netgraph7/vjc/ng_vjc.c:316/322) with c->maxChannel from a NGM_VJC_SET_CONFIG control message β€” reachable from userspace via ng_socket if that module is loadable by the caller (root-only kldload, but ng_socket control itself is not privilege-gated). This is a potential local-unprivileged path; not exercised in this run.

Escalation ceiling / why no uid=0 line

This is a remote-peer-driven memory-corruption bug, not a local-unprivileged syscall bug. The "local unprivileged user β†’ uid=0" primary question does not map cleanly: locally instantiating a sppp interface (the filed trigger) requires root (ifconfig create), and the attacker is the remote PPP peer. Given the guest has no SMAP / no SMEP / no KASLR, the realistic ceiling is remote kernel code execution: the 32 KB attacker-influenced heap write (controlled byte + predictable heap pointers, fixed 144-byte stride) is a strong grooming primitive β€” a remote attacker who can hold the PPP link open can shape adjacent M_TEMP slabs, corrupt a victim object holding a function pointer / ucred *, and redirect it to userspace-resident shellcode (no SMEP) that escalates. Full remote heap-grooming β†’ code-exec was not developed in this session (it requires a live PPP-link harness); the primitive is, however, fully demonstrated and characterized above. A local escalation via the ng_vjc path is the most promising follow-up.

PoC changes

  • Wrote slc_oob.c + Makefile: a loadable harness that replicates sppp's exact allocation (kmalloc(sizeof(struct slcompress), M_TEMP)) and calls sl_compress_init(comp, 255), using a 64 KB guard region + 0xAA sentinel to detect the OOB deterministically and hexdump the first written chunk. It compiles the real slcompress.c from the source tree (no dependency on the unloaded sppp/sl module), so it exercises the actual vulnerable code.
  • build.sh / run.sh: make then kldload ./slc_oob.ko.

Fix (fix.diff)

Clamp max_state to [0, MAX_STATES-1] after the max_state == -1 block, before the loop, covering all callers (sppp p[4], ng_vjc c->maxChannel, and any future caller) and the negative-int case (which would otherwise wrap a u_int loop index):

if (max_state < 0 || max_state > MAX_STATES - 1)
    max_state = MAX_STATES - 1;

This matches the finding proposal's intent (clamp before the loop) but is placed as a single clamp point rather than inside the else branch, so it also protects the max_state == -1 re-set path and negatives. Validated: baseline OVERFLOW_DETECTED (2015 bytes, pointer+0x20) β†’ fixed NO_OVERFLOW (guard untouched).

Kernel references (verified)

Fix verification

fixed

validated

see evidence pack

Confirmed kernel references

β€”

Detail

Exploit chain

none

Evidence (decisive lines)

β€”

Verdict

REPRODUCED. sl_compress_init no max_state clamp -> 32KB heap overflow with kernel ptrs + controlled byte at 144B stride. Remote PPP unauth. Harness-confirmed.