Dead code: oid_running is never incremented, so the dynamic-oid teardown drain and CTLFLAG_DYING machinery in sysctl_remove_oid_locked are inoperative
| Field | Value |
|---|---|
| ID | DF-2737 |
| Status | new |
| Severity | Info |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:N |
| CWE | CWE-1164 / CWE-667 (defense-in-depth) |
| File | sys/kern/kern_sysctl.c |
| Lines | 384-405 (counter declared sysctl.h:168) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-30 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | base:kern |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
The module-unload safety net ("Wait for all threads running the handler to drain ... necessary for module unload correctness") never executes: oid_running is only declared and read/tslept-on β nothing increments or decrements it, and dynamic oids are M_ZERO-allocated so it stays 0 forever. CTLFLAG_DYING is consequently never set and the KASSERTs can never fire. Actual handler-vs-teardown safety rests entirely on the pcpu SLOCK-across-handler protocol, which the race hammer confirmed holds (12 kld load/unload cycles vs 4245 unpriv sysctl -aN walks, no panic). Risk is latent: any refactor that drops SLOCK-across-handler while trusting the comment silently turns module unload into a UAF of struct sysctl_oid and handler text.
Recommended fix
Wire the counter up (atomic add around handler dispatch + wakeup on 1β0) with the mandatory DYINGβENOENT companion hunk in sysctl_find_oid (without it, wiring the counter opens an INVARIANTS panic race through the remover's tsleep window). Full git-apply-clean diff in findings/poc/DF-2737/fix.diff.
Timeline
- 2026-08-30 Discovered during pass-2 audit of kern_sysctl.c (GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2737 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| prober.c | β | 4.9 KB | view raw | |
| run_user_prober.log | β | 1.7 KB | view raw | |
| race_hammer.txt | β | 1.7 KB | view raw | |
| env.txt | β | 198 B | view raw | |
| fix.diff | β | 1.3 KB | view raw | |
| VERDICT.md | β | 6.2 KB | β raw | |
| manifest.json | β | 972 B | view raw | |
| verdict.json | β | 3.8 KB | view raw |
DF-2737 verdict β dead oid_running drain (Info / defense-in-depth)
Classification
- Finding: Info, confidence certain (static proof), no demonstrated impact today β hence no standalone exploit PoC (Phase V not warranted for an Info finding; the prober/race-hammer runs here are the negative-result record for the whole file audit).
- PoC machine status:
untested(nothing to reproduce; this is dead-code hardening, not a triggerable bug).
Why certain (static)
rg -n oid_running sys/ over the entire kernel tree returns exactly:
sys/sys/sysctl.h:168β struct field declarationsys/kern/kern_sysctl.c:391βwhile (oidp->oid_running > 0)sys/kern/kern_sysctl.c:393/395β tsleep on&oidp->oid_running
No increment, no decrement, no wakeup counterpart anywhere. Dynamic oids
are allocated with M_ZERO (kern_sysctl.c:463-464), so oid_running
stays 0 for the oid's entire life; the drain loop body never executes;
CTLFLAG_DYING (kern_sysctl.c:392) is never set; the
KASSERT((oid->oid_kind & CTLFLAG_DYING) == 0) in sysctl_find_oid
(kern_sysctl.c:1386-1387, 1395-1396) can therefore never fire.
Why it is (currently) harmless
Reader/dispatcher sites hold the per-cpu shared topology lock across the
whole dispatch including the handler:
- userland_sysctl kern_sysctl.c:1569-1578 (SYSCTL_SLOCK() β¦
sysctl_root() β¦ SYSCTL_SUNLOCK())
- kernel_sysctl kern_sysctl.c:1274-1276 (same pattern)
Teardown sites take the exclusive topology lock over ALL cpus
(_sysctl_xlock, kern_sysctl.c:1641-1651) before unlink/free
(sysctl_remove_oid_locked kern_sysctl.c:336-408, sysctl_ctx_free
kern_sysctl.c:213-261, sysctl_unregister_oid β¦). lockinit(β¦,
LK_CANRECURSE) (sys/kern/lwkt_thread.c:340) makes the recursive
XLOCK inside those paths (e.g. kern_sysctl.c:383 β 173) legal.
Mutual exclusion between in-flight handlers and oid free is therefore
complete as long as every future dispatch keeps the SLOCK-across-handler
property β exactly the invariant the dead drain's comment claims to
guarantee independently. The danger is latent: a refactor that drops the
SLOCK across handlers (e.g. for latency), trusting the "drain", silently
converts module unload into a use-after-free of struct sysctl_oid and
the handler text.
Runtime corroboration on the INVARIANTS guest (see race_hammer.txt):
12 kldload/kldunload coretemp cycles (coretemp allocates/frees dynamic
oids via sysctl ctx) while an unprivileged user ran sysctl -aN full-tree
walks (4245 completed): no panic, guest up, vmstat -m sysctloid arena
fully drained afterwards.
Fix
fix.diff (git-apply-able, git apply --check clean) does two things:
sysctl_root:atomic_add_int(&oid->oid_running, 1)around the handler invocation,wakeup(&oid->oid_running)on the 1β0 transition β makes the existing drain at kern_sysctl.c:391-398 actually function.sysctl_find_oid: returnENOENTforCTLFLAG_DYINGnodes instead of falling through β mandatory companion, because once the drain can run, it drops the XLOCK while sleeping, and a fresh reader could otherwise walk into a DYING node and hit the (now reachable) INVARIANTS KASSERT at kern_sysctl.c:1386 β a panic an unprivileged user could race for.
fix_status: not_testable β Info-severity finding; a full kernel rebuild
cycle for a defense-in-depth change with no runtime trigger was not
warranted under the single-tenant guest.
Negative results established by the same run (for the file record)
sysctl_old_user(kern_sysctl.c:1332-1345)oldlen - oldidxsize_t underflow after a truncated (ENOMEM) SYSCTL_OUT: an ignoring handler would copy out atoldptr + oldidxpast the user's declared buffer β butoldptris the attacker's own pointer in its own address space, so this is at worst a self-overwrite, not a kernel violation. In-tree handlers were scanned; every multi-OUT handler checks the first error (devstat, collect, cputimer, tcp_subr, in_pcb, rman, vm_machdep, acpi, clockmod, kern_proc). Prober truncation matrix (run_user_prober.log) confirms byte-exact clamping.- Exact-path
{0,4}/{0,5}oidfmt/oiddescr queries makesysctl_find_oidread uninitialized in-bounds entries of the caller'sint name[CTL_MAXNAME]stack array (kern_sysctl.c:1372-1374 with arg2==0 via the node dispatch at 1477): no kernel data is disclosed (the stale ints are only used as a lookup key; prior-syscall stack spray leaves attacker-chosen values anyway). 64 sprayed iterations on the INVARIANTS kernel: no panic. sysctl_sysctl_next_lswrites intoint newoid[CTL_MAXNAME](kern_sysctl.c:786) at recursionlevelβ depth-bounded by tree depth; fullsysctl -aNwalks over the stock tree (4245 iterations) never exceeded it. Overflow needs a >12-deep handler-less dynamic chain, creatable only by privileged module code.- Write gating re-verified at runtime: unprivileged writes to
debug.sysctlandkern.hostnameβ EPERM (kern_sysctl.c:1438-1450). TheCTLFLAG_ANYBODYwrite-bypass population (name2oid query-only; kern.proc.args/cwd handler-gated in kern_proc.c:1897-1903; psm knobs hardware-local) contains no unguarded kernel-state writer. - Reader-gating model: the dispatcher gates writes only (SYSCAP_NOSYSCTL_WR, kern_sysctl.c:1446-1449); reads are unconfined with zero jail/PRISON visibility filtering β sensitivity is a per-handler responsibility (per audit instructions this known model is not re-reported; DF-2684 & friends are the handler-side instances).
- Mid-handler CPU migration vs
mycpu-basedSYSCTL_SLOCK/SUNLOCK(release-on-wrong-cpu β shared-count theft, kern_lock.c:886+): not demonstrable β LWKT threads are home-cpu scheduled while blocked in-kernel; dfly usched migration happens at the user boundary. Left as a design observation, no trigger found. sysctl_ctx_freedouble-entry double-free (same oid appearing twice in one ctx β secondsysctl_remove_oid_lockedon freed memory, kern_sysctl.c:249-258): requires a module adding the same node name twice to one ctx (sysctl_add_oid:448-456 happily ctx-adds an existing node). No in-tree instance; module-bug-dependent β recorded, not filed.
Fix verification
not_testableInfo/defense-in-depth finding with no runtime trigger, so no baseline bad behavior exists to observe disappearing; a kernel rebuild cycle was not warranted. fix.diff is git-apply-able (git apply --check clean) and was code-reviewed against sysctl_root/sysctl_find_oid, including the mandatory DYING->ENOENT companion hunk without which wiring the counter would make the KASSERT at kern_sysctl.c:1386 raceable to panic.
['findings/poc/DF-2737/fix.diff (git apply --check: APPLIES-CLEAN)']
Confirmed kernel references
Detail
Evidence (decisive lines)
['rg -n oid_running sys/ -> only sys/sys/sysctl.h:168 and sys/kern/kern_sysctl.c:391,393,395: no increment/decrement anywhere', 'findings/poc/DF-2737/race_hammer.txt: 12 coretemp load/unload cycles vs 4245 unpriv sysctl -aN walks, guest up, vmstat -m sysctloid drained', 'findings/poc/DF-2737/run_user_prober.log: adversarial oldlenp/name2oid/oidfmt/truncation matrix, no panic on INVARIANTS kernel', 'sys/kern/lwkt_thread.c:340: lockinit(&gd->gd_sysctllock, ..., LK_CANRECURSE) legitimizes the recursive XLOCK at kern_sysctl.c:383/241']
PoC changes
n/a (prober authored fresh for this pass-2 run; no seed PoC existed)
Verified recommended fix
Wire up oid_running in sysctl_root (atomic inc/dec + wakeup around handler invocation) and make sysctl_find_oid return ENOENT for CTLFLAG_DYING nodes instead of KASSERTing - see fix.diff
Verdict
Info defense-in-depth finding, proven statically: the dynamic-oid teardown drain at sys/kern/kern_sysctl.c:391-398 is dead code because nothing in the kernel ever increments struct sysctl_oid.oid_running (grep over the whole tree returns only the declaration in sys/sys/sysctl.h:168 and the read/tsleep at kern_sysctl.c:391-395). CTLFLAG_DYING is consequently never set and the KASSERT at kern_sysctl.c:1386/1395 can never fire. Actual handler-vs-teardown safety rests entirely on the pcpu SLOCK-across-handler protocol (userland_sysctl:1569-1578, kernel_sysctl:1274-1276 vs _sysctl_xlock over all cpus:1641-1651), which the race hammer (12 kldload/kldunload coretemp cycles vs 4245 unprivileged sysctl -aN walks) confirmed holds on the INVARIANTS guest with no panic and a fully drained sysctloid arena. No runtime trigger exists, so no standalone exploit PoC was warranted for this Info finding; the pack records the prober and race-hammer evidence for the whole-file negative result. fix.diff (git apply --check clean) wires the counter up in sysctl_root AND makes sysctl_find_oid return ENOENT for DYING nodes so the drain cannot itself introduce the INVARIANTS panic race its comment's mechanism would otherwise enable.
No comments yet.