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

ng7 ng_UI FLOW-cookie relay in a closed UI<->UI hook cycle: unbounded synchronous control-message forwarding overflows the LWKT token array β€” deterministic kernel panic (DF-3540 class, ng_UI instance)

Summary

ng_UI_rcvmsg() relays any NGM_FLOW_COOKIE control message that arrives with lasthook set via NG_FWD_ITEM_HOOK with no bound. Two UI nodes wired into a closed ring (mkpeer+mkpeer+rmhook+connect; neither ng7 ng_con_nodes() nor ng_UI_newhook() reject graph cycles) plus ONE FLOW-cookie message hook-addressed to 'u0:downstream' (hook-addressed control delivery does not consume the wire, so it reaches nodes whose every hook is in the loop) relays synchronously forever: each ng_apply_item pushes an LWKT tokref into td_toks_array[32], tripping the always-on KKASSERT at lwkt_token.c:661 - panic, guest dead. VERIFIED on the stock INVARIANTS guest (sendto never returns; console shows the token-array KKASSERT with recursive ng_apply_item frames; guest down in DDB). Fix validated (atomic nesting counter NG_UI_FLOW_HOPS_MAX=8 -> ELOOP; guest survives repeated injections, errno 62). Root-gated (uid 1001 EPERM at socket creation). The pure-UI DATA cycle is NOT constructible (degree-2 node; data socket injects only via its own node's hooks; closed cycle has no spare hook) - the control relay is the only injection channel. v1 twin killed: v1 rcvmsg frees every control msg (no FLOW relay), control delivery carries no hook, data entry needs a free hook.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3557 Β· 14 files
FileTypeDescriptionSize
README.md file 2.3 KB ↓ raw
VERDICT.md file 5.7 KB ↓ raw
build.sh file 209 B view raw
console_full.txt file 2.2 KB view raw
env.txt file 613 B view raw
fix.diff file 1.5 KB view raw
fix_run.log file 681 B view raw
manifest.json file 1.3 KB view raw
panic.txt file 1.1 KB view raw
run.log file 786 B view raw
run.sh file 137 B view raw
ui_cycle.c file 5.9 KB view raw
unpriv_check.txt file 216 B view raw
verdict.json file 4.1 KB view raw
README.md file
↓ download raw

DF-3557 -- netgraph7 ng_UI: closed UI<->UI cycle + NGM_FLOW_COOKIE relay

Root cause

ng_UI_rcvmsg() (sys/netgraph7/UI/ng_UI.c:138-161) relays NGM_FLOW_COOKIE control messages between its two hooks with NG_FWD_ITEM_HOOK and no bound on nesting. The ng7 core applies forwarded items synchronously (ng_snd_item(): items with refs>1 go straight to ng_apply_item, netgraph/ng_base.c:1961), so a closed hook cycle relays forever, pushing one LWKT tokref per hop into the fixed td_toks_array[32] until lwkt_gettoken() trips its always-on KKASSERT.

A pure-UI cycle IS constructible even though a UI node has only two hooks (both consumed by the loop, leaving no data-injection port):

  • two UI nodes wired U0.upstream<->U1.downstream and U1.upstream<->U0.downstream (4 generic messages: mkpeer, mkpeer, rmhook, connect), and
  • injection through the CONTROL channel: a FLOW-cookie message sent to the path "u0:downstream" is delivered by ng_address_path() WITH el_hook set (netgraph/ng_base.c:3083-3105 -- hook-addressed delivery does not consume the wire), so ng_UI_rcvmsg() sees lasthook != NULL and starts the relay. The data socket cannot inject (it only addresses hooks of its own node, socket/ng_socket.c:466), and v1 netgraph has no hook-addressed control delivery at all -- this is ng7+ng_UI specific.

Trigger: ui_cycle.c (AF_NETGRAPH control socket only, no data socket needed). Build: build.sh. Run: run.sh.

Expected output (stock kernel): STEP connect U1.upstream<->U0.downstream -- RING CLOSED STEP injecting FLOW message to u0:downstream ... Serial console shows: panic: assertion "ref < &td->td_toks_end" failed in lwkt_gettoken at /usr/src/sys/kern/lwkt_token.c:661 ng_apply_item() x3+ recursive Debugger("panic") (panic.txt, console_full.txt)

Reachability: root-only. uid 1001 gets EPERM at socket() creation (ngc_attach caps_priv_check) -- unpriv_check.txt.

Fix (fix.diff): nesting counter bound (8) around the relay in ng_UI_rcvmsg(); pure cycles stay nested and trip ELOOP. Validated: patched module -> sendto returns errno 62 (ELOOP), guest survives repeated injections (fix_run.log); stock -> panic (run.log).

VERDICT.md file
↓ download raw

DF-3557 β€” VERDICT

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

What was verified

Guest: DragonFly 6.5-DEVELOPMENT #0 (stock INVARIANTS kernel, 2026-07-02 build), QEMU/KVM single-tenant, fresh vm.sh reset with-src before the decisive runs. ng7 stack (netgraph.ko, ng_socket.ko, ng_UI.ko) built from the guest's clean /usr/src with -Werror (build via build.sh).

Baseline (stock) β€” deterministic panic

Trigger ui_cycle.c (control socket only, no data socket needed):

  1. control socket node named "uicyc" (NGM_NAME);
  2. mkpeer UI β†’ S.a ↔ U0.downstream; name U0 "u0";
  3. mkpeer UI from U0 β†’ U0.upstream ↔ U1.downstream; name U1 "u1";
  4. rmhook uicyc:a β€” U0 survives with one hook (a UI node only self-destructs at zero hooks, ng_UI.c:241-244);
  5. connect (addressed to "u1:", path "downstream" resolves to U0 through the wire): U1.upstream ↔ U0.downstream β€” closed 2-cycle;
  6. inject one NGM_FLOW_COOKIE (851672669) message addressed to "u0:downstream".

Result: sendto() never returns; the ssh session dies with the guest. Serial console (panic.txt, console_full.txt):

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

Why it happens (source trace)

  • ng_UI_rcvmsg() β€” sys/netgraph7/UI/ng_UI.c:145-157 β€” relays any NGM_FLOW_COOKIE message that arrives with lasthook set: downlinkβ†’uplink, uplinkβ†’downlink, via NG_FWD_ITEM_HOOK (ng_UI.c:148, 153). No hop/nesting bound; cmd field is never examined.
  • Injection channel: ngc_send() (socket/ng_socket.c:301-306) uses ng_address_path(), which for a hook-terminated path sets the item's el_hook (netgraph/ng_base.c:3100-3103). Hook-addressed delivery does not consume the wire, so it reaches nodes whose every hook is already part of the loop. ng_path2noderef() walks through hooks and returns lasthook = peer of the final hook (ng_base.c:1804-1806), so "u0:downstream" is delivered to the node across the wire β€” inside the ring either way.
  • Each relay re-addresses via ng_address_hook() (ng_base.c:3051-3080) to the peer of the target hook = the other node's cycle hook; ng_snd_item() applies forwarded items synchronously (refs>1 β†’ ng_apply_item, ng_base.c:1948-1961), and ng_acquire_read/ writer pushes one LWKT tokref per hop into td_toks_array[32] (sys/sys/thread.h; KKASSERT at lwkt_token.c:661/732 always compiled).
  • Topology is constructible because neither ng_con_nodes() (ng_base.c:1442-1485) nor the v1 twin ng_con_nodes() (v1 ng_base.c:975-988) rejects same-graph cycles, and ng_UI_newhook() grants both hooks unconditionally (ng_UI.c:117-132).
  • The data path cannot be the entry: the data socket only addresses hooks of its own node (socket/ng_socket.c:466), and a closed UI cycle has no spare hook. The control relay is the enabling ng_UI code β€” that is what makes this a finding on this file (DF-3540 class, ng_UI instance).

This is the same framework property as DF-3540 (ng_bpf) / DF-3553 (ng_tee), but a distinct constructible instance: no bpf programs, no tee, two stock UI nodes and one FLOW message.

Unprivileged reachability

uid 1001 running the same trigger: socket(AF_NETGRAPH, …, NG_CONTROL) fails with errno 1 (EPERM) β€” ng7 ngc_attach gates socket creation via caps_priv_check (unpriv_check.txt). Root-only trigger; severity Low (privileged DoS), consistent with DF-3540/3553.

Exploit chain

None beyond denial of service: root-gated configuration, deterministic panic. No memory-safety violation β€” the tripwire is an always-on KKASSERT; no controlled overwrite occurs before it. No path to uid=0 (and the trigger itself requires root).

Fix validation (fix.diff, applied to the guest's /usr/src copy only)

fix.diff bounds nested synchronous FLOW relaying in ng_UI_rcvmsg() with an atomic nesting counter (NG_UI_FLOW_HOPS_MAX = 8): pure cycles stay nested for the whole relay, so the counter trips deterministically; every exit path decrements. Rebuilt ng_UI.ko alone (-Werror clean), swapped in over the stock module:

build FLOW injection into closed ring
stock ng_UI.ko panic (token-array KKASSERT), guest down (run.log)
patched ng_UI.ko sendto returns errno 62 (ELOOP); guest survives; repeated injections with fresh node names trip ELOOP again and the guest stays up (fix_run.log)

fix_status: fixed (baseline reproduced; patched reproduces neither the panic nor the hang; the ELOOP return exercises 8 full relay hops).

v1 twin β€” why there is NO v1 finding (negative result)

sys/netgraph/UI/ng_UI.c cannot participate in a self-contained cycle:

  • v1 ng_UI_rcvmsg() (v1 ng_UI.c:149-154) frees every message and returns EINVAL β€” no relay, so no control-message cycle.
  • v1 control delivery carries no hook (v1 ngc_send discards it; the v1 rcvmsg signature has no hook parameter).
  • v1 data entry needs a free hook on a cycle node; a closed UI cycle has none (data socket only addresses its own node's hooks, v1 socket/ng_socket.c:391-397).
  • A v1 cycle containing a UI node therefore requires a third node type with an injection port (bpf's ifMatch redirect β€” DF-3546; tee's left/right+connect β€” DF-3554); the UI is an interchangeable passenger, not the enabler. Not filed against this file.

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

Baseline (stock ng_UI.ko): deterministic panic, guest dead. Patched (ng_UI.ko rebuilt with fix.diff, only module swapped): sendto() returns errno 62 (ELOOP) after 8 bounded relay hops, guest survives repeated injections with fresh node names. fix.diff was authored against the read-only sys/ tree and applied only to the guest's /usr/src copy.

fix_run.log (patched runs: ELOOP, GUEST_STILL_UP); run.log + panic.txt (baseline panic); fix.diff (the patch)
↓ fix.diffper-fix-DF-3557

Confirmed kernel references

Detail

Evidence (decisive lines)

panic.txt + console_full.txt (panic banner + recursive ng_apply_item trace); run.log (baseline trigger output up to the hang, then guest death); fix_run.log (patched: errno 62 ELOOP, guest up across repeated runs); unpriv_check.txt (uid 1001 EPERM at socket creation); ui_cycle.c / build.sh / run.sh to re-run; VERDICT.md for the full trace

PoC changes

Wrote trigger from scratch (no seed existed for this file): ng7 control-socket-only harness modeled on the DF-3540 pack (NG_VERSION 8, AF_NETGRAPH 32, sockaddr_ng data at offset 2). Key discovery vs the lead: a pure-UI DATA cycle is impossible (degree-2 node, data socket only injects via its own node's hooks), but hook-addressed CONTROL delivery (ng_address_path setting el_hook) injects into a closed ring; also required the rmhook step so U0 survives with one hook (UI self-destructs at zero hooks) before the closing connect.

Verified recommended fix

Bound nested synchronous FLOW relaying in ng_UI_rcvmsg() with an atomic nesting counter (NG_UI_FLOW_HOPS_MAX=8) that returns ELOOP; see fix.diff

Verdict

Reproduced on the stock INVARIANTS guest: two ng7 UI nodes wired into a closed downstream/upstream ring (mkpeer+mkpeer+rmhook+connect) plus one NGM_FLOW_COOKIE control message hook-addressed to "u0:downstream" (delivered with lasthook set, no wire consumed) makes ng_UI_rcvmsg relay the item synchronously forever; ng_snd_item applies forwarded items inline, each ng_apply_item pushes an LWKT tokref, and the fixed td_toks_array[32] trips the always-on KKASSERT: 'panic: assertion "ref < &td->td_toks_end" failed in lwkt_gettoken at lwkt_token.c:661' with recursive ng_apply_item frames; guest dies in DDB. Root-gated (uid 1001 gets EPERM at socket() creation), so severity Low (privileged DoS), same class/ceiling as DF-3540/3553. No memory-safety violation, no write primitive, no path to uid=0. fix.diff (atomic nesting counter, max 8, ELOOP) validated: patched module returns errno 62 and survives repeated injections.