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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3531 Β· 17 files
FileTypeDescriptionSize
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_node in the ID hash and pinning the module (kldunload ng_tty stays "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)

  1. The baseline node-count growth is TWO stacked leaks: this one (items, via ng_uncallout) plus DF-3534 β€” ng7 ngt_shutdown never 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).
  2. The ng_callout oitem-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.
  3. 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 free panic β€” panic.txt). fix_status = inconclusive for that reason; the leak itself is flat.
VERDICT.md
↓ download raw

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-454 ng_callout(&sc->chand, sc->node, NULL, 1, ngt_timeout, NULL, 0)
  • ng_callout() (sys/netgraph7/netgraph/ng_base.c:3248-3268) allocates a struct ng_item carrying one NG_NODE_REF (ng_base.c:3257) and stores it as the callout argument.
  • ngt_close() cancels it: sys/netgraph7/tty/ng_tty.c:252-253 if (callout_pending(&sc->chand)) ng_uncallout(&sc->chand, sc->node);
  • ng_uncallout() (ng_base.c:3273-3296) frees the item only under if ((rval > 0) && callout_func(c) == trampoline && NGI_NODE(item) == node). On the stock kernel callout_stop() of an ARMED not-yet-fired callout completes inline and returns 0 (DF-3515: _callout_cancel_or_stop early return 0 before 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.c uses plain callout_reset/callout_stop with 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

inconclusive
baseline reproduced→ patch + rebuild →patched clean

Leak 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.
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Mon Sep 14 09:24:11 UTC 2026 (kern_timeout layer-1) + ng7 modules with layer-2

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).