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

ng7 ng_tty: ngt_shutdown() never drops the constructor node reference β€” every ng_tty node ever created leaks

Summary

ng7's ng_rmnode() drops only its own extra reference when the node types has a shutdown method; the constructor reference is the shutdown method's job (ng_pred1.c:351 'Let the node escape'; the v1 twin does it at sys/netgraph/tty/ng_tty.c:554). ng7 ngt_shutdown frees sc but never calls NG_NODE_UNREF(node), so after every ldisc teardown the node refcount parks at 1 and the ng_node + ID-hash entry leak permanently. Isolation-proven: bare TIOCSETD on/off (no hook, no data, no timer) leaks +1 node/cycle while a socket<->socket control is flat. VERIFIED + fix validated: 500 plain TIOCSETD(6)/TIOCSETD(0) cycles -> netgraph_node in-use 3 -> 503 on stock; with the 2-line fix (SET_PRIVATE(NULL)+NG_NODE_UNREF) flat at 2 across nodd-500 and 2x2000 stall-close cycles.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3534 Β· 9 files
FileTypeDescriptionSize
tty_leak2.c β€” 5.8 KB view raw
isolation.log β€” 1.1 KB view raw
fix_validation.log β€” 1.3 KB view raw
build.log β€” 440 B view raw
env.txt β€” 906 B view raw
fix.diff β€” 344 B view raw
verdict.json β€” 3.1 KB view raw
VERDICT.md β€” 2.8 KB ↓ raw
README.md β€” 3.0 KB ↓ raw

DF-3534 β€” netgraph7 ng_tty: ngt_shutdown() never drops the constructor

node reference β€” every ng_tty node ever created leaks

What

In netgraph7 the node-teardown contract (see ng_rmnode, sys/netgraph7/netgraph/ng_base.c:686-751) is: when a node type supplies a shutdown method, ng_rmnode() drops only its own extra reference; the constructor (creation-time) reference must be dropped by the shutdown method itself. ng_pred1 in the same tree documents the idiom explicitly:

sys/netgraph7/ng_pred1.c:349-351
    kfree(priv, M_NETGRAPH_PRED1);
    NG_NODE_SET_PRIVATE(node, NULL);
    NG_NODE_UNREF(node);        /* Let the node escape. */

ng7 ngt_shutdown() (sys/netgraph7/tty/ng_tty.c:553-571) frees sc but never calls NG_NODE_UNREF(node) (and never clears the private pointer) β€” so after ngt_close() tears the line discipline down, the node's refcount stops at 1 and the ng_node is never freed:

leak = one ng_node (~176 B, M_NETGRAPH_NODE) + its ID-hash entry
       per netgraph-tty ldisc install/removal, forever.

The v1 twin got this right (sys/netgraph/tty/ng_tty.c:554 does ng_unref(sc->node)); the ng7 port dropped the call.

Trigger (root: ngt_open requires SYSCAP_NONET_NETGRAPH)

Bare minimum β€” no hook, no data, no timer needed:

posix_openpt(); grantpt(); unlockpt();
slave = open(ptsname(master));
ioctl(slave, TIOCSETD, &(int){6});    /* NETGRAPHDISC  -> ngt_open  */
ioctl(slave, TIOCSETD, &(int){0});    /* back to termios -> ngt_close */
close(slave); close(master);          /* -> one ng_node leaked */

Repeatable without bound; the accumulating nodes permanently pin the ng_tty module (kldunload: Device busy), squat the node ID/name hashes and β€” as DF-3513/DF-3514 already established β€” push ng_nodes across the ng7 hash-rehash panic threshold (on stock module builds) turning routine ldisc churn into panic: hashdestroy: hash not empty.

Isolation proof (isolation.log, tty_leak2.c)

On a kernel/module with the DF-3531 ng_uncallout item leak already fixed (so items are flat and cannot mask anything):

nodd 500      (ldisc on/off only)   nodes 3 -> 503   (+1/cycle)
nodata 500    (+ socket connect)    503 -> 1003      (+1/cycle)
socksock 500  (socket<->socket, no tty) 1003 -> 1003 (FLAT)
full 500      (+ data)              1003 -> 1.47K    (+1/cycle)

=> the leak is the tty ldisc lifecycle alone; the generic connect/data path is clean; the chand callout is irrelevant (nodd leaks with no timer ever armed).

Fix (fix.diff β€” validated)

Add NG_NODE_SET_PRIVATE(node, NULL); NG_NODE_UNREF(node); at the end of ngt_shutdown (mirroring ng_pred1.c:350-351). Validated on the guest (fix_validation.log): nodd 500 and 2x2000 stall-close cycles all leave netgraph_node in-use flat at 2 (was +1/cycle); netgraph_hook stays 0.

Build / Run

cc -O2 -o tty_leak2 tty_leak2.c
vmstat -m | grep netgraph_node
./tty_leak2 nodd 500
vmstat -m | grep netgraph_node      # stock: +500
VERDICT.md
↓ download raw

DF-3534 β€” VERDICT

