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

ip_input/ip_output called from netgraph thread context: ASSERT_NETISR_NCPUS panic (INVARIANTS) and PFIL list UAF (production)

Summary

ng_ipfw_rcvdata :245 ip_output(m) :248 ip_input(m) called from netgraph worker thread (netgraph N created ng_base.c:2787-2789). Netgraph thread msgports NOT in netreglist (only netmsg_service_port_init ports are netisr.c:258-280). INVARIANTS: ASSERT_NETISR_NCPUS(mycpuid) at ip_input.c:460 ip_output.c:185 trips immediately = deterministic panic. Production: pfil_run_hooks ip_input.c:631 ip_output.c:529 iterates pfil list synced by netmsg_service_sync (pfil.c:238,346) which only walks netreglist NOT netgraph ports. Concurrent pfil_add_hook/remove_hook (pfil.c:244-247,351-354) frees old list while netgraph thread still in TAILQ_FOREACH = UAF on pfil_list_t and struct packet_filter_hook. Sibling ng_ip_input.c:122-125 correct pattern: netisr_queue(NETISR_IP,m) never calls ip_input directly. LATENT: optional netgraph7_ipfw not in default kernel. Trigger: root ngctl connect to ipfw node + m_tag NGM_IPFW_COOKIE dir=IN/OUT. Fix: use netisr_queue dispatch to real netisr thread.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0735 Β· 19 files
FileTypeDescriptionSize
df735_trigger.c trigger-source sysctl trigger: ip_input(m) direct -- mirrors ng_ipfw.c:248, PANICS 2.8 KB view raw
df735_fixed.c trigger-source sysctl trigger: netisr_queue(NETISR_IP, m) -- mirrors fix.diff + ng_ip_input.c:125, NO panic 2.0 KB view raw
ng_df735_poc.c trigger-source faithful netgraph node mirroring ng_ipfw_rcvdata line-for-line (builds; netgraph7 ABI mismatch blocks kldload) 4.9 KB view raw
ng_df735_inject.c trigger-source userspace PF_NETGRAPH injector for ng_df735_poc 4.2 KB view raw
Makefile.ng_ipfw build-script Makefile template for the real (unbuildable) ng_ipfw.ko 723 B ↓ download
trigger_df735.sh build-script alternative trigger via real ng_ipfw.ko + ipfw ngtee divert 3.0 KB view raw
run_df735.sh build-script alternative trigger via faithful netgraph node + ng_socket 6.0 KB view raw
build.sh build-script builds df735_trigger.ko, df735_fixed.ko, ng_df735_poc.ko 1.3 KB view raw
run.sh run-script Phase A (fixed, no panic) + Phase B (buggy, PANIC) on the same kernel 1.8 KB view raw
build.log build-log full compiler output of the final three-module build (gcc 8.3) 2.7 KB view raw
run.log run-log Phase B decisive run + serial console panic signature 409 B view raw
fix_run.log run-log Phase A (fixed) run -- 5 triggers, no panic, guest stays up 609 B view raw
panic.txt panic-signature panic: thread ... is not within netisr_ncpus 6 / ip_input+0x3de 243 B view raw
env.txt environment uname, cc version, kldstat, sysctls 483 B view raw
fix.diff suggested-fix git-apply-able unified diff: route ng_ipfw rcvdata through netisr (matches ng_ip_input.c + dummynet pattern) 2.8 KB view raw
VERDICT.md verdict full narrative: mechanism, reachability (latent), fix validation 10.5 KB ↓ raw
README.md readme reproduction instructions and file index 3.2 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme reproduction instructions and file index
↓ download raw

DF-0735 β€” PoC and evidence pack

Finding: ip_input/ip_output called from netgraph thread context (sys/netgraph7/ng_ipfw.c:245,248). The rcvdata callback calls ip_output(m) / ip_input(m) directly from the netgraph worker thread, which is not a netisr thread, so the ASSERT_NETISR_NCPUS(mycpuid) at ip_input.c:460 / ip_output.c:185 fires on default-GENERIC (INVARIANTS on) and the kernel panics.

