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

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2737 Β· 8 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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:

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:

  1. 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.
  2. sysctl_find_oid: return ENOENT for CTLFLAG_DYING nodes 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)

  1. sysctl_old_user (kern_sysctl.c:1332-1345) oldlen - oldidx size_t underflow after a truncated (ENOMEM) SYSCTL_OUT: an ignoring handler would copy out at oldptr + oldidx past the user's declared buffer β€” but oldptr is 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.
  2. Exact-path {0,4}/{0,5} oidfmt/oiddescr queries make sysctl_find_oid read uninitialized in-bounds entries of the caller's int 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.
  3. sysctl_sysctl_next_ls writes into int newoid[CTL_MAXNAME] (kern_sysctl.c:786) at recursion level β€” depth-bounded by tree depth; full sysctl -aN walks over the stock tree (4245 iterations) never exceeded it. Overflow needs a >12-deep handler-less dynamic chain, creatable only by privileged module code.
  4. Write gating re-verified at runtime: unprivileged writes to debug.sysctl and kern.hostname β†’ EPERM (kern_sysctl.c:1438-1450). The CTLFLAG_ANYBODY write-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.
  5. 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).
  6. Mid-handler CPU migration vs mycpu-based SYSCTL_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.
  7. sysctl_ctx_free double-entry double-free (same oid appearing twice in one ctx β†’ second sysctl_remove_oid_locked on 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_testable
baseline no→ patch + rebuild →patched clean

Info/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)']
↓ fix.diffper-fix-DF-2737

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.