ng7 ng_tty: closing the tty with a pending chand callout permanently leaks the ng_item + node reference (ng_uncallout twin of DF-3515/DF-3523)
Summary
ngt_start() arms the pty-drain timer via ng_callout, storing an ng_item holding one NG_NODE_REF. ngt_close() cancels via ng_uncallout, but ng_uncallout frees the item only under rval>0 and on this kernel callout_stop returns 0 for armed not-yet-fired stops (DF-3515), so the gate never passes; even past the gate NG_FREE_ITEM is flag-only and ng_free_item is never called. Every tty close with a pending chand callout leaks one ng_item + one node ref permanently, pinning the ng_tty module and feeding the DF-3514 rehash panic. VERIFIED live: 12 -> 1.96K netgraph allocations (283K) per 2000 stall-close cycles on stock, kldunload stays Device busy, guest panicked in DF-3514 hashdestroy within 20 cycles without the rehash guard. Fix validated (leak flat, 9 items in-use across 4000 stall cycles) with CAVEATS documented in-pack: the kern_timeout PREVENTED patch is not dispatch-precise (close-vs-fire race can free an in-flight item -> 'memory chunk already free' slotimer panic), and the ng_callout oitem hunk from DF-3514/3523's fix.diff must NOT be shipped for 1-tick self-rearming timers (double-free on every re-arm).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3531 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| ngtty_leak.c | β | 8.5 KB | view raw | |
| build.sh | β | 237 B | view raw | |
| run.sh | β | 192 B | view raw | |
| fix_run.sh | β | 905 B | view raw | |
| build.log | β | 1.3 KB | view raw | |
| run.log | β | 1.0 KB | view raw | |
| fix_run.log | β | 1.3 KB | view raw | |
| fix_run.2.log | β | 1.4 KB | view raw | |
| fix_run.3.log | β | 1.3 KB | view raw | |
| fix_run.final.log | β | 453 B | view raw | |
| fix_run.final2.log | β | 1.3 KB | view raw | |
| panic.txt | β | 2.1 KB | view raw | |
| env.txt | β | 906 B | view raw | |
| fix.diff | β | 3.2 KB | view raw | |
| verdict.json | β | 5.5 KB | view raw | |
| VERDICT.md | β | 7.1 KB | β raw | |
| README.md | β | 4.9 KB | β raw |
DF-3531 β netgraph7 ng_tty: closing a tty with a pending chand callout
permanently leaks the ng_item + node reference
(ng_tty twin of DF-3515/DF-3523, same ng_uncallout defect)
What
ng7 ng_tty arms its output-drain timer with
sys/netgraph7/tty/ng_tty.c:453-454 (ngt_start) if (!IF_QEMPTY(&sc->outq) && !callout_pending(&sc->chand)) ng_callout(&sc->chand, sc->node, NULL, 1, ngt_timeout, NULL, 0);
ng_callout() (sys/netgraph7/netgraph/ng_base.c:3248-3268) stores a
struct ng_item β holding one NG_NODE_REF (ng_base.c:3257) β as the
callout argument. Tearing the line discipline down runs
sys/netgraph7/tty/ng_tty.c:252-253 (ngt_close) if (callout_pending(&sc->chand)) ng_uncallout(&sc->chand, sc->node);
ng_uncallout() (ng_base.c:3273-3296) frees that item only inside
if ((rval > 0) && ...). On this kernel callout_stop() of an ARMED,
not-yet-fired callout completes the stop inline and returns 0 (the
DF-3515 _callout_cancel_or_stop contract violation, kern_timeout.c
early return 0), so the gate never passes and the item is orphaned.
Even when the gate passes, NG_FREE_ITEM() is flag-only in ng7
(netgraph.h:787-793) β ng_free_item() is never called. Every tty
close with a pending chand callout permanently leaks:
- one
struct ng_item(~141 B incl. objcache backing), - one node reference β pinning the whole
ng_nodein the ID hash and pinning the module (kldunload ng_ttystays "Device busy").
Trigger
Root (ngt_open requires SYSCAP_NONET_NETGRAPH, ng_tty.c:188): open a pty, set ldisc NETGRAPHDISC(6), connect an ng7 data socket to "ttyN:hook", queue more output than the pty drains (master unread), and close the line discipline (fd close or TIOCSETD-away). The drain timer is then pending at ngt_close time and the cancel leaks.
Repeating this turns routine ng_tty use into unbounded permanent kernel
memory growth; the accumulating leaked refs also push the ng7 node
id/name hashes across the (separately filed, DF-3514) rehash panic
threshold β observed here: the very first 20-cycle smoke run without
the DF-3514 module fix wedged the guest in
panic: hashdestroy: hash not empty.
Files
ngtty_leak.c PoC (leak mode + "drain" control mode)
build.sh build ng7 modules (with DF-3514 rehash fix only, so
the known DF-3514 module-build panic does not mask
the measurement) + compile the PoC
run.sh baseline -> leak -> control -> leak -> kldunload check
fix.diff two-layer fix validated here (kern_timeout.c contract
+ ng_base.c item free), applied to the guest for the
fix-validation kernel/module
Build (in-guest, root)
cd /usr/src/sys/netgraph7/netgraph && make cd ../socket && make cd ../tty && make kldload .../netgraph.ko .../ng_socket.ko .../ng_tty.ko cc -O2 -o ngtty_leak ngtty_leak.c
Run (in-guest, root)
vmstat -m | grep -E '^[[:space:]]+netgraph ' # baseline ./ngtty_leak 2000 # stall-close: LEAKS ./ngtty_leak 2000 drain # drained control: ~flat kldunload ng_tty # -> "Device busy"
Observed (guest, DragonFly 6.5-DEVELOPMENT #0 INVARIANTS stock)
baseline 12 allocs 1.69K
after 2000 stall-close cycles 1.96K allocs 283K (+~1.9 allocs/cycle)
after 2000 drain-close cycles 2.32K allocs 334K (mostly flat; the
residual is cycles whose drain
outlasted the sleep windows and hit
the same leak path)
after 2000 more stall-cycles 3.98K allocs 573K (monotone growth)
kldunload ng_tty "Device busy" (refs=0, pinned by leaks)
With BOTH fix layers installed (kern_timeout.c PREVENTED-reporting +
ng_base.c ng_free_item), the identical 2000-cycle stall-close run is
FLAT β see VERDICT.md and fix_run.log.
IMPORTANT verification-stage discoveries (read VERDICT.md)
- The baseline node-count growth is TWO stacked leaks: this one (items,
via ng_uncallout) plus DF-3534 β ng7
ngt_shutdownnever drops the constructor node reference, so every ng_tty node leaks (even a bare TIOCSETD on/off cycle with no hook, no data, no timer). - The
ng_calloutoitem-free hunk from DF-3514/DF-3523's fix.diff is UNSAFE for ng_tty's 1-tick self-rearming timer (re-arm from inside the callback frees the in-flight fired item β double-free; reproduced). It is excluded from this pack's fix.diff. - The layer-1 kern_timeout fix, as validated, is not dispatch-precise:
under close-vs-fire racing it reported PREVENTED for an already-
dispatched callback and ng_uncallout freed an in-flight item
(delayed
memory chunk already freepanic β panic.txt). fix_status = inconclusive for that reason; the leak itself is flat.
DF-3531 β VERDICT
Finding: netgraph7 ng_tty β closing the tty while the chand drain callout
is armed permanently leaks the timer ng_item and its node reference
(the ng_tty twin of DF-3515/DF-3523's ng_uncallout defect).
Status: REPRODUCED (leak, certain) on the stock INVARIANTS guest.
Fix: leak measurably eliminated by layers 1+2, but stress-testing the
patched state surfaced a residual fire/close race that can free an
in-flight timer item β heap double-free (delayed chunk_mark_free
panic). fix_status = inconclusive β see "Fix validation" below.
Root cause (line-accurate)
ngt_start()arms the pty-drain timer:sys/netgraph7/tty/ng_tty.c:453-454ng_callout(&sc->chand, sc->node, NULL, 1, ngt_timeout, NULL, 0)ng_callout()(sys/netgraph7/netgraph/ng_base.c:3248-3268) allocates astruct ng_itemcarrying oneNG_NODE_REF(ng_base.c:3257) and stores it as the callout argument.ngt_close()cancels it:sys/netgraph7/tty/ng_tty.c:252-253if (callout_pending(&sc->chand)) ng_uncallout(&sc->chand, sc->node);ng_uncallout()(ng_base.c:3273-3296) frees the item only underif ((rval > 0) && callout_func(c) == trampoline && NGI_NODE(item) == node). On the stock kernelcallout_stop()of an ARMED not-yet-fired callout completes inline and returns 0 (DF-3515:_callout_cancel_or_stopearlyreturn 0before the PREVENTED-reporting path, sys/kern/kern_timeout.c) β the gate never passes β the never-sent item (refs==0) is orphaned forever: one ng_item + one node ref leaked per close. Even if the gate passed,NG_FREE_ITEM()is flag-only in ng7 (sys/netgraph7/netgraph.h:787-793) βng_free_item()is never invoked.
Reproduction (baseline: stock kernel #0 + stock modules)
Harness: ngtty_leak.c (root; ngt_open requires SYSCAP_NONET_NETGRAPH,
ng_tty.c:188). Per cycle: /dev/ptmx pty β TIOCSETD=NETGRAPHDISC(6) β
NGIOCGINFO β ng7 NGM_CONNECT from a data-socket node to "ttyN:hook" β
sendto 1500 B (master never read β t_outq pinned at NGT_HIWATER=400,
remainder sits in sc->outq, chand re-arms every tick) β TIOCSETD back to
termios (the tty.c:1137 l_close path calls ngt_close synchronously; same
ngt_close code as fd-close) β sockets closed.
vmstat -m | grep netgraph:
baseline 12 allocs 1.69K
2000 stall-close cycles 1.96K allocs 283K <-- ~1/cycle leaked
2000 drain-close (control) 2.32K allocs 334K (mostly flat; the
residual = cycles whose drain outlasted
the sleep windows and hit the same leak)
2000 more stall-close cycles 3.98K allocs 573K (monotone)
kldunload ng_tty "Device busy" (refs=0 β pinned by leaked refs)
Corroboration: the very first 20-cycle smoke run (stock modules, before the
known DF-3514 module-build rehash fix was pre-applied) wedged the guest in
panic: hashdestroy: hash not empty β the leaked node refs had already
pushed ng_nodes past the rehash threshold within ~20 cycles (DF-3514 feed).
Note on measurement: the node-count part of the baseline delta is
contaminated by a second, independent ng_tty leak discovered during this
verification (DF-3534: ngt_shutdown never drops the constructor node ref β
every ng_tty node leaks regardless of the callout). The item leak
(+1.95K/2000, M_NETGRAPH netgraph type) is DF-3531's alone: with DF-3534's
node fix but stock ng_uncallout behavior β¦ items still climb; with the
ng_uncallout fix items go flat while nodes still climb (fix_run.3.log) β
the two leaks are cleanly separable and each fix is independently effective
for its own allocation class.
Fix validation
Layers applied (fix.diff):
1. sys/kern/kern_timeout.c β _callout_cancel_or_stop reports PREVENTED
for inline-completed stops (the DF-3515 contract fix, same diff DF-3523
validated).
2. sys/netgraph7/netgraph/ng_base.c β ng_uncallout() calls
ng_free_item(item) after the flag-only NG_FREE_ITEM (layer 2).
Result (kernel #1 + rebuilt modules, fix_run.final2.log):
baseline: netgraph(items)=2 in-use, netgraph_node=2
nodd 500 (ldisc on/off only): nodes 2 -> 2 (flat; DF-3534 effect)
stall-close 2000: items 9 in-use (2.45K allocs cycled),
nodes 2 (flat)
stall-close 2000 (2nd): items 9, nodes 2 (FLAT over 4000)
hooks/paths/socks in-use 0 throughout; kldunload ng_tty succeeded once
the ref pinning was gone (final battery, earlier run: KLDUNLOAD_OK).
Residual hazard (why fix_status=inconclusive): under aggressive
close-vs-fire racing (drain-mode stress, and earlier an instrumented build)
the patched state double-freed an in-flight timer item:
* instrumented module: Fatal trap 12 in ng_snd_item
(lock addl $0x1,0x80(%rdi), el_node==NULL) β an item already
dispatched to the cpu0 netgraph port had been freed by ng_uncallout;
* clean modules: delayed panic: memory chunk 0x... is already free!
(chunk_mark_free via slab_cleanup/slotimer_callback) after the
flat 4000-cycle battery, during drain-mode stress.
Mechanism: when ngt_close's ng_uncallout races the 1-tick fire β the
trampoline has dequeued the item and handed it to lwkt_sendmsg (cpu0) β
the layer-1 patched callout_stop can report PREVENTED (rval>0) for a
callout whose callback already dispatched, so ng_uncallout frees an item
that is queued/in-flight (refs==1 held by ng_snd_item). Stock code cannot
do this (it never frees). CONSEQUENCE FOR UPSTREAM: the DF-3515/DF-3523
kern_timeout fix layer must guarantee PREVENTED is reported only when
the callback never dispatched; alternatively ng_uncallout must distinguish
"stopped before dispatch" from "already dispatched" (e.g. trampoline-side
arg clearance). This also means the ng_callout oitem-free hunk shipped
in DF-3514/DF-3523's fix.diff is dangerous for self-rearming 1-tick timers
(ng_tty re-arms from inside ngt_timeout): it frees the in-flight fired
item on every re-arm β do NOT include that hunk for ng_tty-class users
(it was excluded from this pack's fix.diff after it reproduced the same
double-free signature immediately).
What was NOT the leak (negative results)
- v1
sys/netgraph/tty/ng_tty.cuses plaincallout_reset/callout_stopwith no ng_item β no ng_uncallout twin exists in v1. - The generic ng7 socket/connect/data path leaks nothing (socksock control flat) β the tty ldisc is the sole leaker of its own items/nodes.
Environment
Baseline kernel: DragonFly 6.5-DEVELOPMENT #0 (stock INVARIANTS X86_64_GENERIC, Thu Jul 2 06:02:54 2026). Fix kernel: #1 Mon Sep 14 09:24:11 2026 (kern_timeout layer-1). Modules built in-guest from /usr/src/sys/netgraph7 with gcc80.
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
inconclusiveLeak itself FIXED measurably: items flat (9 in-use) across 4000 stall-close cycles vs +1.95K/2000 on stock; module unpinned. NOT clean overall: under close-vs-fire race stress the patched state freed an in-flight timer item -> heap double-free (immediate ng_snd_item el_node==NULL trap; delayed 'memory chunk already free' slotimer panic, panic.txt). Root: layer-1 reports PREVENTED for a callback that already dispatched (item in flight on the cpu0 netgraph port, refs==1). The kern_timeout fix must be dispatch-precise before this is shippable; ng_tty-class 1-tick self-rearming timers are the worst case. The ng_callout oitem hunk from DF-3514/3523 fix.diff was EXCLUDED here after it reproduced the same double-free signature on every re-arm.
fix_run.log (=fix_run.final2.log): flat items/nodes over nodd500+stall2000x2 on kernel#1+layer2+DF-3534 module; panic.txt: 'panic: memory chunk 0xfffff8004f0a2740 is already free!' chunk_mark_free<-slab_cleanup<-slotimer_callback after drain-mode stress; earlier instrumented run: 'Fatal trap 12 ng_snd_item lock addl $0x1,0x80(%rdi)' with el_node==NULL.
Confirmed kernel references
Detail
Exploit chain
unpriv-reachable? No: TIOCSETD->ngt_open requires SYSCAP_NONET_NETGRAPH (ng_tty.c:188). Chain for a capability-holding context: stall a tty's output (outq non-empty -> chand re-arms every tick), close the ldisc -> 1 item+node ref (~140B) leaked per cycle, unbounded; leaked refs pin the ng_tty module forever and push ng_nodes past the DF-3514 rehash panic threshold (panicked within 20 cycles pre-DF-3514-fix) -> local DoS / permanent kernel memory exhaustion.
Evidence (decisive lines)
run.log (baseline: 12 -> 1.96K/283K per 2000 stall cycles, control ~flat, kldunload busy); fix_run.log (kernel#1 + layer-2 module: items 9 in-use FLAT over 4000 stall cycles, nodes flat with DF-3534); panic.txt (delayed chunk_mark_free double-free on patched state under drain stress); VERDICT.md for the full separation of DF-3531 (items) vs DF-3534 (nodes) via the isolation runs in DF-3534's pack (tty_leak2 nodd/nodata/socksock/full).
PoC changes
Rewrote trigger vs the seed: close via TIOCSETD-away instead of fd-close (avoids the tty output-drain wait blocking close forever with a stalled master), O_NONBLOCK master for the drain control, NGIOCGINFO type check to prove the ng7 ldisc owns the slot, AF_NG=32 socket ABI harness mirroring DF-3523's working one. Pre-applied the known DF-3514 rehash module fix during baseline measurement so the known panic cannot mask the leak (documented in build.sh).
Verified recommended fix
kern_timeout.c: report PREVENTED only when the callback never dispatched (fix the ran-during-stop window); ng_base.c ng_uncallout: ng_free_item(item) after the flag-only NG_FREE_ITEM (keep the rval>0 + trampoline + node-match gate); do NOT free oitem in ng_callout for re-arm-from-callback timers.
Verdict
REPRODUCED on stock INVARIANS guest: every ng7 ng_tty close with a pending chand callout permanently leaks one ng_item + one node reference (ng_uncallout's rval>0 gate never passes on this kernel -- DF-3515 callout_stop contract -- and even past the gate NG_FREE_ITEM is flag-only). 12 -> 1.96K netgraph allocations (283K) per 2000 stall-close cycles; kldunload ng_tty stays 'Device busy'; the leaked refs feed the DF-3514 rehash panic (observed within 20 cycles without DF-3514's module fix). Trigger is privileged (ngt_open needs SYSCAP_NONET_NETGRAPH). Fix layers 1+2 (kern_timeout PREVENTED contract + ng_free_item in ng_uncallout) measurably eliminate the leak (items flat: 9 in-use across 4000 stall cycles) BUT aggressive close-vs-fire racing on the patched state double-freed an in-flight timer item (immediate ng_snd_item NULL-el_node trap on an instrumented build; delayed 'memory chunk already free' slotimer panic on clean modules) -- PREVENTED is being reported for a callout whose callback already dispatched. fix_status=inconclusive; the kern_timeout layer needs a dispatch-precise PREVENTED (flagged against DF-3515/DF-3523), and the ng_callout oitem-free hunk from DF-3514/3523's fix.diff must NOT be applied for self-rearming 1-tick timers like ng_tty (it reproduces the same double-free on every re-arm).
No comments yet.