Unserialized global dumper: set_dumper() EBUSY-check TOCTOU / torn struct copy, and clear-or-swap landing during an active dumpsys() β NULL di->priv dereferenced by dev_ddump() (kernel page fault while dumping) and torn-geometry dump misdirection to the wrong device
| Field | Value |
|---|---|
| ID | DF-2815 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H |
| CWE | CWE-362 (CWE-367 / CWE-667 secondary) |
| File | sys/kern/kern_shutdown.c |
| Lines | 137 (global), 948-961 (writers), 980-983 (handoff) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-31 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
The crash-dump registration is a global struct dumperinfo dumper with
zero serialization. set_dumper() does an unlocked check-then-write and
set_dumper(NULL) bzeros the 40-byte struct in place. dumpsys() checks
dumper.dumper != NULL and then passes &dumper β the global itself, not a
snapshot β to md_dumpsys(), whose dump loops re-read di->priv/
maxiosize/blocksize/mediasize for every block write. The writers are
reached via disk_dumpconf() from the DIOCGKERNELDUMP ioctl (no lock; per
DF-2743 no privilege check) and from the root-gated kern.dumpdev sysctl
(which the ioctl takes no part in). Race (a): two registrations interleave
their 40-byte copies β mixing priv (device A) with mediaoffset/mediasize
(device B): a later dump computes dumplo from B's geometry but writes
through A's device (silent destructive disk corruption + disclosure).
Race (b): a clear landing after dumpsys()'s check NULLs di->priv
mid-dump β dev_ddump(NULL,...) faults. No function-pointer forgery
(dumper fn can only be diskdump or NULL) β no uid0 route; ceiling is
panic DoS (proven) plus destructive dump misdirection (code-proven, not
executed to avoid destroying the test disk).
Threat model & preconditions
Attacker with an open fd on a disk device (root; group operator on
default 0640 nodes; fully unprivileged when chained with DF-2743 in
configurations where disk nodes are openable β jails/vkernel hosts with
delegated devices) races DIOCGKERNELDUMP against another registration or
a dump in progress (root reboot -d / panic-path dumps are
stop_cpus-protected, reboot-path dumps are not).
Proof of contest
VERIFIED on the guest (findings/poc/DF-2815/): 4-thread DIOCGKERNELDUMP
churn (clear every 64th) + reboot(RB_DUMP|RB_NOSYNC) from a separate
process β serial console shows Dumping 666 MB: immediately followed
by Fatal trap 12 ... fault virtual address = 0xa8 ... dev_ddump+0xc <-
blk_write β the NULLed di->priv dereference; baseline without churn:
Dump complete. Fix (dumper_lock spinlock + stack snapshot in
dumpsys) validated on a rebuilt kernel: identical churn yields Dump
complete with the churner provably live.
Recommended fix
Validated fix.diff in findings/poc/DF-2815/.
References
- DF-2743 (the missing ioctl privilege check feeding the same sink), DF-2744 (dumpcheck underflow)
Timeline
- 2026-08-31 Discovered during pass-2 audit of kern_shutdown.c (GLM 5.3); mid-dump panic reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2815 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.5 KB | β raw | |
| VERDICT.md | β | 5.4 KB | β raw | |
| churn.c | β | 3.2 KB | view raw | |
| rebootdirect.c | β | 610 B | view raw | |
| build.sh | β | 327 B | view raw | |
| run.sh | β | 1.6 KB | view raw | |
| build.log | β | 146 B | view raw | |
| run.baseline.log | β | 573 B | view raw | |
| run.patched.log | β | 798 B | view raw | |
| serial.race9.log | β | 2.4 KB | view raw | |
| panic.txt | β | 1.6 KB | view raw | |
| env.txt | β | 785 B | view raw | |
| fix.diff | β | 2.7 KB | view raw | |
| verdict.json | β | 6.8 KB | view raw |
DF-2815 β kern_shutdown.c: unsynchronized dumper global (set/clear TOCTOU, torn copy, clear/swap during active dump)
What
sys/kern/kern_shutdown.c keeps the registered crash-dump device in a
global struct dumperinfo dumper (kern_shutdown.c:137) with zero
serialization:
set_dumper()(kern_shutdown.c:948-961) does an unlocked check-then-write (if (dumper.dumper != NULL) return EBUSY; dumper = *di;) andset_dumper(NULL)bzero()s the 40-byte struct in place.dumpsys()(kern_shutdown.c:980-983) checksdumper.dumper != NULLand then passes&dumper(the global itself, not a snapshot) tomd_dumpsys(), whose dump loops re-readdi->priv,di->maxiosize,di->blocksize,di->mediasizeon every block write (sys/platform/pc64/x86_64/minidump_machdep.c:95,143,348,427 and dump_machdep.c:124,144,168,188).
The only writers are reached via disk_dumpconf() (sys/kern/subr_disk.c:915):
the DIOCGKERNELDUMP ioctl (subr_disk.c:1186-1188 β no privilege check and
no lock; see DF-2743), and sysctl kern.dumpdev
(kern_shutdown.c:715-726 β root-gated by SYSCAP_NOSYSCTL_WR,
kern_sysctl.c:1446, and serialized only against other sysctl writes via the
oid lock, which the ioctl path does not take).
Impact
- Clear during dump (demonstrated): a
DIOCGKERNELDUMPclear landing afterdumpsys()passed its check leavesdi->priv == NULLfor the nextdev_ddump()βdev_needmplock(NULL)readsNULL->si_ops(sys/kern/kern_device.c:119-122) β kernel page fault while dumping (panic in the reboot/panic path instead of a clean dump/reboot). - Swap during dump: a concurrent set for a different device swaps
privmid-dump β the remainder of kernel memory is written to a different physical device at the current dump offset. - Torn registration (set vs set): two concurrent sets both pass the
EBUSY check and interleave their 40-byte struct copies β mixed
priv/mediaoffset/mediasize/blocksizefrom two devices β a later dump computesdumplofrom one device's geometry but writes through another device'sprivβ silent destructive disk writes at the wrong offset on the wrong device (disk corruption; if the dump target is readable by a less-privileged user, kernel-memory disclosure).
Exploit ceiling (assessed, not demonstrated): the dumper function-pointer
field can only ever be diskdump or NULL (only disk_dumpconf() builds
registrations), and priv is only a referenced cdev β no attacker-chosen
function pointer or forged object is possible, so the primitive tops out at
kernel-panic DoS (proven) plus destructive wrong-device dump writes
(code-proven). Not an LPE on its own.
Privilege needed: an open fd on a disk device (root, or group operator
with default 0640 nodes; unprivileged only in combination with DF-2743's
missing ioctl privilege check), plus a dump in progress (root reboot -d,
shutdown -d, or any kernel panic while churn runs).
Reproduce
Guest: DragonFly 6.5-DEVELOPMENT x86_64, stock X86_64_GENERIC (INVARIANTS).
sh build.shβ compileschurnandrebootdirectin the guest.- Baseline:
/root/rebootdirectalone β serial console showsDumping ... Dump completethen reboot. - Race:
nohup /root/churn /dev/vbd0s1b 900 3 4 & /root/rebootdirect(mode 3 = mostly-set with a clear every 64th ioctl; see run.sh). Expected on the vulnerable kernel: mid-dumpFatal trap 12: page fault ... fault virtual address = 0xa8 ... dev_ddump() at dev_ddump+0xc / blk_write()β the NULLdi->privdereference β see panic.txt. - Patched: apply fix.diff,
make nativekernel && make installkernel, reboot, repeat step 3 βDump completedespite the churn.
Files: churn.c (ioctl churner), rebootdirect.c (single-threaded direct reboot(2) caller), run.sh, panic.txt (decisive serial-log excerpt), serial.race9.log (full serial log), run.baseline.log (clean baseline dump), fix.diff (verified fix: spinlock + snapshot), build.log.
Notes
- The churn must be a separate process from the reboot(2) caller: the
caller's own process is torn down by
shutdown_cleanup_proc()(see DF-2816) before the dump, and console writes freeze in polled mode, so in-loop logging/markers contaminate the experiment. - With the default sync-enabled reboot the churner reliably survives into
the dump window only when the reboot(2) caller skips the sync/unmount
phase;
rebootdirectusesRB_NOSYNC|RB_DUMPto reachdumpsys()quickly (kern_shutdown.c:393) while the churner is provably alive.
DF-2815 VERDICT β REPRODUCED (kernel panic while dumping; unsynchronized dumper global)
Bottom line
The global struct dumperinfo dumper in sys/kern/kern_shutdown.c is written
by set_dumper() (kern_shutdown.c:948-961) with no lock and no atomicity:
the EBUSY check is a check-then-act TOCTOU, the registration is a 40-byte
struct copy, and the clear path is an in-place bzero(). dumpsys()
(kern_shutdown.c:980-983) checks dumper.dumper != NULL and then hands the
global itself (&dumper) to md_dumpsys(), whose write loops re-read
di->priv for every block (minidump_machdep.c:95,143,348,427). A concurrent
DIOCGKERNELDUMP clear (subr_disk.c:1186-1188 β disk_dumpconf(dev,0) β
set_dumper(NULL), no lock, no privilege check on the ioctl itself β
DF-2743) that lands after dumpsys()'s check NULLs di->priv mid-dump, and
the next dev_ddump(NULL, ...) dereferences NULL->si_ops in
dev_needmplock() (kern_device.c:119-122) β page fault while dumping.
Reproduction (MARK-9, stock INVARIANTS kernel)
- churner: separate root process, 4 threads, ioctl(DIOCGKERNELDUMP) mostly "set" with a clear every 64th ioctl on /dev/vbd0s1b (the configured dump device).
- trigger: single-threaded
rebootdirectcallsreboot(2)withRB_AUTOBOOT|RB_DUMP|RB_NOSYNC(NOSYNC skips the sync/unmount phase so the dump starts while the churner is provably alive β the kproc-wait phase still shows churn progress markers in the diagnostic runs). - Result (serial console, see panic.txt / serial.race9.log):
Dumping 666 MB:Fatal user address access from kernel mode from rebootdirect at ffffffff8062d3fc Fatal trap 12: page fault while in kernel mode cpuid = 0; lapic id = 0 fault virtual address = 0xa8 current process = 843 (rebootdirect) current thread = pri 31 (CRIT) panic: page fault dev_ddump() at dev_ddump+0xc blk_write() at blk_write+0x9a
dev_ddump+0xc is dev_needmplock(dev) β dev->si_ops->head.flags at
offset 0xa8 of a NULL dev β i.e. di->priv == NULL, the just-bzeroed
global. Fault on cpu0 (the dumping CPU), in the rebooting thread, mid-dump
("Dumping 666 MB:" printed, no progress steps yet). This is the exact
predicted primitive, 1/1 once the churner was kept alive into the dump
window.
Earlier attempts that did NOT reproduce taught the setup constraints
(attempts 1-4): (a) the churner must not be a thread of the rebooting
process β boot() tears that process down first (that is DF-2816, which
panicked the box via naccess instead); (b) the churner must not touch the
root filesystem or console after shutdown starts β the forced unmount
blocks those writes forever and freezes the churn threads before the dump;
(c) reboot -d/reboot -dq (init-mediated) kills or starves the churner
before dumpsys, so the reboot(2) must be issued directly by an unrelated
single-threaded helper; (d) with the default sync-enabled path the window
is still reachable but far narrower β RB_NOSYNC makes it deterministic.
Exploit chain / escalation ceiling (rigorously assessed)
- Proven: unprivileged-to-root N/A; requires an open fd on a disk device
(root; operator group on default 0640 nodes; unprivileged only via
DF-2743's missing ioctl privilege check in setups where such nodes are
openable) plus a dump in progress (root
reboot -d/ any panic while churning). Demonstrated as root: kernel panic during the dump path (panic-in-reboot; in the panic path it would be a double panic). - Code-proven but not run (would be destructive to the guest disk):
the set-vs-set TOCTOU interleaves two 40-byte struct copies and can mix
priv(device A) withmediaoffset/mediasize/blocksize(device B), so a subsequent dump computesdumplo(dump_machdep.c:313-318) from B's geometry but writes through A's device β kernel memory written at a wrong offset on the wrong device: silent disk corruption, and RAM/kernel disclosure if the misdirected dump target is user-readable. - NOT achievable with this bug: userβroot. The
dumperfunction-pointer field can only bediskdump(subr_disk.c:931) or NULL β no attacker- chosen function pointer, no forged object;privis only a referenced cdev. The primitive tops out at NULL-deref (DoS) and destructive dump misdirection. uid0 escalation would require a separate function-pointer forgery primitive that this race does not provide.
Fix validation
fix.diff (in this pack; git-apply --check'd against the read-only tree):
a dumper_lock spinlock serializing set_dumper()/clear, and dumpsys()
taking a stack snapshot of the dumper under the lock and passing the
snapshot to md_dumpsys() (safe: md_dumpsys/minidumpsys only read di fields
β verified by audit of both files). Applied to the guest's /usr/src copy,
make nativekernel && make installkernel, rebooted into the patched
kernel, re-ran the exact MARK-9 PoC: the dump completes ("Dump complete")
despite identical churn β see run.patched.log. Baseline (unpatched)
signature (mid-dump page fault at dev_ddump+0xc) is gone.
Kernel references
- sys/kern/kern_shutdown.c:137 (global), :948-961 (set_dumper), :963-984 (dumpsys check + &dumper handoff)
- sys/kern/subr_disk.c:1186-1188 (unlocked, unprivileged ioctl entry), :915-939 (disk_dumpconf builds the registration)
- sys/kern/kern_device.c:119-122,423+ (dev_needmplock NULL deref sink)
- sys/platform/pc64/x86_64/minidump_machdep.c:95,143,348,427 (per-write di->priv re-reads); dump_machdep.c:313-318 (geometry math)
Fix verification
fixedfix.diff applied to the guest /usr/src copy, kernel rebuilt in-guest (make nativekernel && make installkernel) and booted (#1 Sep 1 17:21:28). Re-running the exact reproducing recipe (4-thread mode-3 churn + rebootdirect RB_DUMP|RB_NOSYNC): the dump now runs to completion ('Dumping 624 MB: ... Dump complete', run.patched.log) with the churner provably alive (console markers at boot() entry and through the kproc waits); no page fault, no panic. In an additional run the churn's clear happened to be in effect at dumpsys() entry and the dump was cleanly skipped - also correct, crash-free behavior. Baseline (stock kernel #0) signature 'Fatal trap 12 ... dev_ddump()+0xc while dumping' is gone.
['run.patched.log - patched-kernel run: churn markers live, full dump progress, Dump complete, no Fatal/panic', 'fix.diff - the validated diff (also carries the DF-2816 hunk validated in that same build)']
Confirmed kernel references
- sys/kern/kern_shutdown.c:137
- sys/kern/kern_shutdown.c:948-961
- sys/kern/kern_shutdown.c:963-984
- sys/kern/subr_disk.c:1186-1188
- sys/kern/subr_disk.c:915-939
- sys/kern/kern_device.c:119-122
- sys/platform/pc64/x86_64/minidump_machdep.c:95
- sys/platform/pc64/x86_64/minidump_machdep.c:143
- sys/platform/pc64/x86_64/minidump_machdep.c:348
- sys/platform/pc64/x86_64/dump_machdep.c:313-318
Detail
Exploit chain
root/operator fd on a disk device -> 4 threads spam ioctl(DIOCGKERNELDUMP) set/clear (set_dumper NULL/bcopy race on the global) -> separate single-threaded process calls reboot(2) with RB_DUMP|RB_NOSYNC -> kernel enters dumpsys() during a 'set' phase -> churn clear bzeros dumper mid-dump -> dev_ddump(di->priv==NULL) -> dev_needmplock NULL->si_ops deref -> page fault while dumping -> panic in the reboot path (double panic if triggered from the panic path). uid0 NOT reachable: no attacker-controlled function pointer or forged object; impact ceiling = panic DoS + destructive wrong-device dump writes (torn registration variant).
Evidence (decisive lines)
["panic.txt - decisive serial excerpt: 'Dumping 666 MB:' then Fatal trap 12, fault VA 0xa8, dev_ddump()+0xc <- blk_write(), cpu0, pri 31 (CRIT)", 'serial.race9.log - full serial console of the reproducing run (RACE-MARK-9, stock kernel #0)', 'run.baseline.log - clean baseline dump without churn (Dump complete)', "run.patched.log - fix validation: churn markers (T/c) live through shutdown, 'Dumping 624 MB ... Dump complete', no fault (patched kernel #1)", 'churn.c / rebootdirect.c - final PoC sources; run.sh - exact recipes', 'fix.diff - git-apply-checked diff (spinlock dumper_lock + dumpsys snapshot)']
PoC changes
Seed concept (dumpctl churn vs reboot -d) did not reproduce as-is; iterated to a working recipe: (1) churner must be a separate process - threads of the reboot(2) caller are torn down by shutdown_cleanup_proc first (that is DF-2816, which panicked via a different path); (2) removed all in-loop file/console I/O from the churner - the root fs is force-unmounted before the dump and blocked writes freeze the churn threads; (3) replaced reboot(8) with a direct single-threaded reboot(2) helper using RB_NOSYNC|RB_DUMP so dumpsys() is reached while the churner is provably alive; (4) mode-3 churn cadence (clear every 64th ioctl) maximizes NULL duty. Added diagnostic marker variant to prove churner liveness through the shutdown phases.
Verified recommended fix
Serialize set_dumper()/clear with a spinlock and make dumpsys() take a stack snapshot of the dumper under that lock before calling md_dumpsys() (see fix.diff).
Verdict
The global struct dumperinfo dumper in sys/kern/kern_shutdown.c is completely unsynchronized: set_dumper() (:948-961) does an unlocked EBUSY check-then-write / bzero, and dumpsys() (:980-983) hands &dumper itself (not a snapshot) to md_dumpsys(), whose write loops re-read di->priv on every block (minidump_machdep.c:95/143/348/427). A DIOCGKERNELDUMP clear (subr_disk.c:1186-1188, unlocked and unprivileged per DF-2743) landing after dumpsys()'s check NULLs di->priv mid-dump; the next dev_ddump(NULL,...) faults reading NULL->si_ops in dev_needmplock (kern_device.c:119). Demonstrated on the stock INVARIANTS guest: with a 4-thread ioctl churner on the dump device and a single-threaded reboot(2) (RB_DUMP|RB_NOSYNC) caller, the kernel page-faults mid-dump at dev_ddump+0xc (fault VA 0xa8, cpu0, CRIT priority, trace dev_ddump <- blk_write; panic.txt). Ceiling rigorously assessed: the dumper function-pointer field can only be diskdump or NULL and priv only a referenced cdev, so no function-pointer forgery exists - the primitive tops out at kernel-panic DoS (proven) plus code-proven destructive dump misdirection (torn set-vs-set copy mixing priv/geometry of two devices -> wrong-device/wrong-offset dump writes). Fix (spinlock + stack snapshot in dumpsys) validated on a rebuilt guest kernel: identical churn recipe yields 'Dump complete' with the churner provably alive (run.patched.log).
No comments yet.