REPRODUCED (leak, certain). Discovered during the DF-3531 Phase V when the post-fix node counts refused to go flat: a refcount ledger (instrumented ng_unref_node + inlined _NG_NODE_REF with return-address tagging) showed the tty node acquiring 9 refs and releasing only 8 in a single connect+data cycle, with the orphan being the creation-time reference β€” and isolation runs then proved the leak needs nothing but a ldisc install/remove.

Mechanism

ng7 ng_rmnode() (ng_base.c:686-751) unrefs only its own extra reference when the type has a shutdown method; the constructor ref is the shutdown method's job (ng_pred1.c:351 does exactly this). ng7 ngt_shutdown (tty/ng_tty.c:553-571) never does NG_NODE_UNREF(node) β†’ after ngt_close the node refcount parks at 1 forever.

Ledger excerpt (1 cycle, node 0xfffff8004f1e87f0; full trace in the DF-3531 verification session): 9 NGREF acquisitions (creation via ngt_openβ†’ng_make_node_common; hook; timer items; connect-path noderef; rmnode extra; …) vs 8 NGUNREF releases β€” last observed state refs=1, never freed. Socket nodes in the same cycles reach -> FREED β€” only the tty nodes strand.

Reproduction numbers

Stock-equivalent behavior (kernel #1 + layer-2 module, i.e. the DF-3531 item leak already neutralized β€” see isolation.log):

nodd 500:     netgraph_node 3 -> 503    (+1/cycle, no hook/data/timer)
nodata 500:   -> 1003                   (+1/cycle)
socksock 500: -> 1003                   (FLAT β€” generic path clean)
full 500:     -> 1.47K                  (+1/cycle)

On the fully stock kernel/module the effect is the same +1 node/cycle stacked under DF-3531's item leak (baseline run.log of DF-3531: netgraph_node 2 β†’ 1.96K after 2000 stall cycles).

Fix validation

fix.diff = 2 lines (SET_PRIVATE(NULL) + NG_NODE_UNREF) in ngt_shutdown, module rebuild only. Result (fix_validation.log, kernel #1 + layer-2 + this fix):

nodd 500:                 nodes 2 -> 2   (FLAT)
stall-close 2000 x2:      nodes 2 -> 2 -> 2 (FLAT), items 9 in-use flat
netgraph_hook in-use 0 throughout

The patched state later panicked during DF-3531's drain-mode stress with memory chunk already free β€” that is the DF-3531 layer-1/layer-2 close-vs-fire race (double-free of an in-flight timer item), NOT this fix: nodd-mode (no timer at all) ran clean and flat, and the same panic signature was reproduced on a build without the ngt_shutdown change.

Environment

Same guest/session as DF-3531 (see ../DF-3531/env.txt).

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

2-line module fix (NG_NODE_SET_PRIVATE(node,NULL) + NG_NODE_UNREF(node) at ngt_shutdown tail, mirroring ng_pred1.c:350-351) validated in-guest: netgraph_node in-use flat at 2 across nodd-500 and 2x2000 stall-close cycles (baseline +1/cycle); module no longer pinned by node refs. The later drain-stress panic in the same session is DF-3531's layer-1/2 close-vs-fire race, not this change (nodd mode with no timers ran clean and flat).

fix_validation.log: 'nodd 500' nodes 2->2; stall-close 2000 twice nodes 2->2->2; isolation.log for the pre-fix +1/cycle baseline.
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Mon Sep 14 09:24:11 UTC 2026 + module rebuilt with fix.diff

Confirmed kernel references

Detail

Exploit chain

root/capability holder flips a pty's line discipline to NETGRAPHDISC and back: one ng_node (~176B) leaked per flip, forever; module unload permanently blocked; ng_nodes count only grows, eventually crossing the ng7 hash-rehash threshold (DF-3514 panic on stock module builds).

Evidence (decisive lines)

isolation.log (nodd/nodata/socksock/full matrix); fix_validation.log (nodes flat with the fix); refcount ledger narrative in VERDICT.md (9 refs in, 8 out, orphan = creation ref); fix.diff (2 lines).

PoC changes

n/a β€” harness written from scratch during DF-3531 verification (tty_leak2.c, 4 isolation modes).

Verified recommended fix

Add NG_NODE_SET_PRIVATE(node, NULL); NG_NODE_UNREF(node); at the end of ng7 ngt_shutdown (sys/netgraph7/tty/ng_tty.c:568, after kfree(sc)).

Verdict

REPRODUCED: ng7 ngt_shutdown() never drops the constructor node reference (the ng7 shutdown contract β€” cf. ng_pred1.c:351 β€” requires it; the v1 twin at sys/netgraph/tty/ng_tty.c:554 does ng_unref(sc->node)). Every netgraph-tty ldisc install/remove leaks one ng_node + ID-hash entry permanently, with nothing but TIOCSETD on/off (no hook, no data, no timer). Isolation: nodd +1/cycle, socksock flat, and with the 2-line NG_NODE_UNREF fix nodes are flat across nodd500 + 2x2000 stall cycles. Privileged trigger (SYSCAP_NONET_NETGRAPH); unbounded; pins the ng_tty module; feeds the DF-3514 rehash panic threshold exactly like DF-3513/DF-3515 did.