ng_pptpgre timer-accounting leak: callout_stop() contract violation (DF-3515) makes every cancelled rack/sack timer permanently leak its argument, a node reference, priv->timers, and finally priv and the node itself
Summary
The two stop helpers gate the cleanup - kfree(timerPtr), priv->timers--, ng_unref(node) - on callout_stop() returning non-zero. On this kernel _callout_cancel_or_stop completes armed stops inline and early-exits returning 0 before the PREVENTED-reporting path (the DF-3515 contract violation, here against the v1 ng_pptpgre consumer). Every armed-then-cancelled timer therefore leaks its 8-byte argument allocation, one node reference, and one timers unit, permanently; because timers never drains, rmnode's 'if (timers==0) kfree(priv)' never fires - ~440 B leak per node shutdown that had a timer armed. Reached by NORMAL operation (every ack that stops the rack timer, every piggy-backed ack, every SET_CONFIG reset). REPRODUCED in-guest deterministically: M_NETGRAPH Count 503 -> 29.8K after 30k arm/cancel cycles (+1/cycle) -> 38.6K/1.54MB after 3000 lifecycles; no-armed-timer control flat. Fix VALIDATED on a REBUILT KERNEL (kernel #1, RC=0): identical workload -> Count=0 MemUse=0. Root cause: kern_timeout.c inline-completed-stop early exit; the fix restores the callout_stop(9) contract for every consumer.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3521 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| leak_test.c | trigger-source | deterministic leak driver: arm rack timer via 1 frame, cancel via SET_CONFIG, repeat; mode 1 = full node lifecycle | 9.8 KB | view raw |
| build.log | build-log | in-guest cc build output | 363 B | view raw |
| run.log | run-log | stock kernel: baseline + 30k cycles + 3k lifecycles with vmstat -m before/after | 720 B | view raw |
| fix_run.log | run-log | fixed-kernel rerun (after kern_timeout.c fix + kernel rebuild + reboot) | 632 B | view raw |
| fix.diff | fix | kern_timeout.c: report CALLOUT_PREVENTED for inline-completed sync stops | 1.3 KB | view raw |
| env.txt | env | guest uname, modules, compiler | 575 B | view raw |
| VERDICT.md | verdict | full narrative with citations | 5.5 KB | β raw |
| verdict.json | verdict | machine verdict | 3.5 KB | view raw |
DF-3521 β ng_pptpgre timer-accounting leak via broken callout_stop() contract
Verdict: REPRODUCED (in-guest, deterministic, quantified). Fixed by 1-line
kern_timeout.c fix validated with a rebuilt kernel.
status = reproduced, reproduced = 1, impact = leak, confidence = certain.
What leaks and why
ng_pptpgre pairs every armed timer with exactly one cleanup, gated on
callout_stop() returning non-zero:
/* sys/netgraph/pptpgre/ng_pptpgre.c:786-791 */
if (callout_stop(&a->rackTimer)) {
kfree(a->rackTimerPtr, M_NETGRAPH); /* :787 */
priv->timers--; /* :788 */
ng_unref(node); /* :789 */
}
a->rackTimerPtr = NULL; /* :791 */
(same pattern for the send-ack timer at :889-894).
The pairing is correct only if callout_stop() returns non-zero when it
cancels an armed-but-not-yet-fired callout (the documented contract). On this
kernel it returns 0 for exactly that case, because the stop is completed
inline by _callout_update_spinlocked() (kern_timeout.c:266-277: removes
the callout from the wheel, clears CALLOUT_SET, sets CALLOUT_PREVENTED)
and the early exit at sys/kern/kern_timeout.c:888-891
if (sync == 0 || (c->flags & (CALLOUT_INPROG | CALLOUT_SET)) == 0) {
exis_drop_gd(gd);
return 0; /* <-- SET was just cleared inline */
}
fires before the PREVENTED-reporting path at :895-900. This is the
same contract violation DF-3515 live-instrumented on ng_hci; DF-3515 fixed
the ng7 consumer (ng_uncallout); the v1 ng_pptpgre consumer
(if (callout_stop(...))) has the identical dependency and leaks
deterministically β this was not covered by DF-3515 or DF-0596.
Consequence chain (single CPU, no race needed):
xmitarmsrackTimer(ng_pptpgre.c:762-774:kmalloc+node->refs+++priv->timers+++callout_reset).- Any of the normal stop sites fires while the timer is armed:
* ack received β
ng_pptpgre_stop_recv_ack_timer(:679) β happens on every ack of a live PPTP session; * piggy-backed ack on xmit (:525); *NGM_PPTPGRE_SET_CONFIGβng_pptpgre_reset(:977-978). callout_stop()returns 0 although it cancelled the pending timer β thekfree/timers--/ng_unrefblock is skipped,rackTimerPtris only NULLed β callback will never run β nobody ever does the cleanup.- Per stop: one 8-byte
M_NETGRAPHallocation, one node reference, and onepriv->timersunit leak β permanently. - At shutdown,
ng_pptpgre_rmnode(:437)if (priv->timers == 0)never succeeds β the ~208-byteprivleaks too, and the leaked node reference pins theng_node(and its ID/name) forever. The NG_INVALID-branch free in the callbacks (:813-816,:917-920) can't happen either β the callback was cancelled, not fired.
Trigger (root-local; netgraph control socket is gated by
caps_priv_check(SYSCAP_RESTRICTEDROOT) at sys/netgraph/socket/ng_socket.c:172)
leak_test.c builds: ctlA:p0 <-> pptp:upper, ctlB:p1 <-> pptp:lower,
then per cycle: one 64-byte frame through upper (arms rackTimer with a
~10 s ato), then NGM_PPTPGRE_SET_CONFIG (reset β stop armed timer β
leak). Mode 1 additionally tears the node down each cycle (full lifecycle).
Measured (stock kernel, guest up, INVARIANTS X86_64_GENERIC #0)
baseline (after 500-cycle smoke) : netgraph Count=503 4.50K after 30,000 arm/cancel cycles : netgraph Count=29.8K 239K (long-lived node) after 3,000 full lifecycles : netgraph Count=38.6K 1.54M
- ~1 leaked allocation per cancelled timer (29.3k for 30k cycles) β exactly
the leaked
rackTimerPtr. - ~2.9 allocations / ~440 bytes per node lifecycle β
rackTimerPtr+priv+node(+name) β proving thepriv->timersnever drains andng_pptpgre_rmnode's free is skipped. - Contrast: the DF-3518 hammer ran 52k node lifecycles without armed
timers (
vmstat -mCount unchanged) β the leak appears iff a timer was armed and then stopped. Deterministic, not a race.
Fix
fix.diff β sys/kern/kern_timeout.c: in _callout_cancel_or_stop(),
when the request completed inline (neither INPROG nor SET remains),
return (c->flags & CALLOUT_PREVENTED) != 0 instead of 0. This restores
the callout_stop(9) contract for every consumer (and is the same
direction as DF-3515's root-cause note). Validated: kernel rebuilt in-guest
with the fix, leak_test re-run β see fix_run.log; leaked-count growth
per cycle dropped to 0.
Files
leak_test.cβ PoC (build:cc -O2 -lpthread -o leak_test leak_test.c)build.log,run.logβ full untrimmed build + stock-kernel runfix_run.logβ post-fix run (fixed kernel)fix.diffβgit apply-able against the read-only master treeenv.txtβ guest kernel/modules/compiler
Privilege / exposure
Root (or delegated SYSCAP_RESTRICTEDROOT/netgraph caps) is needed to build
the graph β same prerequisite class as DF-0589/0590/0596. Impact is
unbounded kernel-memory exhaustion under normal PPTP-session operation
(every ack-restart cycle leaks), degrading the whole system β kernleak
bucket.
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
fixedConfirmed kernel references
- sys/netgraph/pptpgre/ng_pptpgre.c:437
- sys/netgraph/pptpgre/ng_pptpgre.c:525
- sys/netgraph/pptpgre/ng_pptpgre.c:679
- sys/netgraph/pptpgre/ng_pptpgre.c:762
- sys/netgraph/pptpgre/ng_pptpgre.c:768
- sys/netgraph/pptpgre/ng_pptpgre.c:786
- sys/netgraph/pptpgre/ng_pptpgre.c:889
- sys/netgraph/pptpgre/ng_pptpgre.c:977
- sys/kern/kern_timeout.c:888
Detail
Evidence (decisive lines)
run.log (vmstat -m netgraph Count 503 -> 29.8K after 30k cycles -> 38.6K/1.54M after 3k lifecycles); fix_run.log (post-fix rerun, Count flat); leak_test.c cycle driver; build.log; VERDICT.md full trace
PoC changes
Seeded draft did not exist for this ID; authored from scratch. Fixed during bring-up: used real struct ngm_mkpeer/ngm_connect/ngm_name from guest headers (hand-rolled connect struct had path[32] instead of path[512] -> EINVAL); node-name addressing requires trailing colon ('pptp:', 'p0' hook-walk); node NAME itself must not contain a colon.
Verified recommended fix
kern_timeout.c: in _callout_cancel_or_stop(), return ((c->flags & CALLOUT_PREVENTED) != 0) for sync requests completed inline, instead of unconditional 0 (restores the callout_stop(9) contract for every consumer).
Verdict
REPRODUCED deterministically in-guest: every cancelled (armed-then-stopped) rack/sack timer in ng_pptpgre permanently leaks its 8-byte timerPtr allocation, one node reference, and one priv->timers unit, because ng_pptpgre_stop_*_ack_timer() gates cleanup on callout_stop() returning non-zero and this kernel's _callout_cancel_or_stop() returns 0 for inline-completed stops (kern_timeout.c:888-891, the DF-3515 contract violation) even though it cancelled the pending callout. Measured: +29.3k leaked allocations for 30k arm/cancel cycles; ~440 B per node lifecycle (priv+node+ptr) because priv->timers never drains so ng_pptpgre_rmnode never frees priv. Unbounded kernel memory leak under normal PPTP session operation (every ack, every SET_CONFIG). Fix (report CALLOUT_PREVENTED in the kern_timeout early exit) validated with a rebuilt kernel: leak per cycle drops to 0.
No comments yet.