v1 ng_bpf: cyclic ifMatch/ifNotMatch forwarding (incl. ifMatch == thisHook) + v1 core's unbounded synchronous ng_send_data recursion exhausts the kernel thread stack β deterministic panic
Summary
ng_bpf_setprog() accepts ifMatch/ifNotMatch that equal thisHook (or form cross-node cycles) with no validation, and ng_bpf_rcvdata() forwards via NG_SEND_DATA into ng_send_data(), which calls the peer's rcvdata directly in the same thread with no hop limit, no item, and no token (v1 core is crit_enter()-only). One packet injected into a cyclic graph recurses until the kstack guard page double-faults: 'DOUBLE FAULT - KERNEL STACK GUARD HIT!' / 'panic: double fault', rip inside ng_bpf.ko. VERIFIED live on the stock INVARIANTS guest in BOTH a self-referential two-node config and a three-hook cycle with no self-reference (fresh vm.sh reset with-src boots; sendto never returns; guest down). v1 twin of DF-3540 (ng7), different failure mode (kstack exhaustion, not LWKT token-array KKASSERT). Root-gated (caps_priv_check; uid 1001 verified errno 43). Fix validated live (two-part: setprog rejects self-bounce; rcvdata bounds nested forwarding at 16 hops with an atomic depth counter -> ELOOP; both modes survive). LEAD (a) also killed: v1 SET_PROGRAM DOES validate via bpf_validate - DF-3444's primitive not reachable here either.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3546 Β· 21 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.4 KB | β raw | |
| VERDICT.md | β | 4.1 KB | β raw | |
| ng_cycle_v1.c | β | 8.2 KB | view raw | |
| build.sh | β | 180 B | view raw | |
| run.sh | β | 163 B | view raw | |
| build.log | β | 71 B | view raw | |
| run.log | β | 227 B | view raw | |
| run.self.log | β | 227 B | view raw | |
| run.cross.log | β | 0 B | β download | |
| console_self.txt | β | 1.9 KB | view raw | |
| console_cross_tail.txt | β | 628 B | view raw | |
| console_full.txt | β | 1.9 KB | view raw | |
| panic.txt | β | 434 B | view raw | |
| unpriv_check.txt | β | 100 B | view raw | |
| env.txt | β | 203 B | view raw | |
| fix.diff | β | 2.8 KB | view raw | |
| fix_build.log | β | 1.7 KB | view raw | |
| fix1_self.log | β | 141 B | view raw | |
| fix2_cross.log | β | 333 B | view raw | |
| manifest.json | β | 1.2 KB | view raw | |
| verdict.json | β | 3.5 KB | view raw |
DF-3546 β netgraph (v1) ng_bpf: cyclic ifMatch/ifNotMatch forwarding β unbounded synchronous recursion β kernel stack exhaustion (panic)
v1 twin of DF-3540 (ng7). ng_bpf_setprog() (sys/netgraph/bpf/ng_bpf.c:474-496)
resolves ifMatch/ifNotMatch as hook names on the same node with no check
that the target differs from thisHook and no cycle detection, and v1
netgraph forwards synchronously β NG_SEND_DATA (ng_bpf.c:431) β
ng_send_data() (sys/netgraph/netgraph/ng_base.c:1678-1698) calls the peer's
rcvdata directly in the same thread, with no hop bound and no LWKT token
acquisition (v1 core uses only crit_enter()). A cyclic configuration makes
one injected packet recurse until the kernel thread's kstack guard page is hit:
DOUBLE FAULT - KERNEL STACK GUARD HIT! Fatal double fault rip = 0xffffffff82656970 <- ng_bpf.ko (ng_bpf_rcvdata) rsp = 0xfffff8008d1f6000 panic: double fault (verified on both cycle modes)
Unlike ng7 (DF-3540, token-array KKASSERT at 32 hops), v1 has no tripwire at all β the recursion simply runs the kstack off the end; the guard-page double fault is the only stop.
Trigger privilege
ROOT ONLY (verified live): v1 control sockets require
caps_priv_check(SYSCAP_RESTRICTEDROOT|__SYSCAP_NULLCRED)
(sys/netgraph/socket/ng_socket.c:172-174); uid 1001 gets errno 43
(EPROTONOSUPPORT) on socket(AF_NETGRAPH, ..., NG_CONTROL) (unpriv_check.txt).
Root must build the cyclic graph; one packet (root-injected, or any traffic
source once configured) panics the machine. Severity Low (privileged DoS),
same class as DF-3540.
Modes (ng_cycle_v1.c)
self : two ng_bpf nodes, B1.x1 <-> B2.y1, both progs ifMatch == thisHook ("echo on arrival hook" is what keeps the packet in the pair); injection via B1.x2. cross : three-hook cycle with NO self-reference at all (B1.e -ifMatch-> B1.p <-> B2.q -ifMatch-> B2.s <-> B1.r -ifMatch-> B1.p ...), proving the cycle class, not just self.
Build / Run (in-guest, root, stock 6.5-DEVELOPMENT #0 INVARIANTS)
sh build.sh # cc -O2 -o /root/ng_cycle_v1 /root/ng_cycle_v1.c sh run.sh self # expect: DOUBLE FAULT - KERNEL STACK GUARD HIT, guest down # after vm.sh reset with-src: sh run.sh cross # expect: same panic
Fix validation (fix.diff)
stock self -> panic cross -> panic
patched module self -> EINVAL (ifMatch==thisHook rejected)
cross -> sendto errno 62 (ELOOP, depth bound 16), survives
Patch applied to the guest's /usr/src copy only; module rebuilt with -Werror (fix_build.log); never applied to the audit tree.
Files
ng_cycle_v1.c trigger (v1 NG_VERSION 2 ABI, sync control msgs) build.sh run.sh exact commands build.log compiler output (trigger) run.self.log decisive stock run (self): panic run.cross.log stock cross run (ssh output lost when guest died) console_self.txt serial console: boot + DOUBLE FAULT (self) console_cross_tail.txt / console_full.txt serial console (cross) panic.txt panic signature unpriv_check.txt uid 1001 -> errno 43 on control socket fix.diff two-part fix (thisHook guard + depth bound) fix_build.log patched module build (-Werror clean) fix1_self.log patched: self -> SET_PROGRAM EINVAL, survives fix2_cross.log patched: cross -> ELOOP drop, survives 30s env.txt guest uname / kernel
DF-3546 β VERDICT
status: reproduced / impact: panic / confidence: certain
What was verified, step by step
Guest: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, stock INVARIANTS,
2026-07-02 build), QEMU/KVM single-tenant, fresh vm.sh reset with-src
before each decisive run.
Baseline (stock kernel + stock /boot/kernel modules)
kldload netgraph ng_socket ng_bpf(v1 modules).-
./ng_cycle_v1 self(root): * graph built: socket nodebpcycβinβ B1.x2; B1.x1 β B2.y1; SET_PROGRAM B1: x2βx1, x1βx1; B2: y1βy1 (all accepted, stock has no thisHook guard). * 1-bytesendtoon the data socket never returns β the ssh session dies with the guest. Serial console (console_self.txt, panic.txt):DOUBLE FAULT - KERNEL STACK GUARD HIT! Fatal double fault rip = 0xffffffff82656970 <- ng_bpf.ko base 0xffffffff82656000 rsp = 0xfffff8008d1f6000 panic: double fault
rip is inside ng_bpf.ko (+0x970 = ng_bpf_rcvdata); rsp sits exactly at the kstack guard boundary. The recursion is:
ngd_sendβNG_SEND_DATAβng_send_data(ng_base.c:1687 calls(*rcvdata)(hook->peer, m, meta)directly, same thread) βng_bpf_rcvdataβNG_SEND_DATAβ β¦ with no bound anywhere in v1. 3.vm.sh reset with-src, then./ng_cycle_v1 cross(root): identical DOUBLE FAULT at the identical rip after a clean boot (console_cross_tail.txt, console_full.txt) β the no-self-reference 3-hook cycle class panics too. (First cross attempt failed with ENOENT from a path bug in my trigger βcon.pathmust be relative to the addressed node ("p"), not socket-relative ("in.p"); fixed.)
Unprivileged reachability
uid 1001 running the same trigger: socket(AF_NETGRAPH, β¦, NG_CONTROL)
fails with errno 43 (EPROTONOSUPPORT) β v1 ngc_attach gates socket
creation on caps_priv_check(SYSCAP_RESTRICTEDROOT|__SYSCAP_NULLCRED)
(sys/netgraph/socket/ng_socket.c:172-174). The trigger is root-only;
severity capped at Low (privileged DoS), consistent with DF-3540.
Fix validation (fix.diff, applied to the guest's /usr/src copy only)
- Part 1 β
ng_bpf_setprog()rejects non-emptyifMatch/ifNotMatchequal tothisHook(forwarding out the arrival hook is always a cycle; the empty default program still passes). - Part 2 β
ng_bpf_rcvdata()bounds nested synchronous forwarding at 16 hops with an atomic depth counter decremented after the forward (so pure cycles stay nested and trip the bound; unwinding is clean).
Module rebuilt in-guest with -Werror (fix_build.log, clean), loaded
from /usr/src/sys/netgraph/bpf/ng_bpf.ko:
| run | stock | patched |
|---|---|---|
| self | DOUBLE FAULT panic, guest down | SET_PROGRAM in(x1->x1) FAILED errno=22, guest up (fix1_self.log) |
| cross | DOUBLE FAULT panic, guest down | sendto errno=62 (ELOOP) β dropped at depth 16 β RESULT no-panic survived 30s, guest up (fix2_cross.log) |
fix_status: fixed (baseline reproduced both modes; patched reproduces neither; per-hop filter/forward behavior intact β the ELOOP run exercised 16 full match+forward hops).
Why this is a real v1 finding and not a DF-3540 duplicate
DF-3540 was filed against sys/netgraph7/bpf/ng_bpf.c (itemized ng7 core, per-node tokens β token-array KKASSERT overflow). This finding is the v1 twin: sys/netgraph/bpf/ng_bpf.c on the v1 core (sys/netgraph/netgraph/), which has no items, no tokens and no hop accounting β the failure mode is plain kstack exhaustion (guard-page double fault), verified on this guest's stock v1 modules.
Exploit ceiling
Write-capable? No β this is pure recursion depth (no memory overwrite before the guard page). The guard-page double fault makes it a clean privileged DoS; there is no path to uid=0 (and the trigger itself requires root).
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 applied to guest /usr/src only; ng_bpf.ko rebuilt -Werror-clean and loaded. Stock: self=panic, cross=panic. Patched: self -> SET_PROGRAM EINVAL (thisHook guard), cross -> sendto ELOOP at depth 16, guest survives 30s in both. fix.diff never applied to the audit sys/ tree.
fix1_self.log (errno=22 EINVAL on x1->x1), fix2_cross.log (errno=62 ELOOP, RESULT no-panic survived 30s), fix_build.log (-Werror module build)
Confirmed kernel references
Detail
Evidence (decisive lines)
panic.txt (double fault signature, rip=0xffffffff82656970 inside ng_bpf.ko), console_self.txt + console_cross_tail.txt (fresh boot then panic for each mode), run.self.log (program output stops at injection; ssh died with guest), unpriv_check.txt (uid 1001 EPROTONOSUPPORT), fix1_self.log + fix2_cross.log (patched module: EINVAL guard / ELOOP depth-drop, guest survives)
PoC changes
Adapted the ng7 DF-3540 trigger to the v1 user ABI: NG_VERSION 2 header layout (arglen u16 at offset 2, flags/token/typecookie/cmd order), NGM_GENERIC_COOKIE 851672668, synchronous control delivery (errors via errno, no NODEINFO round-trip needed), named socket node for the data-socket connect. Fixed one trigger bug found during verification: NGM_CONNECT con.path must be relative to the addressed node ('p'), not socket-relative ('in.p') -- first cross attempt failed ENOENT.
Verified recommended fix
ng_bpf_setprog: reject ifMatch/ifNotMatch == thisHook (non-empty); bound recursive forwarding depth in ng_bpf_rcvdata (ELOOP past 16 hops) -- or better, add a hop bound to v1 ng_send_data itself.
Verdict
v1 ng_bpf accepts cyclic ifMatch/ifNotMatch configs (incl. ifMatch==thisHook) and the v1 core forwards synchronously with no hop bound (ng_send_data calls the peer rcvdata directly), so one injected packet recurses until the kernel stack guard page double-faults. Verified live on the stock INVARIANTS guest in both self and cross cycle modes: 'DOUBLE FAULT - KERNEL STACK GUARD HIT!' / 'panic: double fault', rip inside ng_bpf.ko. Root-only trigger (v1 ngc_attach caps_priv_check; uid 1001 gets errno 43), so privileged DoS like the ng7 twin DF-3540.
No comments yet.