Verdict: REPRODUCED at source + harness level. The cited path is latent β€” ng_ipfw.c doesn't compile on this tree (netinet/ip_fw.h does not exist), has no in-kernel caller of its ng_ipfw_input_p function pointer, and is neither in the default kernel nor in the netgraph7 module SUBDIR. The harness demonstrates the assertion mechanism that the cited call would trip if it were ever reached.

Impact: panic / DoS, root-only reachability, no escalation primitive.

How to reproduce (on the DragonFly guest as root)

# 1. Build the three harness modules.
./build.sh

# 2. Run the two-phase demonstration (Phase A: fixed, no panic;
#    Phase B: buggy, PANIC -> guest wedges in DDB).
./run.sh

After Phase B the guest is wedged in DDB; reset it from the host with ./dfbsd-qemu/vm.sh reset with-src. The panic signature is in dfbsd-qemu/boot.log and in panic.txt.

Files

file purpose
df735_trigger.c sysctl trigger module: ip_input(m) direct (mirrors ng_ipfw.c:248) β€” PANICS
df735_fixed.c sysctl trigger module: netisr_queue(NETISR_IP, m) (mirrors the fix + ng_ip_input.c:125) β€” no panic
ng_df735_poc.c faithful netgraph node harness (mirrors ng_ipfw_rcvdata) β€” builds; netgraph7 ABI mismatch prevents kldload
ng_df735_inject.c userspace PF_NETGRAPH injector (for ng_df735_poc)
Makefile.ng_ipfw Makefile template for building the real (unbuildable) ng_ipfw.ko from source
trigger_df735.sh alternative trigger via the real (unbuildable) ng_ipfw.ko + ipfw ngtee divert
run_df735.sh alternative trigger via the faithful netgraph node
build.sh builds all three harness modules
run.sh runs Phase A (fixed, no panic) + Phase B (buggy, PANIC)
build.log full compiler output of the final build
run.log full output of the decisive Phase B run (with panic signature)
fix_run.log full output of the Phase A (fixed) run (no panic)
panic.txt panic signature excerpt from dfbsd-qemu/boot.log
env.txt guest environment (uname, cc, kldstat, sysctls)
fix.diff git-apply-able unified diff against sys/netgraph7/ng_ipfw.c
VERDICT.md full narrative verdict (mechanism, reachability, fix validation)
manifest.json machine-readable artifact catalog for the static-site renderer
VERDICT.md verdict full narrative: mechanism, reachability (latent), fix validation
↓ download raw

DF-0735 β€” ip_input/ip_output called from netgraph thread context

Verdict: REPRODUCED at the source level + harness level (latent / dead-code path). Impact: panic / DoS β€” but only reachable via root (kldload of an out-of-tree module) on a configuration that is not built by default. No unprivileged-reachability, no escalation primitive. Fix: VALIDATED via harness β€” the fix pattern (route via netisr_queue) eliminates the panic.


1. The claim, restated

sys/netgraph7/ng_ipfw.c calls the IP layer directly from the netgraph worker thread:

  • ng_ipfw.c:245 β€” return ip_output(m, NULL, NULL, IP_FORWARDING, NULL, NULL);
  • ng_ipfw.c:248 β€” ip_input(m);

These calls run inside ng_ipfw_rcvdata() (the netgraph rcvdata callback at ng_ipfw.c:220). Because the hook is set with NG_HOOK_FORCE_QUEUE(hook) in ng_ipfw_connect (ng_ipfw.c:182-187), data sent to the hook is queued asynchronously and processed by the netgraph worker thread created at sys/netgraph7/netgraph/ng_base.c:2787-2789:

lwkt_create(ngthread, NULL, &td, NULL, 0, i, "netgraph %d", i);
ng_msgport[i] = &td->td_msgport;

That thread is a plain lwkt thread. It is never registered on netreglist β€” only ports initialized via netmsg_service_port_init are (sys/net/netisr.c:258-280, with the TAILQ_INSERT_TAIL(&netreglist, ...) at line 279). netmsg_service_sync() (the pfil list lifetime barrier at sys/net/pfil.c:238,346) only walks netreglist, so it does not cover the netgraph thread.

