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

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)

No comments yet.

PoC verification

Evidence pack

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

  1. 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.)
  2. SHUTDOWN vs in-flight data (pppteardown.c mode 0): 400+300 attempts, hammer at ~700k deliveries/s across 6 CPU-hashed pump flows. No panic.
  3. 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_rcvdata reached via ng_ksocket: delivery is serialized through the ksocket's UDP socket β€” so_pru_soreceive in ng_ksocket_incoming DOMSGs 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 before ng_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_testable
baseline no→ patch + rebuild →patched clean

Patched 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
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: (ng_ppp.ko rebuilt in-guest with per-node token; netgraph.ko rebuilt with DF-3483 fix)

Confirmed kernel references

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.