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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3453 Β· 14 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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:

  1. softclock: tbf_reprocess_q callout (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-CPU softclock N LWKT threads (kern_timeout.c:423 lwkt_create(softclock_handler, ...)).
  2. driver interrupt threads with IFF_IDIRECT: ether_dispatch() calls ether_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_testable
baseline no→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-3453

Confirmed kernel references

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.