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)
PoC verification
Evidence pack
findings/poc/DF-3530 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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-213node = 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-513priv = hook->node->private;(netgraph data path)ng_ether_rcvmsg:401-497priv = node->private;(control path; guarded only by the caller'sNG_INVALIDcheck 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
- sys/netgraph/ether/ng_ether.c:204-213 (ng_ether_input reader)
- sys/netgraph/ether/ng_ether.c:219-233 (ng_ether_input_orphan reader)
- sys/netgraph/ether/ng_ether.c:257-273 (ng_ether_output reader)
- sys/netgraph/ether/ng_ether.c:502-513 (ng_ether_rcvdata reader)
- sys/netgraph/ether/ng_ether.c:321-337 (ng_ether_detach: unsynchronized free)
- sys/netgraph/netgraph/ng_base.c:1711-1714 (HK_INVALID check-then-use TOCTOU)
- sys/netgraph/netgraph/ng_base.c:2059-2065 (rcvmsg NG_INVALID TOCTOU)
- sys/netgraph7/netgraph/ng_base.c:1948-1956 (contrast: ng7 cpu0 funnel that v1 lacks)
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
fixedfix.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)
Confirmed kernel references
- sys/netgraph/ether/ng_ether.c:321-337
- sys/netgraph/ether/ng_ether.c:204-213
- sys/netgraph/ether/ng_ether.c:219-233
- sys/netgraph/ether/ng_ether.c:257-273
- sys/netgraph/ether/ng_ether.c:502-513
- sys/netgraph/netgraph/ng_base.c:1711-1714
- sys/netgraph/netgraph/ng_base.c:2059-2065
- sys/netgraph7/netgraph/ng_base.c:1948-1956
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.
No comments yet.