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

ng_bpf SET_PROGRAM accepts cyclic match/nomatch forwarding destinations (incl. ifMatch == thisHook); ng7's unbounded synchronous forwarding recurses until the LWKT token array overflows β€” deterministic kernel panic

Summary

ng_bpf_setprog resolves ifMatch/ifNotMatch via ng_findhook with no check that the target differs from thisHook and no cycle detection; a cyclic configuration makes each arriving packet recurse synchronously through NG_FWD_ITEM_HOOK -> ng_snd_item -> ng_apply_item -> rcvdata, each hop recursively acquiring the destination node's LWKT token until td_toks_array[LWKT_MAXTOKENS=32] overflows: 'panic: assertion ref < &td->td_toks_end failed in lwkt_gettoken_shared' (KKASSERT compiled into production kernels). Same framework property reachable via other forwarding node types (ng_echo pair). VERIFIED on stock INVARIANTS guest: 1-byte inject -> instant panic, both 'self' (ifMatch==thisHook) and 'cross' (multi-hook cycle, no self-reference) modes. Root-gated config; privileged DoS. Fix validated in two stages: ng_bpf guard alone stops 'self' with EINVAL but 'cross' still panics; full fix (+ el_hops NG_HOPS_MAX=16 bound in ng_snd_item) stops both. LEAD (a) resolved: SET_PROGRAM DOES call bpf_validate (cap 512 insns, MEM bounds, jump validation, terminal RET) - DF-3444's unvalidated-filter primitive NOT reachable via ng_bpf.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3540 Β· 18 files
FileTypeDescriptionSize
README.md β€” 2.8 KB ↓ raw
VERDICT.md β€” 4.1 KB ↓ raw
bpf_cycle.c β€” 7.5 KB view raw
build.sh β€” 181 B view raw
run.sh β€” 163 B view raw
run.log β€” 1.7 KB view raw
unpriv_check.txt β€” 174 B view raw
panic.txt β€” 611 B view raw
console_full.txt β€” 2.3 KB view raw
console_cross.txt β€” 2.3 KB view raw
stock_cross_run.log β€” 28 B view raw
fix1_self.log β€” 309 B view raw
fix1_cross.log β€” 28 B view raw
fix2_self.log β€” 314 B view raw
fix2_cross.log β€” 278 B view raw
fix.diff β€” 2.4 KB view raw
verdict.json β€” 4.6 KB view raw
manifest.json β€” 1.0 KB view raw

DF-3540 β€” netgraph7 ng_bpf: cyclic ifMatch/ifNotMatch forwarding β†’ unbounded synchronous recursion β†’ kernel panic

What

ng_bpf_setprog() (sys/netgraph7/bpf/ng_bpf.c:584-585) resolves ifMatch/ifNotMatch with ng_findhook() and stores the result as a direct forwarding target, with no validation that the targets differ from thisHook and no cycle detection. netgraph7 forwards items whose refcount is > 1 synchronously β€” NG_FWD_ITEM_HOOK β†’ ng_snd_item() β†’ ng_apply_item() β†’ the peer's rcvdata() β†’ NG_FWD_ITEM_HOOK β†’ … β€” with no hop bound (sys/netgraph7/netgraph/ng_base.c:1908-1965). Each hop also takes the destination node's LWKT token (ng_acquire_read, ng_base.c:1998) recursively.

A cyclic configuration β€” two ng_bpf nodes cross-connected, each with ifMatch == thisHook ("self" mode), or a pure multi-hook ifMatch cycle with no self-reference at all ("cross" mode) β€” makes one injected packet recurse until the kernel thread's LWKT token reference array (td_toks_array[LWKT_MAXTOKENS == 32], sys/sys/thread.h:331) overflows:

panic: assertion "ref < &td->td_toks_end" failed in lwkt_gettoken_shared
       at /usr/src/sys/kern/lwkt_token.c:732     (KKASSERT: all builds)
lwkt_gettoken_shared() at lwkt_gettoken_shared+0x34c
lwkt_gettoken_shared() at lwkt_gettoken_shared+0x34c
ng_apply_item() at ng_apply_item+0xa9
ng_apply_item() at ng_apply_item+0x105     (recursive)
...

The KKASSERT is compiled into production (non-INVARIANTS) kernels too, so the panic is deterministic everywhere. Without the token-array tripwire the recursion would instead exhaust the ngthread kernel stack.

Trigger privilege

