pfil COW hook-list free races pfil_run_hooks() walkers on threads netmsg_service_sync() never waits for (softclock via ip_mroute tbf_reprocess_qβip_output; IFF_IDIRECT bridge interrupt threads) β use-after-free with indirect call through pfh->pfil_func; stock INVARIANTS kernel panics on the mroute softclock path
Summary
pfil_add/remove_hook_dispatch swap ph_in/ph_out to fresh COW lists and, after netmsg_service_sync(), free the old lists on the premise that the sync excludes everyone still walking. But netreglist contains ONLY the netisr threads, while pfil_run_hooks() also executes on (a) the per-CPU softclock threads via ip_mroute's tbf_reprocess_q callout -> tbf_process_q -> ip_output -> pfil_run_hooks(PFIL_OUT), and (b) driver interrupt threads with IFF_IDIRECT (bridge_pfil). A walk in flight on those threads keeps iterating the freed hook entries and calls pfh->pfil_func on recycled memory. VERIFIED: (1) stock INVARIANTS kernel + mroute + rate-limited vif + multicast UDP flood -> panic 'thread ... is not within netisr_ncpus' with backtrace ip_output <- tbf_send_packet <- tbf_reprocess_q <- softclock_handler (the softclock ip_output ASSERT itself - a root-configured default-INVARIANTS panic); (2) non-INVARIANTS build: ip_output->pfil_run_hooks runs on softclock for minutes (softclock-0 pegged 97%); (3) instrumented COW mutation + M_IFADDR spray proved freed hook chunks return via the very next same-size kmalloc (2/2) - the poisoning half works exactly as designed. The final wild-call step did not trigger on this guest (softclock-0 shares CPU0 with the pinned mutation thread; LWKT non-preemption serializes them - a configuration coincidence documented in VERDICT.md, not pfil synchronization). Root-gated both sides; reliability/corruption class, no uid0 route. Fix: dedicated pfil_token across the walk and the swap->free sections (blocking semantics make it deadlock-free).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3453 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| prhook.c | β | 6.4 KB | view raw | |
| prhook/Makefile | β | 176 B | β download | |
| pfilrace_feeder.c | β | 4.8 KB | view raw | |
| pfilrace.sh | β | 884 B | view raw | |
| run.sh | β | 378 B | view raw | |
| build.sh | β | 138 B | view raw | |
| build.log | β | 1.4 KB | view raw | |
| run.log | β | 3.6 KB | view raw | |
| panic.txt | β | 2.2 KB | view raw | |
| env.txt | β | 791 B | view raw | |
| fix.diff | β | 2.7 KB | view raw | |
| VERDICT.md | β | 6.8 KB | β raw | |
| manifest.json | β | 1.8 KB | view raw | |
| verdict.json | β | 5.9 KB | view raw |
DF-3453 VERDICT β pfil COW free vs non-netisr walkers
Bottom line
netmsg_service_sync() in pfil_add_hook_dispatch() /
pfil_remove_hook_dispatch() (sys/net/pfil.c:238, :346) only
synchronizes with the netisr threads β netreglist is populated
exclusively with the netisr_threads[i] msgports (sys/net/netisr.c:229,
the only caller of netmsg_service_port_init), and netmsg_service_sync
(netisr.c:292-303) iterates exactly that list. But pfil_run_hooks()
walkers also execute on threads that are NOT in that list:
- softclock:
tbf_reprocess_qcallout (sys/net/ip_mroute/ip_mroute.c:1874, TBF_REPROCESS = hz/100) βtbf_process_q(:1844) βtbf_send_packet(:1926) βip_output(m, ..., IP_FORWARDING, ...)(:1931/:1948) βpfil_run_hooks(&inet_pfil_hook, ..., PFIL_OUT)(ip_output.c:529). Callouts execute on the per-CPUsoftclock NLWKT threads (kern_timeout.c:423lwkt_create(softclock_handler, ...)). - driver interrupt threads with
IFF_IDIRECT:ether_dispatch()callsether_input_handler()directly in the caller's context (if_ethersubr.c:1457) βether_input_oncpuβbridge_inputβbridge_pfil(PFIL_IN)βpfil_run_hooks(if_bridge.c:4097).
An in-flight walk on one of those threads holds the OLD list pointer
across the COW swap; pfil_list_free(old_list) (pfil.c:244-247,
:351-354) then recycles packet_filter_hook entries and the list head
while the walk's TAILQ_FOREACH (pfil.c:110) dereferences them and
calls pfh->pfil_func β a use-after-free with an indirect call.
What was proven on the guest
(a) The walker class is real β stock INVARIANTS kernel PANICS from the
plain feeder (no test modules): with only kldload ip_mroute +
MRT_INIT/MRT_ADD_VIF(rate-limited)/MRT_ADD_MFC + a multicast UDP flood,
the stock kernel (DragonFly 6.5-DEVELOPMENT #0 ... X86_64_GENERIC)
panicked:
panic: thread 0xfffff800895bf040 cpu0 is not within netisr_ncpus 6 ip_output() at ip_output+0x64e ip_output() at ip_output+0x64e tbf_send_packet() at tbf_send_packet+0xac tbf_process_q() at tbf_process_q+0x56 tbf_reprocess_q() at tbf_reprocess_q+0x2b softclock_handler() at softclock_handler+0x1b8
(ip_output.c:185 ASSERT_NETISR_NCPUS(mycpuid) β the kernel's own
assertion documents that this ip_output/pfil caller is not a netisr
thread.) This is a self-contained root-configurable panic on default
INVARIANTS builds; see panic.txt.
(b) On a production (non-INVARIANTS) kernel, the softclock pfil walk
runs: the identical flood on a PRNOINV kernel (same source, INVARIANTS
disabled) forwarded ~2.3kpps through tbf_send_packet β ip_output β
pfil_run_hooks on softclock for minutes with no assert (asserts
compiled out). With the prhook module's softclock-only delay,
[softclock 0] burned 97% CPU inside pfil_run_hooks/hook A
(net.prhook_n_delay counting hundreds of parked walks).
(c) The freed hook chunks are immediately reclaimable: the
instrumented toggle (net.prhook_n_spray_hit vs net.prhook_n_walk_probe)
returned 2/2: immediately after pfil_remove_hook() returns on
netisr0, the very next kmalloc(36, M_IFADDR) on netisr0 hands back one
of the just-freed packet_filter_hook chunks (slab fast path pops
z_LChunks, kern_slaballoc.c:1008-1017 / kfree push :1584). Any
concurrent same-zone allocation on the freeing CPU lands on the list a
parked walk is still iterating β the poisoning step is trivially
effective, as designed in the PoC.
Why the end-to-end wild-call crash did not trigger on this guest
Both sides of the race are CPU-pinned to CPU0 here:
- The mutation always executes on netisr0 (PFIL_CFGPORT =
netisr_cpuport(0), pfil.c:47), pinned to CPU0.
- The mroute tbf callout is armed by tbf_control on the netisr CPU
that processes the forwarding packet (ip_mroute.c:1792) and then
re-arms itself from its own softclock handler (:1883). On this
6-vCPU guest the flood's loopback-hash flow landed on netisr0, so the
ONLY sustainable walker was softclock 0 β the same CPU as netisr0.
DFly LWKT threads are non-preemptive: while softclock-0 is parked
inside the hook, netisr0 cannot run, and vice versa β the free can
never interleave with the parked walk when both share CPU0.
- Walking on softclock N (N!=0) starves instead: while the softclock-N
walk parks, netisr-N's inline tbf_process_q (invoked per packet
arrival, ip_mroute.c:1806) consumes every token the bucket accrues, so
the softclock-N callout fires with zero tokens and never walks
(net.prhook_n_delay stayed 0 for non-zero-hash flows β measured).
So on this specific configuration the same-CPU coincidence closes the
window; on other flow mixes / CPU layouts (multi-flow forwarding where
the callout-bearing CPU differs from CPU0, or the IFF_IDIRECT bridge
walker on a driver interrupt thread whose IRQ CPU != 0), the mutation
on netisr0/CPU0 proceeds concurrently with the parked walk. The
constituent facts (walker on a non-netreglist thread; COW free;
immediate reclaim of the freed chunks) are each individually proven
above; the composition is blocked here by CPU pinning, not by any
synchronization in pfil.c β netmsg_service_sync() never waits for
softclock or interrupt threads.
Impact / exploitability
Both triggers are root-gated: enabling multicast routing (MRT_INIT via a raw IGMP socket) and loading/unloading pfil-hooking modules (ipfw/ipfw3/pf). There is no unprivileged trigger β this is a kernel reliability/UAF-class defect (root racing the kernel), not an escalation. Severity Low. The INVARIANTS panic (a) is an unconditional root-configured DoS on default INVARIANTS builds.
Fix
fix.diff: add a pfil_token (LWKT token); pfil_run_hooks() takes
it around the walk, and both dispatch functions take it around the
swapβfree section. Token blocking/release semantics keep this deadlock
free (the walker that holds the token during a long hook simply delays
the mutation; netisr threads replying to netmsg_service_sync() never
need the token). Not applied to sys/ (read-only audit tree); not
runtime-validated on the guest since the end-to-end race did not
trigger there (fix_status: not_testable, honestly recorded).
Follow-up observation (out of batch scope)
During cleanup on the non-INVARIANTS kernel, kldunload ipfw3_basic
panicked in rn_flush+0x1d (NULL page fault, Fatal trap 12, fault
va 0x28) right after "ipfw3 module basic unloaded" β a teardown bug in
the ipfw3 table subsystem (sys/net/ipfw3_basic/ip_fw3_table.c) worth a
dedicated audit pass.
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_testablefix.diff authored (pfil_token) but not kernel-built/validated: the end-to-end UAF could not be triggered on this guest (same-CPU LWKT serialization pins the only sustainable walker against the netisr0 mutation), so a patched-vs-baseline comparison has no observable signal here. The INVARIANTS panic reproduced instead lives at ip_output.c:185's assert (mroute callout-context send), a different code site unaffected by the pfil token; fixing that site belongs with ip_mroute (drain tbf via a netmsg on the protocol thread, like igmp/carp/pfsync timers do).
fix.diff; VERDICT.md 'Fix' section
Confirmed kernel references
- sys/net/pfil.c:47
- sys/net/pfil.c:110
- sys/net/pfil.c:238
- sys/net/pfil.c:244
- sys/net/pfil.c:346
- sys/net/pfil.c:351
- sys/net/netisr.c:229
- sys/net/netisr.c:292
- sys/netinet/ip_output.c:185
- sys/netinet/ip_output.c:529
- sys/net/ip_mroute/ip_mroute.c:1874
- sys/net/ip_mroute/ip_mroute.c:1931
- sys/kern/kern_timeout.c:423
- sys/net/if_ethersubr.c:1457
- sys/net/bridge/if_bridge.c:4097
Detail
Evidence (decisive lines)
["panic.txt: stock-kernel panic backtrace ip_output<-tbf_send_packet<-tbf_process_q<-tbf_reprocess_q<-softclock_handler ('thread ... is not within netisr_ncpus 6')", 'run.log: [softclock 0] 96-97% CPU parked inside pfil walk on the non-INVARIANTS kernel; net.prhook_n_spray_hit=2/2 (first kmalloc(36,M_IFADDR) after pfil_remove_hook returns a just-freed packet_filter_hook chunk); k=1..5 flow probes show walker CPU pinning', 'VERDICT.md: full serialization analysis of why the end-to-end wild call is blocked on this guest (CPU0 LWKT non-preemption; token starvation of non-zero softclock walkers)', 'fix.diff: pfil_token around pfil_run_hooks walk and around the dispatch swap+free sections']
PoC changes
Seed had no runnable PoC. Built: prhook.ko (pfil hook module: softclock-only multicast busy-delay to widen the in-flight walk window, sysctl-driven COW add/remove mutation on netisr0, M_IFADDR 36-byte 0x41 spray, instrumentation counters proving 2/2 freed-chunk reclaim) and pfilrace_feeder (MRT_INIT + 2 phyint vifs + rate-limited vif1 + MFC + throttled multicast flood; parameterized rate and per-flow source-port pinning). Discovered en route: the tbf callout arms on the feeding flow's CPU; tsleep parking deadlocks softclock inside its own callout wheel; ipfw3_basic unload panics in rn_flush (follow-up).
Verified recommended fix
Hold a dedicated LWKT token (pfil_token) across the pfil_run_hooks walk and across the add/remove dispatch swap->free section so list frees exclude all walkers, not only netisr threads
Verdict
netmsg_service_sync() only synchronizes the netisr threads (netreglist is populated solely with netisr_threads, netisr.c:229), but pfil_run_hooks() walkers also execute on softclock (ip_mroute tbf_reprocess_q callout -> tbf_process_q -> tbf_send_packet -> ip_output -> pfil_run_hooks PFIL_OUT) and on IFF_IDIRECT driver interrupt threads (bridge_pfil). pfil_list_free() after the COW swap recycles the packet_filter_hook entries and list head an in-flight walk on those threads still iterates, ending in an indirect call through freed pfh->pfil_func. Reproduced on the stock INVARIANTS guest: the plain mroute feeder (no test modules) panics with the kernel's own ASSERT_NETISR_NCPUS inside ip_output on the tbf_reprocess_q/softclock path (panic.txt) - an unconditional root-configured panic on default INVARIANTS builds. On a non-INVARIANTS kernel the same flood runs ip_output->pfil_run_hooks on softclock for minutes (97% CPU in the walk, production semantics), and instrumentation proves the freed hook chunks are returned by the very next same-size kmalloc on netisr0 (spray-hit 2/2), i.e. the poisoning half of the UAF works exactly as designed. The final wild-call step could not be triggered on this 6-vCPU guest because the only sustainable tbf walker (softclock 0) shares CPU0 with the pinned mutation thread (netisr0) and LWKT non-preemption serializes them, while softclock-N walkers are starved of tokens by netisr-N inline tbf_process_q - a configuration coincidence, not a pfil.c synchronization. Both triggers are root-gated: no unprivileged path, severity Low (reliability/UAF class, root racing the kernel). fix.diff adds a pfil_token held across the walk and across the swap->free section.
No comments yet.