ng_ppp mutates its entire private state (frag TAILQ, qlen, mseq/xseq, activeLinks, conf) with zero synchronization across concurrent v1 execution contexts β double TAILQ_REMOVE/kfree/m_freem (UAF-class) if raced; empirically contained on stock socket-fed topologies but one feeder refactor away
Summary
grep for lock/token/spin/serializer in ng_ppp.c returns nothing but the timer's per-CPU crit_enter. mp_input inserts, frag_process/get_packet/frag_trim/checkstale remove-and-free, frag_reset frees the whole queue, update rewrites conf/activeLinks, and rmnode bzero+kfree's priv - all from multiple structurally concurrent v1 contexts (cpu0-netisr control plane, cpuN-netisr ksocket upcalls, BGL-held fragTimer, process-thread ng_iface output). If two mutators overlap: double TAILQ_REMOVE of a struct ng_ppp_frag, double kfree, double m_freem, list-pointer writes through freed chunks - reclaim-exploitable heap corruption. On stock socket-fed topologies delivery is funneled through the socket's cpu0 protocol port and teardown synchronizes via the soclose token handshake - empirically zero collisions in ~6000 staged windows at 700k deliveries/s; deployments with non-socket-funneled feeds (ng_ether-fed PPPoE under multi-CPU hashing, ng_tty/ng_async, driver-direct) or any refactor expose the race; remote-traffic-triggerable there. VERDICT: not_reproduced with the containment analysis as deliverable (full race harnesses in pack: mseq-parking + dup-seq reverse-walk + shutdown-vs-inflight + SET_CONFIG-toggle at 700k/s). Fix authored+validated-compile (per-node lwkt_token at every entry point, 13-hunk diff): 1000 toggle-storms + 160 race cycles clean with data path functional.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3481 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| pppfragrace.c | β | 7.9 KB | view raw | |
| pppteardown.c | β | 7.0 KB | view raw | |
| run.log | β | 1.0 KB | view raw | |
| run.teardown.log | β | 335 B | view raw | |
| fix_regression.log | β | 1.0 KB | view raw | |
| fix.diff | β | 4.0 KB | view raw | |
| VERDICT.md | β | 5.7 KB | β raw | |
| verdict.json | β | 5.6 KB | view raw |
DF-3481 VERDICT β v1 ng_ppp cross-context private-state race
Status: not_reproduced (structural defect certain by inspection; harness races contained by undocumented stock-topology funnels)
Root cause (certain, path:line)
sys/netgraph/ppp/ng_ppp.c has zero synchronization for priv state:
grep for lock/token/spin/serializer in the file returns nothing but the
timer's crit_enter (ng_ppp.c:1453), which is per-CPU only. Unlocked
shared mutators include:
- ng_ppp_mp_input insert (ng_ppp.c:1144-1159), ng_ppp_frag_process
deliveries/cap-drops (:1290-1350), ng_ppp_get_packet removes/kfrees
(:1210-1234), ng_ppp_frag_trim (:1241-1285), ng_ppp_frag_checkstale
junk-removal+delivery (:1364-1442), ng_ppp_frag_reset free-all
(:1999-2012), ng_ppp_update config rewrite (:1876-1947),
ng_ppp_rmnode bzero+free (:791-809).
Structurally concurrent contexts reaching them in v1 (no per-node funnel,
unlike netgraph7's cpu0 item funnel that made DF-0330 "latent"):
- ngintr queued data β holds the BGL (ng_base.c:2034)
- fragTimer callout β non-MPSAFE callout_init => runs under the BGL
(kern_timeout.c:580-584); armed on the configuring CPU's softclock thread
- AF_NETGRAPH pru handlers (ngc/ngd) β cpu0 netisr thread, no BGL
(uipc_socket.c:259 so->so_port = netisr_cpuport(0))
- ng_ksocket inbound upcall β inline in sowakeup (uipc_socket2.c:604) in
the udp_input netisr context
- ng_iface output β sending process's thread under only the per-interface
serializer (if_ng.c ng_iface_output), NO BGL, NO netisr β the one truly
unfuneled context, and the one that makes DF-0330's ng7 twin REAL in v1.
What was attempted (all on the stock INVARIANTS guest, root harness)
- Timer-vs-data stale-batch race (
pppfragrace.c): two-link mseq-sticker parking ([J middle-frag pin + contiguous packets]), 2.05s staleness staging, dup-seq full-queue reverse-walk hammer (walks without inserting, so only the timer delivers), 8 staggered bundles x 200 cycles. No panic. (Note: the staging had to work around DF-0386's scanner desync β gapped batches are invisible to ng_ppp_frag_checkstale; contiguity-from-head is required, which the pack's final design satisfies.) - SHUTDOWN vs in-flight data (
pppteardown.cmode 0): 400+300 attempts, hammer at ~700k deliveries/s across 6 CPU-hashed pump flows. No panic. - SET_CONFIG-toggle frag_reset vs in-flight data (mode 1): 2000+4000 attempts x ~5k deliveries per attempt window. No panic.
Why it did not reproduce (the interesting negative result)
ng_ppp_rcvdatareached via ng_ksocket: delivery is serialized through the ksocket's UDP socket βso_pru_soreceiveinng_ksocket_incomingDOMSGs to the socket's port, and connected-socket packet processing is port-forwarded to the socket's protocol thread (cpu0 for sockets created from netgraph control messages). The SHUTDOWN path additionally synchronizes via the soclose/token handshake beforeng_cutlinks.- The fragTimer holds the BGL, as does ngintr β those two never race each other; they only race non-BGL contexts, and every non-BGL inbound feeder I could build is socket-funneled as above.
- The one unfuneled feeder (ng_iface output) feeds the transmit side; it exercises mp_output/strategy (DF-3482) β and the iface node types themselves panic on creation on this stock kernel (see DF-3482 pack), blocking a hybrid topology.
Conclusion: on stock single-box topologies with socket-terminated links, v1 ng_ppp execution is effectively serialized by the socket-port funnel (mirroring netgraph7's cpu0 funnel that made DF-0330 latent). Deployments whose inbound feeds do NOT pass through a connected ksocket (ng_ether-fed PPPoE bundles on multi-CPU input hashing, ng_tty/ng_async-fed links via ngintr, driver-direct feeds) still expose genuinely concurrent mutators, and the code remains one refactor away from racing (e.g. MPSAFE soreceive, per-socket port selection, or any new feeder).
Primitive if it raced (characterization)
Concurrent ng_ppp_get_packet/frag_reset/insert on the same TAILQ gives:
double TAILQ_REMOVE of a struct ng_ppp_frag (~56B, M_NETGRAPH kmalloc)
=> double kfree + double m_freem of the fragment mbufs, plus writes of
list pointers through freed chunks β classic reclaim-based heap corruption.
Attacker-controlled content is high (seq numbers order the queue; payloads
are arbitrary), but on this guest no collision was achieved, so the
exploitation chain remains theoretical. NOT pushed to uid=0 (no primitive
demonstrated).
Fix (authored, compiled -Werror, smoke-tested; not race-validated since
the race never fires on stock topologies)
fix.diff: per-node struct lwkt_token in ng_ppp_private, acquired at
every entry point (rcvmsg / newhook / rcvdata / rmnode / disconnect /
frag_timeout). Regression: 1000 config-toggle attempts + 160 frag cycles
on the patched module completed cleanly with the data path functional
(fix_regression.log).
Side discovery (out of file scope β for the orchestrator)
ngiface/ng_eiface node creation panics the stock kernel deterministically:
panic: trying to free NULL pointer in ng_iface_constructor+0x3a2 /
ng_eiface_constructor+0xf6 from a plain root ngctl mkpeer . iface i0 inet
on a fresh boot (logs in the DF-3482 pack). Both hand-rolled-ifnet node
types are crash-on-sight; that is a separate finding for
sys/netgraph/iface/ng_iface.c and sys/netgraph/eiface/ng_eiface.c.
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
not_testablePatched module compiles (-Werror) and survives 1000 config-toggle storms + 160 frag-race cycles with the data path functional (fix_regression.log). Race-validated fix is not possible because the baseline race never collided on stock topologies (socket-port funnel).
findings/poc/DF-3481/fix_regression.log
Confirmed kernel references
- sys/netgraph/ppp/ng_ppp.c:1144
- sys/netgraph/ppp/ng_ppp.c:1210
- sys/netgraph/ppp/ng_ppp.c:1290
- sys/netgraph/ppp/ng_ppp.c:1364
- sys/netgraph/ppp/ng_ppp.c:1447
- sys/netgraph/ppp/ng_ppp.c:1876
- sys/netgraph/ppp/ng_ppp.c:791
- sys/netgraph/netgraph/ng_base.c:2034
- sys/kern/kern_timeout.c:580
- sys/kern/uipc_socket.c:259
- sys/kern/uipc_socket2.c:604
- sys/netgraph/ksocket/ng_ksocket.c:1097
Detail
Evidence (decisive lines)
['findings/poc/DF-3481/VERDICT.md β full containment analysis and attempt log', 'findings/poc/DF-3481/pppfragrace.c β 8-bundle sticker/park/stale/dup-hammer timer race harness', 'findings/poc/DF-3481/pppteardown.c β shutdown (mode 0) and SET_CONFIG-toggle (mode 1) race harness', 'findings/poc/DF-3481/run.log β timer-race cycles completing without panic', 'findings/poc/DF-3481/run.teardown.log β teardown attempts completing without panic', 'findings/poc/DF-3481/fix_regression.log β patched ng_ppp.ko (per-node token) survives toggle storm + frag cycles', 'findings/poc/DF-3481/fix.diff β per-node lwkt_token serialization (compiles -Werror)']
PoC changes
Initial design parked stale packets behind a first-only blocker with sequence gaps; that is invisible to ng_ppp_frag_checkstale because its running-seq scanner desyncs at any gap (the DF-0386 defect) -- final design pins mseq with a middle-frag 'sticker' on a second link and uses strict head-contiguity. Dup-seq hammer fragments walk the full queue (TAILQ_FOREACH_REVERSE in ng_ppp_mp_input) without inserting, so only the timer delivers stale batches. AF_NETGRAPH sockaddr paths longer than ~14 chars are truncated by the socket layer (sendto returns ENOENT); all node/hook names were shortened accordingly. mkpeer must target the OWNING node ('f1:'), not the peer path. Leftover graphs hold UDP ports forever, so ports are allocated dynamically each run.
Verified recommended fix
Add a per-node lwkt_token to ng_ppp_private acquired at every entry point (rcvmsg/newhook/rcvdata/rmnode/disconnect/frag_timeout) -- see fix.diff; porting netgraph7's per-node writer token is the long-term fix.
Verdict
v1 ng_ppp (LIVE stack, LEGACY bucket) guards NONE of its private state (no lock/token/BGL anywhere in ng_ppp.c): the frags TAILQ, qlen, mseq/xseq, activeLinks and conf are mutated by ng_ppp_mp_input/frag_process/get_packet/frag_trim/frag_checkstale/frag_reset/rmnode without any serialization, and v1 (unlike netgraph7's cpu0 item funnel, DF-0330) has structurally concurrent execution contexts: cpu0-netisr control plane (ngc_send, uipc_socket.c:259 port), cpuN-netisr ksocket upcalls (inline in sowakeup, uipc_socket2.c:604), the BGL-held 0.5s fragTimer (plain callout_init => kern_timeout.c:580 get_mplock), and process-thread ng_iface output (no BGL, no netisr). Extensive adversarial attempts on the stock INVARIANTS guest did NOT reproduce corruption: 4000+2000 SET_CONFIG-toggle frag_reset races at ~700k deliveries/s across CPU-hashed pump flows, 700 shutdown-vs-inflight races, and 1600 dup-hammer timer-stale cycles over 8 staggered bundles all ran clean. The negative result is explained by undocumented funnels: connected-socket ksocket feeds forward delivery through the socket's cpu0 protocol port (and the soclose token handshake serializes shutdown), so every harness-reachable inbound pair executes serialized on stock topologies -- mirroring netgraph7's DF-0330 'latent' verdict. The defect remains certain by inspection and is one refactor (MPSAFE soreceive, port selection, any non-socket feeder such as ng_ether-fed PPPoE bundles with multi-CPU input hashing) away from racing; primitive if raced: double TAILQ_REMOVE/kfree of struct ng_ppp_frag + double m_freem (reclaim-exploitable heap corruption). Fix authored and regression-tested (per-node lwkt_token), not race-validated because no collision was achievable.
No comments yet.