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

sysctl_intrnames traverses every CPU's interrupt-handler lists unsynchronized β†’ use-after-free read / kernel heap disclosure via hw.intrnames

Field Value
ID DF-2775
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:H
CWE CWE-362 / CWE-416
File sys/kern/kern_intr.c
Lines 1100-1116 (free side :448/:485-487)
Area kern
Confidence likely
Discovered 2026-08-31
Pass 2 (GLM 5.3 second pass)
Bucket kernleak
Reported pending
Known CVE none
CVE match novel

Summary

The hw.intrnames sysctl handler walks the per-CPU i_reclist chains of ALL cpus from the reader's own CPU with no critical section, no migration, and no lock. unregister_int unlinks a rec under a critical section on the target CPU only, then frees rec/rec->name with kfree after crit_exit. A reader that has already loaded a rec pointer but is timesliced or preempted (an ordinary user syscall thread) will, on resume, dereference freed memory: rec->next and rec->name passed as %s into ksnprintf. If the freed rec chunk is reclaimed by attacker-influenced kernel allocations, rec->name becomes an attacker-influenced pointer and ksnprintf emits a NUL-terminated string read from an arbitrary kernel address to the unprivileged reader β€” a kernel memory disclosure; otherwise the walk corrupts on garbage and panics.

Threat model & preconditions

Any local user can read hw.intrnames (CTLFLAG_RD, no privilege; systat -i uses it). The freeing side needs a concurrent driver teardown: kldunload/drvctl detach (root), USB/PCIe hot-unplug (physical attacker or VM host action), or the shutdown/reboot detach storm racing a reader β€” repeatable arbitrary-address string leak for a physical attacker.

Migrate to each CPU and take a critical section around the list traversal (matching the register/unregister protocol), or maintain a stable snapshot under a spin/token β€” minimal patch in the row above.

Timeline

  • 2026-08-31 Discovered during pass-2 audit of kern_intr.c (GLM 5.3); free-side needs root/hardware trigger on the stock guest, honest not-live-verified.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2775 Β· 23 files
FileTypeDescriptionSize
README.md β€” 3.2 KB ↓ raw
VERDICT.md β€” 5.6 KB ↓ raw
run.log β€” 3.7 KB view raw
panic.txt β€” 562 B view raw
env.txt β€” 263 B view raw
fix.diff β€” 5.3 KB view raw
build.log.gz β€” 191.3 KB ↓ download
src/df_harness.c β€” 22.3 KB view raw
src/df2775_reader.c β€” 3.8 KB view raw
src/pin_drop.c β€” 1.1 KB view raw
src/chk.c β€” 628 B view raw
logs/boot.after4thpanic.log β€” 13.0 KB view raw
logs/hits.log β€” 23 B view raw
logs/hits_fix.log β€” 2.2 KB view raw
logs/hits_fix0.log β€” 150 B view raw
logs/reader_p2.log β€” 49 B view raw
logs/reader_p3.log β€” 22 B view raw
logs/reader_p5.log β€” 55 B view raw
logs/reader_fix.log β€” 2.2 KB view raw
logs/reader_fix0.log β€” 182 B view raw
logs/dmesg.fixedkernel.log β€” 14.9 KB view raw
logs/df2776_wild_panic.txt β€” 466 B view raw
verdict.json β€” 5.1 KB view raw

DF-2775 evidence pack β€” sysctl_intrnames unsynchronized walk (UAF read / heap disclosure / panic)

Finding: sys/kern/kern_intr.c:1100-1116 β€” sysctl_intrnames traverses every CPU's i_reclist chains with no synchronization (no crit section, no token, reader runs on any CPU) while unregister_int unlinks (under a critical section on the owning CPU only, kern_intr.c:438-448) and then frees rec->name + rec (kern_intr.c:486-487). A reader that has loaded a rec pointer can dereference rec->name after the free.

hw.intrnames is world-readable (CTLFLAG_RD), so the reader side is any unprivileged user (systat/vmstat use it).

