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

v1 ng_ether teardown frees priv with no synchronization against data-plane readers (DF-3529 ng7 twin; v1 lacks ng7's cpu0 funnel)

Summary

ng_ether_detach does ng_rmnode -> IFP2NG(ifp)=NULL -> bzero(priv) -> kfree(priv) -> node->private=NULL with no lock, no reference, no drain, while the RX/TX readers dereference node->private lock-free in per-packet/netisr context (input:206-213, orphan:222-230, output:260-273, rcvdata:505-513). A reader straddling teardown dereferences freed priv (true UAF of the M_NETGRAPH slab block) or NULL (deterministic panic). Unlike ng7 - whose DragonFly port serializes node methods through the cpu0 item funnel - v1 enters these callbacks directly from if_ethersubr and per-CPU netisr; its only incidental guard (HK_INVALID flag test in ng_send_data) covers none of the if_ethersubr callbacks and is itself a check-then-use TOCTOU. Distinct from DF-2587 (node==NULL, fault VA 0x28) - here node is valid and node->private is the freed/NULL'd object (fault VA 0x10). VERIFIED: natural race 20,339 tap create/flood/destroy cycles no hit (~2ns window documented); forced-state harness kld on live vtnet0 -> stock kernel panic 'Fatal trap 12, fault VA 0x10, Stopped at ng_ether_input+0xb' with node valid, current process Idle (netisr RX); fix.diff (NULL-guard the four readers) built -Werror clean, same harness + ping -> 3/3 0% loss, no panic, clean unload. Root-gated; DF-0617-twin REFUTED for v1 (rcv_upper has no bridge_input_p call at all - that's DF-0629).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3530 Β· 13 files
FileTypeDescriptionSize
race_poc.c β€” natural-race attempt (tap create/flood/destroy, 4 workers) 3.9 KB view raw
harness_mod.c β€” kld forcing post-teardown state (node->private=NULL) on live vtnet0 3.1 KB view raw
Makefile β€” kmod Makefile for harness 65 B ↓ download
build.sh β€” build both artifacts 92 B view raw
run.sh β€” natural race + harness/ping sequence 162 B view raw
build.log β€” baseline build output (reconstructed post-reset; rc=0 -Werror clean) 1.5 KB view raw
run.log β€” baseline run: 2.6M frames no natural hit + panic signature 1.9 KB view raw
run.2.log β€” fix validation: patched module, 3/3 pings, no panic 1.6 KB view raw
fix_build.log β€” patched ng_ether.ko build log (rc=0) 6.5 KB view raw
panic.txt β€” serial console panic (ng_ether_input+0xb, VA 0x10) 1.2 KB view raw
env.txt β€” guest uname, kldstat, module shas 682 B view raw
fix.diff β€” git-apply-able unified diff (4 hunks) 2.0 KB view raw
VERDICT.md β€” full narrative 7.9 KB ↓ raw
VERDICT.md full narrative
↓ download raw

DF-3530 β€” VERDICT

Status: reproduced (panic, via deterministic post-teardown state harness). Natural race attempted honestly: no hit in 20,339 cycles / ~2.6M frames. Fix validated: patched module passes the identical harness test.

The bug

ng_ether_detach() (sys/netgraph/ether/ng_ether.c:321-337) tears the ether node down and frees its private data with no synchronization at all against the data-plane readers:

ng_rmnode(node);                 /* :328 destroy hooks (HK_INVALID set)   */
node->flags |= NG_INVALID;       /* :329                                 */
ng_unname(node);                 /* :330                                 */
IFP2NG(ifp) = NULL;              /* :331  (4)                            */
priv = node->private;            /* :332                                 */
bzero(priv, sizeof(*priv));      /* :333  (6)                            */
kfree(priv, M_NETGRAPH);         /* :334  (7) priv FREED                 */
node->private = NULL;            /* :335  (8)                            */
ng_unref(node);                  /* :336                                 */