ROOT ONLY (verified live): ng7 control sockets require caps_priv_check(SYSCAP_RESTRICTEDROOT) (sys/netgraph7/socket/ng_socket.c:182-185); uid 1001 gets EPERM (unpriv_check.txt). Root must build the cyclic graph; one packet (sent by root, or by any traffic source once configured) panics the machine. Severity Low (privileged DoS) β€” same class as DF-3486/DF-0679.

Build / Run (in-guest, root, clean /usr/src)

sh build.sh          # ng7 netgraph.ko + ng_socket.ko + ng_bpf.ko + trigger
sh run.sh self       # expect panic (console: token-array assertion)
# after reboot:
sh run.sh cross      # expect same panic (cycle class, no self-reference)

Fix validation (fix.diff)

stock self β†’ panic cross β†’ panic ng_bpf guard only self β†’ EINVAL cross β†’ panic (fix1_.log) full fix self β†’ EINVAL cross β†’ survives (fix2_.log)

fix.diff = (1) ng_bpf.c rejects ifMatch/ifNotMatch == thisHook (kills the trivially-configurable self cycle); (2) ng_base.c bounds synchronous forwards per item (el_hops, NG_HOPS_MAX = 16, ELOOP) β€” the general guard covering cycles built from any node types.

VERDICT.md
↓ download raw

DF-3540 β€” VERDICT

Status: reproduced. Impact: panic (kernel DoS, root-gated). Confidence: certain.

How it was reproduced

Guest: DragonFly 6.5-DEVELOPMENT #0 (stock INVARIANTS kernel), ng7 module stack (netgraph.ko, ng_socket.ko, ng_bpf.ko) built from the guest's clean /usr/src β€” build.sh / run.sh. Trigger bpf_cycle.c uses the AF_NETGRAPH control+data socket API (NG_VERSION 8; sockaddr_ng data at offset 2 β€” DFly sa_family_t is __uint8_t):

  1. control socket node named "bpcyc" (NGM_NAME), data socket connected to it ("bpcyc:" absolute-node addressing).
  2. mkpeer bpf (B1), mkpeer bpf from B1 (B2), three NGM_BPF_SET_PROGRAMs: - self mode: B1.x2: ifMatch=x1, B1.x1: ifMatch=x1 (self), B2.y1: ifMatch=y1 (self). Steady loop B1.x1 ↔ B2.y1. - cross mode: B1 hooks e/p/r, B2 hooks q/s (NGM_CONNECT wires B1.r↔B2.s); programs B1.eβ†’p, B1.rβ†’p, B2.qβ†’s. Loop B1.r β†’ B2.q β†’ B1.r. No ifMatch equals its own thisHook anywhere.
  3. one 1-byte packet injected via the data socket.

Both modes: kernel dies instantly (ssh hangs, guest down). Serial console:

panic: assertion "ref < &td->td_toks_end" failed in lwkt_gettoken_shared
       at /usr/src/sys/kern/lwkt_token.c:732
lwkt_gettoken_shared() x2, ng_apply_item() x3+ recursive
Debugger("panic")

(panic.txt, console_full.txt, console_cross.txt)

Why it happens (source trace)

  • ng_bpf.c:584-585 β€” hip->match = ng_findhook(node, prog->ifMatch) β€” any hook name accepted, including thisHook itself; no cycle check.
  • ng_bpf.c:500 β€” NG_FWD_ITEM_HOOK(error, item, dest).
  • netgraph.h:861-872 β†’ ng_base.c:1908 ng_snd_item(): items with refs>1 are applied synchronously (line 1961) β€” each forward is a stack frame chain, no hop limit.
  • ng_base.c:1998 ng_acquire_read() β†’ lwkt_gettoken_shared() pushes a tokref into td_toks_array[LWKT_MAXTOKENS=32] (sys/sys/thread.h:331); recursive acquisition per hop; 32 acquisitions β†’ KKASSERT at lwkt_token.c:732 (always compiled β€” KKASSERT, not KASSERT).
  • netgraph7 dispatches everything on the cpu0 ngthread (ng_base.c:1948-1956), so the recursion is on a kernel thread stack.

The framework property (no cycle guard on synchronous forwarding) is the root enabler; ng_bpf's SET_PROGRAM is the stock node type that makes a cycle expressible with two/three control messages. The same framework property is reachable through other forwarding node types (e.g. an ng_echo pair), which is why fix.diff fixes both layers.

Exploit chain

None beyond denial of service: root-gated configuration, deterministic panic. No memory-safety violation (the overflow tripwire is an always-on KKASSERT; no controlled overwrite occurs before it).