What is in this pack

  • src/df_harness.c β€” kernel harness compiled into the test kernel:
  • exact copy of the stock walk with a stall point between rec load and rec->name use (deterministic primitive proof),
  • churn thread on cpu1 driving register/unregister + an interposed unregister (unlink -> kfree -> immediate same-cpu kmalloc reclaim of the freed intrec, filled with a chosen rec->name: harness token / kernel version string / arbitrary address),
  • fixed-walk variant (migrate+crit snapshot) for the A/B illustration.
  • src/df2775_reader.c β€” UNPRIVILEGED userland reader hammering sysctl hw.intrnames, scanning every entry for anomaly markers; writes O_SYNC logs so evidence survives crashes.
  • src/pin_drop.c β€” root helper: pin to a cpu, drop to uid 1001, exec the reader (USCHED_SET_CPU needs privilege; the mask survives the uid drop).
  • src/chk.c β€” output-correctness checker for the fixed sysctl.
  • build.log.gz β€” full untrimmed kernel build log (stock K1c build).
  • run.log β€” the decisive outputs (rigged token/version capture, organic panic signature x4, fix A/B).
  • panic.txt, logs/boot.after4thpanic.log β€” organic panic from the unprivileged reader (Stopped at strlen on the reader's cpu).
  • logs/hits*.log, logs/reader_*.log β€” reader runs (stock + fixed).
  • env.txt β€” guest environment.
  • fix.diff β€” canonical fix (pristine -> fixed), applies to stock sys/kern/kern_intr.c (covers DF-2775/2776/2777).

Build (on the guest)

scp src/df_harness.c root@guest:/usr/src/sys/kern/
echo 'kern/df_harness.c    standard' >> /usr/src/sys/conf/files
cd /usr/src && make -j6 nativekernel KERNCONF=X86_64_GENERIC
install kernel.stripped as /boot/kernel/kernel, reboot
cc -O2 -o df2775_reader.bin src/df2775_reader.c
cc -O2 -o pin_drop src/pin_drop.c

Run

# deterministic primitive (groomed token, then kernel version string):
sysctl dfh.mode2775=2 dfh.target2775=0 dfh.run2775=2
sysctl dfh.target2775=1 dfh.run2775=2
# organic race: unpriv reader on cpu0, churn on cpu1 (EXPECT PANIC):
nohup /root/pin_drop 0 1001 ./df2775_reader.bin 90 0 /home/maxx/logs/hits.log &
sysctl dfh.mode2775=2 dfh.persist2775=1 dfh.secs2775=85 dfh.run2775=1

Expected

  • Stock: rig prints .../dfh7/DF2775-SECRET-LEAK-TOKE and .../dfh7/DragonFly 6.5-DEVELOPME (freed+reclaimed rec->name read); organic runs panic in strlen on the reader's cpu (4/4 runs).
  • Fixed (fix.diff applied): same organic PoC -> 0 anomalies, no panic, identical sysctl output (1536 entries, correct slot contents).
VERDICT.md
↓ download raw

DF-2775 VERDICT β€” reproduced (leak primitive + panic; fix validated)

Bottom line

The unsynchronized sysctl_intrnames walk is a real use-after-free read reachable by an unprivileged reader of hw.intrnames whenever any kernel-side register/unregister activity runs concurrently on another CPU. On the stock INVARIANTS guest this reproduced two ways:

  1. Content-controlled arbitrary-address string read (deterministic). A harness copy of the exact stock walk (kern_intr.c:1106-1110), stalled between loading a rec pointer and dereferencing rec->name (a stand-in for any preemption/interrupt the real reader suffers there), resumed after the rec was unlinked and freed and immediately reclaimed by a same-size M_DEVBUF kmalloc filled with a chosen rec->name: - pointing name at the harness token printed dfh0/.../dfh7/DF2775-SECRET-LEAK-TOKE into the reader's entry; - pointing name at the kernel version string (0xffffffff811473a0, no KASLR on this guest) printed dfh0/.../dfh7/DragonFly 6.5-DEVELOPME. Reclaim rate: 100% (kfree -> kmalloc LIFO on the same cpu, 3/3 rig runs, reclaimed=1/2/3, miss=0).

  2. Organic kernel panics from the unprivileged reader (4/4 runs). With the real sysctl hw.intrnames hammered by an unprivileged reader (uid 1001, pinned cpu0 via a root launcher because USCHED_SET_CPU needs privilege) and the churn thread on cpu1 performing the interposed unregister (~200k unlinks/s), the kernel panicked every time within seconds with the identical signature Fatal trap 9 ... Stopped at strlen: cmpb $0,(%rdi) on cpuid 0 (the reader's cpu) β€” strlen is called from ksnprintf("%s", rec->name) (kern_intr.c:1107-1108) on a rec freed by the concurrent unregister. See panic.txt, logs/boot.after4thpanic.log, logs/hits.log.

Honest quantification

  • Deterministic rig: 3/3 successful token/version captures.
  • Organic panic: 4/4 runs (each within ~1-60 s at ~200k deeds/s and ~1000-1900 reads/s).
  • Organic token-in-reader-output: 0 observations. Mechanism: in the stock loop the rec-pointer load (rec = rec->next) and the rec->name dereference (argument evaluation of ksnprintf) are a few instructions apart (~5 ns). A content-controlled read needs the reader to be preempted exactly inside that window; without preemption the organic outcomes are benign (rec still valid) or crash (rec freed and its memory already sprayed/slab-flushed), which is exactly what the four panics show. The rig widens that window to prove the primitive.
  • Baseline with no churn: 0 anomalies across all reader runs (logs/hits.log t=1s reads=1927 hits=0 line, pre-churn).

Privilege assessment (team directive)

  • Reader side: genuinely unprivileged (hw.intrnames is 0444; systat/vmstat use it).
  • Churn side: register_int/unregister_int are kernel-internal APIs; an unprivileged user cannot drive them directly. Real-world churn comes from root-driven driver activity (interface create/destroy, USB hotplug, kldload/kldunload, VPN/pps teardown).
  • Ceiling: kernel heap disclosure (bounded by NUL termination of whatever the reclaimed rec->name points at) plus kernel panic (local DoS). No write primitive, no unpriv-alone trigger -> not a privesc. Severity Medium as filed is right. On this KASLR-less guest any disclosed kernel pointer would also defeat symbol randomization, but that does not change the privilege boundary.

Fix validation (mandatory: memory-safety finding reproduced)

fix.diff (pack root; identical copy in DF-2776/DF-2777 packs) rewrites sysctl_intrnames to snapshot the rendered names per-CPU under the same migrate+crit protocol unregister_int uses for the unlink, and performs SYSCTL_OUT only after the snapshot. Applied to a pristine tree, kernel rebuilt and booted (build.fixed.log.gz in DF-2777 pack):

  • identical organic PoC (reader 90 s cpu0-pinned uid 1001; churn 85 s; 17,408,000 deeds at the same ~205k/s rate, 100% reclaim): SUMMARY reads=113091 hits=0, no panic (stock: panic in seconds).
  • fully-real mode-0 churn (real register_int/unregister_int storm, 9.2M iterations) + reader 45 s: reads=5829 hits=0, no panic.
  • output correctness preserved (src/chk.c): rc=0 bytes=10218 entries=1536 e508=[dfp0/dfp1/dfp2/dfp3].
  • the fixed protocol also defeats the deterministic stall interposition (DF2775-RIG-FIXEDWALK ... token-in-output=0).

Kernel references

Rig honesty notes

  • The harness kernel adds kern/df_harness.c plus (for DF-2777) two dormant hook calls in kern_intr.c; the DF-2775/DF-2776 rigs on kernel K1 used a 100% stock kern_intr.c (verified by diff before build).
  • The interposed unregister performs only unlink+kfree and writes the groom exclusively into memory it owns (the successful LIFO reclaim); no wild post-free stores.
  • The organic panics on stock occurred with the fully-real register_int/unregister_int pair (first panic run) and with the interposed single-target deed (persist mode); all four signatures identical.

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

not_testable
↓ fix.diffper-fix-DF-2775

Confirmed kernel references

Detail

Evidence (decisive lines)

['run.log section 1-2: DF2775-RIG walker entry dfh0/..../dfh7/DF2775-SECRET-LEAK-TOKE and dfh7/DragonFly 6.5-DEVELOPME (chosen-address reads via freed rec->name)', 'panic.txt and logs/boot.after4thpanic.log: Fatal trap 9 stopped at strlen cmpb 0,(rdi) on cpuid 0 from the unprivileged reader, 4 of 4 organic runs', 'logs/hits.log: t=1s reads=1927 hits=0 pre-churn baseline; reader rate evidence', 'run.log section 7-9: fixed kernel A/B SUMMARY reads=113091 hits=0 secs=90 at 17,408,000 deeds (100 percent reclaim), mode-0 run reads=5829 hits=0, and chk.c output rc=0 bytes=10218 entries=1536 e508=[dfp0/dfp1/dfp2/dfp3]', 'fix.diff: sysctl_intrnames migrate+crit snapshot rewrite']

PoC changes

Replaced the stale seeded pack (it contained an unrelated refuted kern_time.c nanosleep candidate) with a purpose-built in-kernel harness: df_harness.c (exact stock-walk copy with a load-to-use stall point, cpu1 churn thread with interposed unregister doing kfree plus same-cpu LIFO reclaim groom, fixed-walk snapshot variant), an unprivileged O_SYNC-logging reader (df2775_reader.c) and a pin-then-setuid launcher (pin_drop.c) because USCHED_SET_CPU needs privilege. Fixed include paths (machine/atomic.h), volatile sysctl node casts, buffer bounds in the walk copy, a persist-mode chain-growth bug, and switched reader logging to O_SYNC so hits survive panic reboots.

Verified recommended fix

Snapshot hw.intrnames per CPU under int_moveto_destcpu plus crit_enter (the protocol unregister_int uses for the unlink), copy the rendered strings into a kmalloc buffer, and SYSCTL_OUT once afterwards (see fix.diff hunk sysctl_intrnames).

Verdict

Reproduced on the stock INVARIANTS guest two ways. (1) Deterministic primitive: an exact copy of the sysctl_intrnames walk (kern_intr.c:1106-1110) stalled between loading a rec pointer and using rec->name, then resumed after the rec was unlinked, freed and immediately reclaimed (kfree->kmalloc LIFO, 100 percent over 3 runs) with a chosen rec->name, printed the chosen string into the walk output: harness token DF2775-SECRET-LEAK-TOKE and the kernel version string from 0xffffffff811473a0 (arbitrary-address NUL-terminated kernel read, no KASLR). (2) Organic: an unprivileged uid 1001 reader hammering the real sysctl hw.intrnames (pinned cpu0) while kernel-side churn on cpu1 performed the interposed unregister at ~200k unlinks/s panicked the kernel in 4 of 4 runs within seconds with the identical signature Fatal trap 9 / trap 12 stopped at strlen cmpb 0,(rdi) on the reader cpu, i.e. ksnprintf percent s of rec->name (kern_intr.c:1107) on a rec freed by the concurrent unregister (kern_intr.c:486-487). Organic token capture in reader output: 0 hits, mechanically explained: the stock loop separates rec load from rec->name dereference by ~5ns so a content-controlled read needs the reader preempted exactly inside that window (the rig proves that case); organic outcomes are benign or crash, matching the 4 panics. Privilege ceiling: unpriv-readable heap disclosure plus local DoS panic; the churn side needs kernel-context (root-driven driver) activity, an unprivileged user cannot trigger alone, and there is no write primitive, so this is an info leak not a privesc. fix.diff (per-cpu migrate+crit snapshot, then a single SYSCTL_OUT) validated on a rebuilt kernel: same organic PoC gave reads=113091 hits=0 with 17.4M deeds and no panic (plus a fully-real mode-0 run reads=5829 hits=0 over 9.2M register/unregister iterations) and byte-identical sysctl output (1536 entries, correct slot contents).