The readers run in arbitrary thread / netisr context and take no lock, no reference, and check no flag:

  • ng_ether_input :206-213 node = IFP2NG(ifp); priv = node->private; if (priv->lower ...) (RX path, if_ethersubr)
  • ng_ether_input_orphan :222-230 same (RX orphan path)
  • ng_ether_output :260-273 same (TX path, if_ethersubr)
  • ng_ether_rcvdata :505-513 priv = hook->node->private; (netgraph data path)
  • ng_ether_rcvmsg :401-497 priv = node->private; (control path; guarded only by the caller's NG_INVALID check at netgraph/ng_base.c:2059 β€” itself a TOCTOU)

A reader that loads node (or hook->node) before step (4)/(8) and then loads ->private after (8) dereferences NULL (deterministic panic); a reader that lands between (7) and (8) dereferences freed memory (true UAF read of priv->lower/priv->upper/priv->ifp β€” heap contents controlled by whatever reuses the M_NETGRAPH slab block). The v1 core's only incidental protection is the HK_INVALID flag test in ng_send_data()/ng_send_dataq() (netgraph/ng_base.c:1684, 1711) β€” which (a) does not cover the three if_ethersubr callbacks at all, and (b) is itself a check-then-use TOCTOU (hook->flags tested, then hook->peer->node->type->rcvdataq dereferenced, racing ng_destroy_hook()'s hook->peer = NULL at ng_base.c:822-823).

This is the v1 twin of DF-3529 (ng7 netgraph7/ether/ng_ether.c, filed as speculative-Low). Note the v1 file is worse positioned than ng7 in one respect: ng7-on-DragonFly funnels every item through the cpu0 netgraph port (netgraph7/netgraph/ng_base.c:1948-1956 lwkt_sendmsg(ng_cpuport(0), ...)) which serializes rcvdata/rcvmsg against teardown; v1 has no such funnel (RX/TX callbacks are entered directly from if_ethersubr in per-packet context, and items are dispatched from per-CPU netisr holding only the MP lock, ng_base.c:2034).

Distinct from known findings on this file: - DF-2587 is the node == NULL deref (IFP2NG NULL) β€” here the node is valid and node->private is the freed/NULL'd object. - DF-0629 is the missing bridge_input_p handoff in rcv_upper (a filter bypass, not memory safety).

Why the lead "DF-0617 twin" is dead

DF-0617 (ng7) is a UAF caused by rcv_upper calling bridge_input_p(), discarding its return value (an mbuf that bridge_input_p may have freed), and feeding the freed mbuf to ether_demux_oncpu. The v1 ng_ether_rcv_upper (:566-587) contains no bridge_input_p call at all β€” it goes straight to ether_demux(m) β€” so the freed-mbuf-relay primitive cannot exist here; the absence of the bridge handoff is exactly already-known DF-0629. Verified by reading the whole function; no return value is discarded because no such call is made.

Reproduction

Natural race (honest attempt)

