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)
PoC verification
Evidence pack
findings/poc/DF-3557 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 ...
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).
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):
- control socket node named "uicyc" (NGM_NAME);
mkpeerUI β S.a β U0.downstream; name U0 "u0";mkpeerUI from U0 β U0.upstream β U1.downstream; name U1 "u1";rmhookuicyc:a β U0 survives with one hook (a UI node only self-destructs at zero hooks, ng_UI.c:241-244);connect(addressed to "u1:", path "downstream" resolves to U0 through the wire): U1.upstream β U0.downstream β closed 2-cycle;- 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 withlasthookset: downlinkβuplink, uplinkβdownlink, viaNG_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) usesng_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 returnslasthook = 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), andng_acquire_read/ writer pushes one LWKT tokref per hop intotd_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 twinng_con_nodes()(v1 ng_base.c:975-988) rejects same-graph cycles, andng_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
fixedBaseline (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)
Confirmed kernel references
- sys/netgraph7/UI/ng_UI.c:145
- sys/netgraph7/UI/ng_UI.c:148
- sys/netgraph7/UI/ng_UI.c:153
- sys/netgraph7/netgraph/ng_base.c:1961
- sys/netgraph7/netgraph/ng_base.c:3100
- sys/netgraph7/netgraph/ng_base.c:1804
- sys/netgraph7/netgraph/ng_base.c:3051
- sys/netgraph7/socket/ng_socket.c:301
- sys/netgraph7/socket/ng_socket.c:466
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.
No comments yet.