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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3521 Β· 8 files
FileTypeDescriptionSize
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
VERDICT.md verdict full narrative with citations
↓ download 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):

  1. xmit arms rackTimer (ng_pptpgre.c:762-774: kmalloc + node->refs++ + priv->timers++ + callout_reset).
  2. 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).
  3. callout_stop() returns 0 although it cancelled the pending timer β†’ the kfree/timers--/ng_unref block is skipped, rackTimerPtr is only NULLed β†’ callback will never run β†’ nobody ever does the cleanup.
  4. Per stop: one 8-byte M_NETGRAPH allocation, one node reference, and one priv->timers unit leak β€” permanently.
  5. At shutdown, ng_pptpgre_rmnode (:437) if (priv->timers == 0) never succeeds β†’ the ~208-byte priv leaks too, and the leaked node reference pins the ng_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 the priv->timers never drains and ng_pptpgre_rmnode's free is skipped.
  • Contrast: the DF-3518 hammer ran 52k node lifecycles without armed timers (vmstat -m Count 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 run
  • fix_run.log β€” post-fix run (fixed kernel)
  • fix.diff β€” git apply-able against the read-only master tree
  • env.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

fixed
↓ fix.diffper-fix-DF-3521

Confirmed kernel references

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.