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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3546 Β· 21 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download raw

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)

  1. kldload netgraph ng_socket ng_bpf (v1 modules).
  2. ./ng_cycle_v1 self (root): * graph built: socket node bpcyc β€” 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-byte sendto on 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.path must 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-empty ifMatch/ifNotMatch equal to thisHook (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

fixed
baseline reproduced→ patch + rebuild →patched clean

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

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.