The first thing ip_input() and ip_output() do is:

ASSERT_NETISR_NCPUS(n) (defined at sys/net/netisr2.h:136-139) is a KASSERT(IN_NETISR_NCPUS(n), ...), i.e. it is compiled in whenever INVARIANTS is on. The default X86_64_GENERIC config ships options INVARIANTS (sys/config/X86_64_GENERIC:56), so the assertion is live on every default DragonFly kernel.

Therefore: the cited call site, executed from the netgraph worker thread, trips the KASSERT and panics with panic: thread <ptr> cpuN is not within netisr_ncpus <N>.

The sibling node sys/netgraph7/ng_ip_input.c shows the correct pattern:

NGI_GET_M(item, m);
NG_FREE_ITEM(item);
m->m_flags &= ~M_HASH;
netisr_queue(NETISR_IP, m);   /* ng_ip_input.c:125 -- routes to netisr */

2. Reachability assessment (why this is latent)

ng_ipfw.c is dead code on any current DragonFly kernel:

  1. sys/conf/files gates it on optional netgraph7_ipfw, which is not in sys/config/X86_64_GENERIC, so it is not in the default kernel.
  2. It is not in the SUBDIR list of sys/netgraph7/Makefile, so it is not built as a loadable .ko either. There is no ng_ipfw.ko anywhere on the running guest (/boot/kernel/ and the source tree both lack it).
  3. The file #includes <netinet/ip_fw.h> (ng_ipfw.c:44), a header that does not exist under sys/netinet/ on this tree (the only ip_fw.h lives at sys/net/ipfw/ip_fw.h with a different API). Trying to build the file standalone fails immediately with fatal error: netinet/ip_fw.h: No such file or directory.
  4. Even if those issues were fixed, no in-kernel caller ever invokes ng_ipfw_input_p (the function pointer registered at ng_ipfw.c:118 in MOD_LOAD). It is referenced only inside ng_ipfw.c and ng_ipfw.h themselves. Neither sys/net/ipfw/ip_fw2.c nor sys/net/ipfw3/* mention it. So even with the module loaded, no packet path would ever reach ng_ipfw_rcvdata.

This is therefore a latent / dead-code finding. The mechanism is real at the source level; the cited path is unreachable on any running DragonFly kernel without substantial additional (root-only) scaffolding.

3. Demonstration

Because the cited code cannot be compiled or reached, the proof uses a deterministic code-level harness that mirrors ng_ipfw.c:248 exactly (calling ip_input(m) directly from a non-netisr thread). This is the acceptable path called out in the assignment.

Two trigger modules were built (both compile cleanly with the guest gcc 8.3):

module what its trigger does matches expected
df735_trigger.ko sysctl handler calls ip_input(m) directly from the invoking thread mirrors ng_ipfw.c:248 direct call PANIC
df735_fixed.ko sysctl handler calls netisr_queue(NETISR_IP, m) instead mirrors the fix.diff pattern (and ng_ip_input.c:125) NO panic
ng_df735_poc.ko real netgraph node whose rcvdata calls ip_input(m) faithful copy of ng_ipfw_rcvdata builds; netgraph7 ABI mismatch prevents kldload

The sysctl-handler thread, the netgraph worker thread, and a kthread are all non-netisr threads, so the ASSERT_NETISR_NCPUS behavior is identical across them. The trigger module is therefore a faithful demonstration of the cited mechanism.

3a. BASELINE β€” buggy module panics

Run on the unpatched audit-source kernel (DragonFly 6.5-DEVELOPMENT #0, with-src snapshot):

# kldload /root/df735/df735_trigger.ko
# sysctl -n debug.df735_trigger=1
0
(kernel panics, guest enters DDB)

Serial console (dfbsd-qemu/boot.log):

panic: thread 0xfffff800905fd480 cpu1 is not within netisr_ncpus 6
cpuid = 1
ip_input() at ip_input+0x3de 0xffffffff807b0dae
ip_input() at ip_input+0x3de 0xffffffff807b0dae
Stopped at      Debugger+0x7c:  movb    $0,0xbdaf09(%rip)
db>

The panic message is the exact expansion of KASSERT(IN_NETISR_NCPUS(n), ("thread %p cpu%d is not within netisr_ncpus %d", ...)) from sys/net/netisr2.h:138, and the backtrace shows the panic originates inside ip_input β€” confirming the cited ip_input.c:460 assertion. (ip_input+0x3de is the post-KASSERT-fault panic return path.)

3b. FIXED module β€” no panic

Run on the same unpatched kernel (the fix pattern is in the module, not the kernel):

# kldload /root/df735/df735_fixed.ko
# for i in 1 2 3 4 5; do sysctl -n debug.df735_fixed=1; done
0 -> 0
0 -> 0
0 -> 0
0 -> 0
0 -> 0
# uptime
 2:55AM  up 4 mins, 0 users, load averages: 0.01, 0.02, 0.00

Five triggers, no panic, guest stays up. The mbuf is dispatched to the NETISR_IP thread; ip_input runs there, where ASSERT_NETISR_NCPUS holds.

4. Exploit chain / escalation

There is no escalation chain β€” this is not a memory-corruption primitive. The bug produces a deterministic kernel panic / DoS, gated behind root-only setup (kldload of an out-of-tree module to even reach the cited path). The production (INVARIANTS-OFF) concern raised in the finding β€” that pfil_run_hooks in ip_input/ip_output iterates a list whose lifetime is synced via netmsg_service_sync (which does not cover the netgraph thread), so concurrent pfil_add_hook/pfil_remove_hook could free the list out from under the netgraph thread β€” is real in theory but only reachable on a configuration that: (a) compiles and loads ng_ipfw (impossible today without fixing the netinet/ip_fw.h include), (b) wires ng_ipfw_input_p into ipfw2/ipfw3 (no such caller exists), (c) runs an attacker-controlled concurrent pfil hooks add/remove storm.

None of those hold on any shipping DragonFly kernel. There is no unprivileged attack surface.

5. fix.diff

fix.diff patches sys/netgraph7/ng_ipfw.c to route through netisr instead of calling ip_input/ip_output directly:

  • adds #include <net/netisr.h> and <net/netmsg2.h>,
  • adds ng_ipfw_ip_output_dispatch() netmsg handler (mirrors dummynet's ip_dn_ip_output at sys/net/dummynet/ip_dummynet_glue.c:270),
  • in ng_ipfw_rcvdata(), the NG_IPFW_OUT case dispatches the mbuf to a netisr port via lwkt_sendmsg(netisr_cpuport(mycpuid), ...) and calls ip_output from there (mirrors dummynet's ip_dn_queue pattern at sys/net/dummynet/ip_dummynet_glue.c:82-99),
  • the NG_IPFW_IN case calls netisr_queue(NETISR_IP, m), matching the sibling sys/netgraph7/ng_ip_input.c:125 pattern exactly.

The diff applies cleanly with patch -p1 --forward (all 4 hunks succeed). It is minimal and targeted: the only behavior change is where ip_input / ip_output are called (which thread), not what they do.

Fix validation (Phase 8)

The default kernel does not link ng_ipfw.c, so the standard "single-fix kernel + reboot" Phase 8 path does not apply (no kernel objects change). The fix is validated at the harness level instead, by demonstrating that the fix pattern (route via netisr) eliminates the panic the buggy pattern (direct call) produces, on the same kernel:

Phase Module Outcome
baseline (buggy mechanism) df735_trigger.ko PANIC panic: thread ... is not within netisr_ncpus 6 at ip_input+0x3de
patched (fix mechanism) df735_fixed.ko NO panic, 5x triggers, guest stays up

This is a clean before/after contrast on the same 6.5-DEVELOPMENT #0 audit-source kernel. fix_status = fixed.

6. Honest impact summary

dimension value
bug class wrong-thread call β†’ assertion panic (INVARIANTS) / lock-context race (production)
reachability latent: cited code is dead on this tree (won't compile, no caller, not in default kernel or module SUBDIR)
preconditions root must kldload an out-of-tree module (and even then, no caller exists to feed packets in)
unpriv→root chain? No — no memory-corruption primitive, no unprivileged reachability.
realistic impact source-level latent bug; would become a real root-triggerable DoS if the netgraph7_ipfw path were ever wired up
confidence certain (source-level mechanism verified + harness panic confirmed)

The finding is real as a code-quality / latent-bug issue. It is not an exploitable vulnerability on any shipping DragonFly configuration.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED via harness on the unpatched 6.5-DEVELOPMENT #0 audit-source kernel: the buggy mechanism (df735_trigger.ko, mirrors sys/netgraph7/ng_ipfw.c:248 direct ip_input(m) call) panics every time with 'thread cpuN is not within netisr_ncpus 6' at ip_input+0x3de (Phase B baseline). The fix mechanism (df735_fixed.ko, uses netisr_queue(NETISR_IP, m) -- the exact pattern fix.diff applies to ng_ipfw.c:248 and that the sibling ng_ip_input.c:125 already uses) does NOT panic -- 5 consecutive triggers, guest stays up, uptime continues (Phase A). The default kernel does not link ng_ipfw.c (gated on optional netgraph7_ipfw, absent from GENERIC), so the standard 'single-fix kernel + reboot' Phase 8 path does not apply (no kernel objects change); the harness-level before/after on the same kernel is the correct validation for this latent/dead-code finding. fix.diff applies cleanly to /usr/src/sys/netgraph7/ng_ipfw.c (4 hunks succeed via patch -p1 --forward) and routes both cited lines (245, 248) through netisr.

BASELINE (buggy mechanism, mirrors ng_ipfw.c:248):
  kldload df735_trigger.ko; sysctl -n debug.df735_trigger=1
  -> panic: thread 0xfffff800905fd480 cpu1 is not within netisr_ncpus 6
     ip_input() at ip_input+0x3de 0xffffffff807b0dae
     Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip)
     db>          (guest wedges; status=down)

FIXED (mirrors fix.diff NG_IPFW_IN branch + ng_ip_input.c:125):
  kldload df735_fixed.ko; for i in 1 2 3 4 5; do sysctl -n debug.df735_fixed=1; done
  -> 0 -> 0 (x5)
  -> uptime: 2:55AM up 4 mins, 0 users, load averages: 0.01, 0.02, 0.00
  -> status: up (no panic)
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC (same kernel for both phases -- fix pattern lives in the harness module, not a rebuilt kernel)

Confirmed kernel references

Detail

Exploit chain

none (not a memory-corruption primitive). This is a deterministic kernel panic / DoS via wrong-thread assertion, gated behind root-only module loading to even reach the cited path. No unprivileged reachability, no privilege-boundary crossing, no escalation chain derivable. The production (INVARIANTS-OFF) concern raised in the finding -- pfil_run_hooks (sys/net/pfil.c:110) iterating a pfil list whose lifetime is synced via netmsg_service_sync (pfil.c:238,346), a sync that does NOT cover the netgraph thread, so concurrent pfil_add_hook/pfil_remove_hook could free the list out from under the netgraph thread -- is real in theory but unreachable today because no caller ever invokes ng_ipfw_input_p.

Evidence (decisive lines)

Phase B baseline (buggy mechanism, mirrors ng_ipfw.c:248):
  kldload /root/df735/df735_trigger.ko
  sysctl -n debug.df735_trigger=1
  -> panic: thread 0xfffff800905fd480 cpu1 is not within netisr_ncpus 6
     cpuid = 1
     ip_input() at ip_input+0x3de 0xffffffff807b0dae
     Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip)
     db>
  (guest wedges in DDB; status=down)

Phase A fixed mechanism (mirrors fix.diff + ng_ip_input.c:125):
  kldload /root/df735/df735_fixed.ko
  for i in 1 2 3 4 5; do sysctl -n debug.df735_fixed=1; done
  -> 0 -> 0 (x5)
  -> uptime: 2:55AM up 4 mins, 0 users, load averages: 0.01, 0.02, 0.00
  -> guest stays up; status=up

(Both phases run on the same unpatched 6.5-DEVELOPMENT #0 audit-source kernel.)

PoC changes

Authored the entire evidence pack from scratch (findings/poc/DF-0735/ did not exist when I started). Wrote: df735_trigger.c (sysctl module mirroring ng_ipfw.c:248 -- calls ip_input(m) directly from a non-netisr thread; PANICS), df735_fixed.c (sysctl module using the fix pattern -- netisr_queue(NETISR_IP, m); no panic), ng_df735_poc.c (faithful netgraph node mirroring ng_ipfw_rcvdata line-for-line; builds cleanly, but netgraph7 ABI version mismatch blocks kldload on this kernel), ng_df735_inject.c (PF_NETGRAPH userspace injector), Makefile.ng_ipfw (template for the real -- unbuildable -- ng_ipfw.ko), build.sh + run.sh (two-phase reproduce), VERDICT.md, README.md, manifest.json, fix.diff. Fixed a header typo (netinet/in_system.h -> netinet/in_systm.h) and added an explicit extern void ip_input(struct mbuf *); because DragonFly's ip_input() is not declared in any public header.

Verified recommended fix

fix.diff routes ng_ipfw_rcvdata's NG_IPFW_IN case through netisr_queue(NETISR_IP, m) (matching the sibling sys/netgraph7/ng_ip_input.c:125 exactly), and the NG_IPFW_OUT case through a netmsg_packet dispatched to netisr_cpuport(mycpuid) with a new ng_ipfw_ip_output_dispatch() handler that calls ip_output() from the netisr thread (mirroring dummynet's ip_dn_queue/ip_dn_ip_output pattern at sys/net/dummynet/ip_dummynet_glue.c:82-99,270). Adds and includes. Minimal and targeted -- the only behavior change is which thread calls ip_input/ip_output. Applies cleanly (4 hunks). Supersedes any finding-proposal fix (no fix proposal was attached to this finding). The full git-apply-able diff lives in findings/poc/DF-0735/fix.diff.

Verdict

REPRODUCED at source + harness level (LATENT/dead-code path). The cited call site at sys/netgraph7/ng_ipfw.c:245 (ip_output(m)) and :248 (ip_input(m)) is real: ng_ipfw_rcvdata runs in the netgraph worker thread created at sys/netgraph7/netgraph/ng_base.c:2787-2789, which is NOT registered on netreglist (only ports initialized via netmsg_service_port_init at sys/net/netisr.c:258-280 are), so it is not a netisr thread. ip_input.c:460 and ip_output.c:185 both open with ASSERT_NETISR_NCPUS(mycpuid) (defined at sys/net/netisr2.h:136, a KASSERT compiled in whenever INVARIANTS is on -- and X86_64_GENERIC ships options INVARIANTS at sys/config/X86_64_GENERIC:56). A harness module (df735_trigger.ko) that mirrors ng_ipfw.c:248 by calling ip_input(m) directly from a non-netisr thread panics deterministically: 'panic: thread 0xfffff800905fd480 cpu1 is not within netisr_ncpus 6' at ip_input+0x3de, confirmed in dfbsd-qemu/boot.log and panic.txt. The mechanism is identical whether the caller is a sysctl-handler thread, a kthread, or the netgraph worker thread -- all are non-netisr, all trip the same KASSERT. The cited path itself is LATENT on this tree: ng_ipfw.c is gated on optional netgraph7_ipfw (sys/conf/files:1712), absent from sys/netgraph7/Makefile SUBDIR, #includes which does not exist anywhere under sys/ (only sys/net/ipfw/ip_fw.h exists with a different API), and its ng_ipfw_input_p function pointer (set at ng_ipfw.c:118) is never referenced by any other .c/.h outside ng_ipfw.{c,h} -- so the rcvdata path is unreachable on any shipping DragonFly kernel.