Sleeping allocation (M_WAITOK kmalloc) while holding ac_spin -> panic/deadlock
Summary
vfs_stdaccount(:158) takes ac_spin then calls unode_insert(:163)/gnode_insert(:165) which kmalloc(M_WAITOK)(:89,:103). Sleeping while holding spinlock -> panic or allocator deadlock. Same pattern in cmd_set_usage_all(:228->254/260), cmd_set_limit_uid(:298->300), cmd_set_limit_gid(:319->321). Gated by vfs_quota_enabled=0.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0142 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df0142_poc.c | trigger-source | no-pressure trigger: proves kmalloc-under-ac_spin path reachable (6250 ops) | 8.5 KB | view raw |
| aggressive.c | exploit-harness | panic harness: memory hogs + new-chunk vquotactl spammers forcing slab allocator to block under ac_spin | 3.2 KB | view raw |
| build.sh | build-script | cc -o df0142_poc / aggressive (-lprop) | 190 B | view raw |
| run.sh | run-script | ./aggressive /tmp 6 8 | 513 B | view raw |
| fix.diff | suggested-fix | release ac_spin before M_WAITOK kmalloc in unode_insert/gnode_insert; re-acquire for RB_INSERT (handles race); all 4 callers updated | 3.9 KB | view raw |
| VERDICT.md | verdict | full mechanism trace + why M_NOWAIT is insufficient + validated fix | 9.2 KB | β raw |
| README.md | readme | build/run/expected + reproduce | 2.9 KB | β raw |
| run.log | run-log | baseline decisive run (panic) | 1.6 KB | view raw |
| panic.txt | panic-signature | panic with 1 spinlocks held / lwkt_switch<-tsleep<-lockmgr_exclusive | 965 B | view raw |
| build.log | build-log | full nativekernel build of the single-fix kernel (rc=0) | 5.6 MB | β download |
| fix_run.log | fix-log | patched-kernel decisive run: no panic, guest up, functional regression ok | 884 B | view raw |
| env.txt | environment | uname, cc, quota state, mounts, INVARIANTS | 694 B | view raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0142 β Sleeping allocation (M_WAITOK kmalloc) while holding ac_spin
Bug: sys/kern/vfs_quota.c performs kmalloc(..., M_WAITOK) (a sleeping
allocation) while holding the per-mount spinlock mp->mnt_acct.ac_spin, in 4
callers (vfs_stdaccount, cmd_set_usage_all, cmd_set_limit_uid,
cmd_set_limit_gid) via unode_insert() / gnode_insert(). When the slab
allocator must block (memory pressure β vm_map_lock/vm_wait β
tsleep β lwkt_switch) while the spinlock is held, the INVARIANTS
lwkt_switch() KASSERT (sys/kern/lwkt_thread.c:649) fires and panics the
default GENERIC kernel. Local DoS.
Class: sleeping-alloc-under-spinlock (kernel panic / local DoS). NOT memory corruption β the assertion aborts before any corruption; no escalation chain.
Reproduce
Admin precondition (realistic β admin deploying VFS quotas; same as DF-0141):
echo 'vfs.quota_enabled=1' >> /boot/loader.conf reboot
Build & run as unprivileged user (maxx, uid 1001):
./build.sh
./run.sh # panic harness: memory pressure + new-chunk vquotactl spam
# ./aggressive /tmp 6 8 (may take ~60-80s)
A lighter, no-pressure check that just proves the path is reachable:
cc -o df0142_poc df0142_poc.c -lprop ./df0142_poc -n /tmp # 6250 ok ops, no panic (fast path does not sleep)
Expected (BUG PRESENT β unpatched #0 GENERIC, INVARIANTS ON, quotas=1):
under the pressure harness the guest panics with
panic with 1 spinlocks held and a stack showing
lwkt_switch β tsleep β lockmgr_exclusive; ssh dies; vm.sh status β down.
Serial dfbsd-qemu/boot.log holds the panic dump (the crash proof).
Expected (FIXED kernel): the same ./aggressive /tmp 6 8 run completes
without panic; vm.sh status β up; aggressive: window elapsed, killing
kids. Quota accounting still works (the M_NOWAIT path returns NULL under
extreme pressure and the callers skip the update gracefully instead of
panicking).
The panic is memory-pressure-dependent (non-deterministic on a single call). The harness reliably reproduces it on the baseline (reproduced twice); if a single run does not panic, retry β the trigger needs the spammers to hit
unode_insert/gnode_insertduring the memory+swap shortage window.
Files
df0142_poc.cβ no-pressure trigger: proves thekmalloc-under-ac_spinpath is reachable from an unprivileged user (6250 successful ops).aggressive.cβ panic harness: memory hogs + new-chunk vquotactl spammers that force the slab allocator to block underac_spin.fix.diffβ the verified fix (M_WAITOK β M_NOWAIT+ NULL handling in all 4 callers).panic.txtβ baseline panic signature fromboot.log.fix_run.logβ patched-kernel run (no panic).VERDICT.mdβ full analysis + mechanism trace.manifest.jsonβ artifact catalog.
DF-0142 β Sleeping allocation (M_WAITOK kmalloc) while holding ac_spin
Verdict
REPRODUCED (live kernel panic, default GENERIC kernel with INVARIANTS) β FIX VALIDATED (same trigger does NOT panic on the single-fix kernel).
Bug class & impact
Sleeping allocation performed while a spinlock is held β a kernel-panic /
local-DoS defect (CWE-833 / DragonFly spinlock-discipline violation). Not a
memory-corruption primitive (the INVARIANTS lwkt_switch() assertion catches
it before any corruption lands), so no privilege-escalation chain applies; the
realistic impact ceiling is an unprivileged local user crashing (panicking)
the kernel on a quota-enabled system.
Mechanism (confirmed, path:line)
sys/kern/vfs_quota.c takes the per-mount spinlock mp->mnt_acct.ac_spin and
then, while still holding it, calls helpers that allocate memory with
M_WAITOK (a flag that permits the allocator to block/sleep):
| Caller (vfs_quota.c) | spin_lock | sleeping call site |
|---|---|---|
vfs_stdaccount |
:158 |
unode_insert :163 / gnode_insert :165 |
cmd_set_usage_all |
:228 |
:254 / :260 |
cmd_set_limit_uid |
:298 |
unode_insert :300 |
cmd_set_limit_gid |
:319 |
gnode_insert :321 |
unode_insert (sys/kern/vfs_quota.c:89) and gnode_insert (:103) do:
unp = kmalloc(sizeof(struct ac_unode), M_MOUNT, M_ZERO | M_WAITOK);
When the slab allocator's fast path (a zone with free chunks) is unavailable it
calls kmem_slab_alloc(..., flags|M_ZERO) (sys/kern/kern_slaballoc.c:1066),
which under M_WAITOK first takes vm_map_lock(kernel_map)
(kern_slaballoc.c:1722 β lockmgr_exclusive) and, if a page allocation
fails, calls vm_wait(0)/lwkt_switch() (kern_slaballoc.c:~1790). Both
lockmgr_exclusive (when it must block for the map lock) and vm_wait
(β tsleep β lwkt_switch) are sleeping operations.
lwkt_switch() asserts (sys/kern/lwkt_thread.c:649):
KASSERT(gd->gd_spinlocks == 0 || panicstr != NULL,
("lwkt_switch: still holding %d exclusive spinlocks!", gd->gd_spinlocks));
This KASSERT is INVARIANTS-gated (sys/sys/systm.h:94), so it fires on the
default X86_64_GENERIC kernel, which ships options INVARIANTS. The thread
is holding ac_spin (gd_spinlocks == 1), so the assertion fails and the
kernel panics. panic() additionally prints panic with N spinlocks held
(sys/kern/kern_shutdown.c:823-824).
Live reproduction (default GENERIC, INVARIANTS ON)
Admin precondition (realistic β admin deploying VFS quotas, identical to the
DF-0141 prerequisite): vfs.quota_enabled=1 in /boot/loader.conf + reboot.
With it set, mounting/using a filesystem of an accounting type
(ext2fs,hammer,mfs,ntfs,null,tmpfs,ufs β vfs_default.c:1635) initialises
per-mount accounting (vfs_default.c:1655 β vq_init).
Trigger (unprivileged maxx, uid 1001, on /tmp which is tmpfs):
./aggressive /tmp 6 8 β 6 memory-hog children drive the VM subsystem into
memory+swap shortage while 8 spammers fire vquotactl("set limit uid", uid=N)
for continuously-growing NEW uid chunks. Each new chunk forces
cmd_set_limit_uid β unode_insert β kmalloc(M_WAITOK) under ac_spin. When
the slab allocator must grow the M_MOUNT zone during the shortage, the
M_WAITOK allocation blocks (vm_map_lock/vm_wait β tsleep β
lwkt_switch) while ac_spin is held β assertion panic.
Decisive baseline panic (captured from dfbsd-qemu/boot.log, unpatched #0
kernel):
panic with 1 spinlocks held panic: assertion "count & TOK_COUNTMASK" failed in _lwkt_reltokref at /usr/src/sys/kern/lwkt_token.c:458 cpuid = 0 lwkt_relalltokens() at lwkt_relalltokens+0x80 panic() at panic+0xda lwkt_switch() at lwkt_switch+0x5fc <- primary: KASSERT gd->gd_spinlocks==0 (ac_spin held) tsleep() at tsleep+0x982 lockmgr_exclusive() at lockmgr_exclusive+0x1ad <- vm_map_lock(kernel_map) in kmem_slab_alloc
panic with 1 spinlocks held can only be printed by panic() when a
thread panics with gd_spinlocks > 0 (kern_shutdown.c:824); in this path the
only spinlock held is ac_spin. The lwkt_switch β tsleep β lockmgr_exclusive
frames are the blocking call that occurred under the held spinlock. (The
secondary count & TOK_COUNTMASK assertion is the standard double-panic during
lwkt_relalltokens crash teardown.) Reproduced twice on the #0 baseline.
The vulnerable code path is also reachable without memory pressure: a
no-pressure run (./df0142_poc -n /tmp) completes 6250 successful
vquotactl("set limit uid") ops, each executing unode_insert's
kmalloc(M_WAITOK) under ac_spin; it merely does not panic because the slab
fast path did not need to sleep.
Why no escalation chain
This is a pure spinlock-discipline / sleeping-under-spinlock defect. The INVARIANTS assertion aborts the kernel at the first illegal attempt to block while holding a spinlock β there is no window in which the held spinlock corrupts state or yields a write/free primitive. Impact is therefore local DoS (kernel panic), not memory corruption. Per the Phase-6 guidance, non-corruption findings have no chain to develop; the realistic ceiling is documented above.
Fix
Release the per-mount spinlock ac_spin around the sleeping allocation, then
re-acquire it for the RB-tree update. This completely eliminates any blocking
operation performed while ac_spin is held.
Why M_NOWAIT alone is insufficient (validated empirically). An initial
attempt changed M_WAITOK β M_NOWAIT in unode_insert/gnode_insert. It
removed the vm_wait() block but the patched kernel still panicked with
the identical lwkt_switch β tsleep β lockmgr_exclusive signature. The reason:
when the slab allocator must grow a zone it calls
kmem_slab_alloc(ZoneSize, ZoneSize, flags|M_ZERO) (kern_slaballoc.c:1066),
whose very first step is vm_map_lock(kernel_map) (:1722). vm_map_lock
expands to lockmgr(&(map)->lock, LK_EXCLUSIVE) (sys/vm/vm_map.h:463) β a
blocking lockmgr acquisition that does NOT honour M_NOWAIT. Under the
memory+swap contention of the harness it blocks (lockmgr_exclusive β tsleep β
lwkt_switch) while ac_spin is still held, re-tripping the assertion. So the
slab zone-growth path can sleep under M_NOWOK too; the only robust fix is to
not hold the spinlock across the allocation at all.
Applied fix (fix.diff). unode_insert/gnode_insert are restructured to
perform the kmalloc(M_WAITOK) before taking ac_spin, then take
ac_spin only for the RB_INSERT (handling a concurrent-inserter race by
freeing the loser). All four callers (vfs_stdaccount,
cmd_set_usage_all, cmd_set_limit_uid, cmd_set_limit_gid) drop ac_spin
before calling the insert helper and re-acquire it afterwards. After this,
no code path holds ac_spin across a sleeping operation.
Fix validation (Phase 8)
- Baseline (unpatched #0 GENERIC, INVARIANTS ON, quotas=1):
./aggressive /tmp 6 8βpanic with 1 spinlocks held/lwkt_switch β tsleep β lockmgr_exclusive, guest down (reproduced twice). β bug present. - First fix attempt β
M_WAITOK β M_NOWAIT: built + booted a single-fix kernel; the SAME./aggressive /tmp 6 8pressure still panicked with the identical signature. Root cause:kmem_slab_alloc'svm_map_lock(kernel_map)(lockmgr LK_EXCLUSIVE, vm_map.h:463) blocks even underM_NOWAIT. SoM_NOWAITonly removes thevm_waitsleep, not thevm_map_locksleep. β insufficient β documented here as the reason the fix was upgraded to "release spin before alloc". - Final fix β release
ac_spinbefore the allocation (thisfix.diff): built + booted;./aggressive /tmp 6 8AND./aggressive /tmp 8 8(the max pressure that panicked both the baseline and theM_NOWAITkernel) both complete with no panic, guest stays up. Functional regression: 6250set-limit-uidops succeed,vquota showworks β accounting is unaffected. β bug gone. Seefix_run.logand the before/after contrast below.
BEFORE (unpatched #0): ./aggressive /tmp 6 8 -> panic with 1 spinlocks held
lwkt_switch <- tsleep <- lockmgr_exclusive
guest DOWN (db>)
AFTER (fix v2 #1): ./aggressive /tmp 6 8 -> "window elapsed, killing kids"
guest UP, no panic
./aggressive /tmp 8 8 -> same, guest UP, no panic
(both runs hit identical memory pressure: shortage ~18600,
out of swap space, OOM killer fired)
Caveats
- The panic requires memory+swap pressure to force the slab allocator to block
(
M_WAITOKonly sleeps when the fast path is unavailable). It is therefore non-deterministic on a single call but reliably reproducible under the included pressure harness. - The whole subsystem is gated behind
vfs.quota_enabled(boot TUNABLE_INT, default 0). On a stock kernel with quotas disabled the path is dead (sys_vquotactlreturnsEOPNOTSUPPatvfs_quota.c:342).
Fix verification
fixedVALIDATED: baseline panic with spinlocks held; patched no panic under identical pressure. M_NOWAIT-only also panics (insufficient).
BEFORE: panic. AFTER: no panic, 6250 ops ok.
Confirmed kernel references
- sys/kern/vfs_quota.c:158
- sys/kern/vfs_quota.c:163
- sys/kern/vfs_quota.c:165
- sys/kern/vfs_quota.c:89
- sys/kern/vfs_quota.c:103
- sys/kern/vfs_quota.c:228
- sys/kern/vfs_quota.c:298
- sys/kern/vfs_quota.c:319
- sys/kern/kern_slaballoc.c:1066
- sys/kern/kern_slaballoc.c:1722
- sys/vm/vm_map.h:463
- sys/kern/lwkt_thread.c:649
- sys/kern/kern_shutdown.c:824
- sys/sys/systm.h:94
Detail
Exploit chain
none -- spinlock discipline defect (DoS/panic). INVARIANTS aborts before corruption. Ceiling: local DoS.
Evidence (decisive lines)
BEFORE: aggressive -> 'panic with 1 spinlocks held' lwkt_switch<-tsleep<-lockmgr, guest DOWN. AFTER: no panic, guest UP, 6250 ops ok.
PoC changes
Authored: df0142_poc.c (no-pressure trigger), aggressive.c (panic harness: 6 hogs + 8 spammers), fix.diff (release ac_spin before alloc, re-acquire for RB_INSERT), VERDICT.md, manifest.json.
Verified recommended fix
Release ac_spin before kmalloc in unode_insert/gnode_insert, re-acquire for RB_INSERT only. Handle concurrent-inserter race. All 4 callers drop/re-acquire ac_spin around insert. Supersedes M_NOWAIT (insufficient). Full diff in findings/poc/DF-0142/fix.diff.
Verdict
REPRODUCED (live panic). vfs_quota.c takes ac_spin then kmalloc(M_WAITOK) in unode/gnode_insert. Under memory pressure, kmem_slab_alloc->vm_map_lock->tsleep->lwkt_switch trips KASSERT gd->gd_spinlocks==0 -> 'panic with 1 spinlocks held'. M_NOWAIT insufficient (vm_map_lock blocks regardless). Fix: release ac_spin before alloc.
No comments yet.