Fix validation (fix.diff)

Applied as module rebuilds on the same stock kernel:

build self mode cross mode
stock panic (run 1) panic (run 2)
ng_bpf guard only SET_PROGRAM β†’ EINVAL (sendto errno=22, apply-error propagation), no panic (fix1_self.log) still panics (fix1_cross.log) β€” proves the framework part is required
full fix (ng_bpf + ng_base hop bound 16) EINVAL, survives (fix2_self.log) bounded: cycle unwinds with ELOOP, packet dropped, survives 30s+ (fix2_cross.log)

fix_status: fixed.

Residual notes

  • NG_HOPS_MAX=16 chosen so worst-case recursion stays well inside a 2-page kernel thread stack; legitimate netgraph paths are far shallower. Upstream may prefer queueing instead of recursion (FreeBSD-style), which removes the need for the bound.
  • v1 netgraph (sys/netgraph/bpf/ng_bpf.c) forwards synchronously too (v1 ng_send_data calls rcvdata directly, ng_base.c:1687) β€” the v1 twin of this bug exists but v1 ng_bpf is a separate file with its own audit record (DF-2595 et al.); not re-filed here.

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

Both panic modes stopped: self mode now fails SET_PROGRAM with EINVAL at config time (error visible in sendto errno via the ng7 apply mechanism) and cross mode survives 30s+ with the cycle bounded (packet dropped with ELOOP, guest up). Two-stage matrix proves the ng_bpf-local guard alone is insufficient (cross still panicked, fix1_cross.log) and the framework hop bound completes the fix (fix2_cross.log).

fix1_self.log; fix1_cross.log; fix2_self.log; fix2_cross.log; fix.diff; panic.txt vs fix2_*.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (stock kernel; fix applied as ng7 module rebuilds only)

Confirmed kernel references

Detail

Evidence (decisive lines)

panic.txt + console_full.txt (stock self-mode panic trace); console_cross.txt (stock cross-mode panic β€” cycle without any self-reference); unpriv_check.txt (EPERM); fix1_self.log / fix1_cross.log (partial-fix matrix); fix2_self.log / fix2_cross.log (full fix, both modes survive); run.sh / build.sh

PoC changes

Seed sketch rewritten entirely: correct ng7 user ABI (NG_VERSION 8, header field order arglen/cmd/flags/token/typecookie), DFly sockaddr_ng layout (sa_family_t is __uint8_t, sg_data at offset 2, sg_len = 2 + strlen + 1), absolute-node addressing needs the trailing colon ('name:'), hook paths are DOT-separated ('in.x1' not 'in:x1'), cross-cycle needs NGM_CONNECT (each NGM_MKPEER creates a NEW node), data socket attach via connect() to the control node's '[id]:'/name.

Verified recommended fix

Reject ifMatch/ifNotMatch == thisHook in ng_bpf_setprog AND bound synchronous forwards per item in ng_snd_item (el_hops, NG_HOPS_MAX=16, ELOOP) β€” see fix.diff.

Verdict

Reproduced on the stock INVARIANTS guest with ng7 modules built from clean /usr/src: a cyclic ng_bpf configuration (two bpf nodes cross-connected, each with ifMatch == thisHook β€” or a pure multi-hook ifMatch cycle with no self-reference) makes ONE injected packet recurse synchronously through NG_FWD_ITEM_HOOK -> ng_snd_item -> ng_apply_item -> rcvdata with no hop bound, and each hop takes the destination node token recursively, overflowing the LWKT token array: 'panic: assertion "ref < &td->td_toks_end" failed in lwkt_gettoken_shared at sys/kern/lwkt_token.c:732' with the ng_apply_item recursion visible in the trace (panic.txt). The KKASSERT is compiled into production kernels, so the panic is deterministic on all configs; without the token tripwire the recursion would exhaust the ngthread kernel stack instead. Root-only trigger (ngc_attach caps_priv_check, EPERM verified for uid 1001), so impact is a privileged kernel DoS (Low, same class as DF-3486). fix.diff validated in two stages: the ng_bpf-local guard (reject ifMatch/ifNotMatch == thisHook) stops the self mode with EINVAL but the self-reference-free cross cycle still panics (fix1_cross.log), proving the framework part is required; the full fix (plus ng_snd_item hop bound el_hops/NG_HOPS_MAX=16 -> ELOOP) makes both modes survive with the packet dropped (fix2_*.log).