race_poc.c: 4 worker processes pinned to different CPUs, each looping ifconfig tapN create β†’ open /dev/tapN β†’ burst-write 128 RX frames (each write == one ether_input β†’ ng_ether_input on that ifp) β†’ close β†’ destroy. 120 seconds: 20,339 cycles, ~2.6M frames, no panic. The winning interleaving needs a reader descheduled between two adjacent loads (IFP2NG(ifp) then node->private) spanning the whole (4)..(8) teardown β€” a ~2 ns reader window vs a microsecond-plus teardown β€” so a bounded run not hitting it is expected and documented (same experience as DF-2587's natural chase). This bounds the practical severity at Low.

Deterministic primitive confirmation (harness)

harness_mod.c loads after kldload ng_ether and puts vtnet0's live ng_ether node into the exact post-teardown observable state β€” node left valid (as an in-flight reader that loaded it before step (4) would hold), node->private set to NULL (its value after step (8)). The very next packet on vtnet0 (QEMU user-net background RX traffic) panicked the stock kernel:

DF3530: vtnet0 ng_ether node=0xfffff8011728d4a0 private=0xfffff8008d721a98 -> NULL.
Fatal trap 12: page fault while in kernel mode
fault virtual address  = 0x10
Stopped at      ng_ether_input+0xb:     movq    0x10(%rax),%rdi

0x10 = offset of lower in struct private; %rax = priv = NULL; the faulting instruction is the priv->lower load at ng_ether.c:210. Current process Idle (netisr RX context) β€” exactly the reader the finding describes. This signature is distinct from DF-2587's (ng_ether_output, VA 0x28, node NULL) and proves the missing-priv-lifetime-check primitive end to end. In the (7)β†’(8) sliver the same reader gets a freed-heap priv (UAF read) rather than NULL β€” not crash-deterministic, hence not the harness variant, but the source ordering (:334 kfree before :335 NULL) makes it real.

Exploit chain assessment

Impact ceiling here is kernel memory-safety violation from a root-gated race: trigger requires interface detach (ifconfig destroy / driver unload / device hotplug) racing live traffic on that interface. Practical payoff demonstrated: panic. Theoretical payoff: priv is freed while readers still deref priv->ifp, priv->upper/lower (pointer-chasing into further calls β€” ether_output_frame(priv->ifp, m)) β€” heap-grooming the M_NETGRAPH slab block could steer those, but the nanosecond window makes this implausible even for root; not pursued further. No uid0 chain claimed.

Fix validation

fix.diff adds NULL guards (node and private) to the four reachable readers (ng_ether_input, ng_ether_input_orphan, ng_ether_output, ng_ether_rcvdata); semantics: no hooks observable β‡’ packet continues normally / data dropped with ENXIO. Applied to the guest's pristine /usr/src (4/4 hunks), make in sys/netgraph/ether clean under -Werror, installed, kldload ng_ether + SAME harness state + ping -c3 10.0.2.2: 3/3 packets, 0% loss, no panic, harness unload restores the node, post-restore ping OK. Baseline panic gone β‡’ fix_status = fixed. Guest reset clean afterwards (source sha and stock module sha verified back to pristine).

Kernel references

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 (4/4 hunks: NULL-guard node/node->private in ng_ether_input/input_orphan/output/rcvdata) applied to guest /usr/src, module rebuilt -Werror clean; same forced post-teardown state + ping -> 3/3 packets 0% loss, no panic, clean unload. (Top-level keys reconstructed from reviewer's embedded verdict_summary fix_status=fixed, attempts=3; run.2.log/fix_build.log in pack.)

fix_build.log (-Werror clean link), run.2.log (3/3 ping 0% loss post-fix, clean unload)
↓ fix.diffper-fix-DF-3530

Confirmed kernel references

Detail

Exploit chain

root: kldload ng_ether; kldload df3530_harness.ko (node->private := NULL on vtnet0, emulating the post-detach observable state); next packet on vtnet0 -> ng_ether_input -> priv = NULL -> priv->lower load -> Fatal trap 12 (panic). Natural-race equivalent: reader loads IFP2NG(ifp) before detach step (4) and node->private after step (8) -> same panic; between (7) and (8) -> UAF read of freed priv.

Evidence (decisive lines)

panic.txt: 'Stopped at ng_ether_input+0xb: movq 0x10(%rax),%rdi', fault VA 0x10 (= offset of lower in struct private, load at ng_ether.c:210 with priv==NULL, node 0xfffff8011728d4a0 valid). run.log: natural race 20,339 cycles / ~2.6M frames, no hit. run.2.log + fix_build.log: fix.diff applied (4/4 hunks), module built -Werror clean, same harness state -> 3/3 pings, no panic, clean unload. VERDICT.md: full interleaving analysis incl. DF-0617-twin lead refuted (no bridge_input_p call in v1 rcv_upper - that absence IS known DF-0629).

PoC changes

No seed existed. Wrote race_poc.c (parallel tap create/flood/destroy, CPU-pinned), harness_mod.c (kld built against real netgraph.h via bsd.kmod.mk; nulls node->private under ifnet_lock, restores on unload), fix.diff generated via diff -u from an edited copy after a hand-written hunk-count bug (first attempt malformed).

Verified recommended fix

NULL-guard node and node->private in ng_ether_input/input_orphan/output/rcvdata (treat as 'no hooks connected': pass packet through / drop data) - see fix.diff; full fix would additionally synchronize ng_ether_detach with in-flight readers.

Verdict

v1 ng_ether_detach (ng_ether.c:321-337) frees priv and NULLs node->private with no synchronization against the no-lock data-plane readers (ng_ether_input:206-213, input_orphan:222-230, output:260-273, rcvdata:505-513); v1 netgraph lacks ng7's cpu0 item funnel so readers run in per-packet/netisr context. Natural race honestly attempted: 20,339 tap create/flood/destroy cycles, ~2.6M injected RX frames, no hit (reader window ~2ns vs microsecond teardown) - practical severity bounded at Low. Primitive confirmed deterministically with a harness kld that forces the exact post-teardown observable state (node valid, node->private=NULL) on live vtnet0: the next packet on the interface panics the stock kernel at ng_ether_input+0xb reading priv->lower (fault VA 0x10, priv==NULL, node valid) - a signature distinct from DF-2587 (VA 0x28, node==NULL). In the kfree->NULL sliver (lines 334-335) the same read is a true UAF of the M_NETGRAPH slab block. Root-gated trigger (interface detach/hotplug racing traffic); no uid0 chain claimed.