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

Lost-wakeup race in slab remote-free z_RSignal interlock permanently strands whole zones β€” unbounded kernel KVA + wired-page leak (local DoS)

Field Value
ID DF-2721
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-662 / CWE-401
File sys/kern/kern_slaballoc.c
Lines 1508-1547 (pre-push read), 978-1005 (owner removal)
Area kern
Confidence certain
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

_kfree()'s remote path samples z_RSignal BEFORE pushing the chunk to z_RChunks and uses that stale sample to decide whether to IPI the owner. If the owner cpu exhausts the zone in between (RSignal=1 swap only when RChunks==NULL, drain, TAILQ_REMOVE), the remote's NULL→non-NULL push lands on a zone that has left ZoneAry with rsignal==0 in hand: no IPI is ever sent. The zone (z_NFree==0, RChunks non-empty, RSignal==1, RCount==0) is reachable by no recovery path — the allocator walks ZoneAry only, slab_cleanup walks ZoneAry only, kfree_remote runs only via IPI, and only a local free would re-insert it. All subsequent remote frees of that zone take the bchunk!=NULL else-branch and never IPI either, so the entire 128KB zone (KVA + 32 wired pages + kernel_map entry) is leaked forever, invisible to vmstat -m.

Threat model & preconditions

Any unprivileged workload producing cross-CPU kmalloc/kfree churn (migrating threads, syscall-vs-interrupt cpu splits, network teardown) hits the precondition routinely (~1/30 remote frees read RSignal==0 β€” 51,948 in 30s measured); each completed straddle permanently leaks a zone; losses aggregate until kmem_slab_alloc panics ("kernel_map ran out of space!") or OOM. Natural window is ~50ns (no hit in 15.4M guest ops), but any preemption/steal-time widens it; validated massively under a 50us legal-window injection: KVA +336MB and v_wire_count +330MB monotonic, none recovered. Protocol leak, not corruption; no uid0 route.

Proof of concept

findings/poc/DF-2721/ (KLD producer/consumer pin harness + kernel_map census): stock 15.4M ops KVA flat; instrumented kernel with a 50us DELAY between the rsignal read and the push (legal preemption emulation) leaks monotonically; fix (post-push decision with RCount held across the push) validated on a rebuilt kernel β€” KVA dead flat across 5.4M remote frees.

Hold z_RCount across the push unconditionally and decide the IPI AFTER the push by re-reading z_RSignal (the NULL→non-NULL transition pusher IPIs whenever RSignal is set; a removed zone always has RSignal==1) — full diff in findings/poc/DF-2721/fix.diff (validated).

Timeline

  • 2026-08-30 Discovered during pass-2 audit of kern_slaballoc.c (GLM 5.3); injection-validated leak + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2721 Β· 16 files
FileTypeDescriptionSize
slabstrand.c β€” 9.8 KB view raw
hammer.c β€” 1.3 KB view raw
build.sh β€” 472 B view raw
run.sh β€” 747 B view raw
build.log β€” 836 B view raw
run.log β€” 5.1 KB view raw
campaigns_raw.log β€” 2.1 KB view raw
fix_validation.log β€” 1.2 KB view raw
env_kernel1.txt β€” 291 B view raw
kernel_instrumented.c β€” 52.1 KB view raw
kernel_fixed.c β€” 51.0 KB view raw
fix.diff β€” 2.0 KB view raw
panic_pocbug_iteration.txt β€” 713 B view raw
VERDICT.md β€” 6.5 KB ↓ raw
manifest.json β€” 1.2 KB view raw
verdict.json β€” 5.6 KB view raw
VERDICT.md
↓ download raw

DF-2721 VERDICT β€” reproduced (with timing injection); fix validated verbatim

Finding

Lost-wakeup race in the slab allocator's cross-CPU free interlock (z_RSignal/z_RChunks/z_RCount) permanently strands entire slab zones: kernel KVA + wired pages leak forever, invisible to accounting, unbounded, reachable by ordinary unprivileged kernel-object churn on any SMP system.

Root cause (path:line)

_kfree() remote path, sys/kern/kern_slaballoc.c:

  • :1511 rsignal = z->z_RSignal; cpu_lfence(); β€” the remote samples the signal flag before pushing its chunk.
  • :1517-1525 β€” the chunk is pushed onto z->z_RChunks (lock-free CAS).
  • :1539 if (bchunk == NULL && rsignal) β€” the IPI decision uses the stale pre-push sample.

_kmalloc() owner path:

  • :991-992 β€” owner sets z_RSignal=1 only if z_RChunks==NULL, then
  • :994 drains, and :1000-1001 TAILQ_REMOVEs the zone from ZoneAry when the last free chunk is handed out.

The interleaving that defeats the interlock:

t0 remote reads z_RSignal == 0 (zone on ZoneAry) t1 owner: swap RSignal=1, drain (RChunks empty), TAILQ_REMOVE (zone leaves ZoneAry; it will never be found by the allocator again) t2 remote pushes its chunk onto z_RChunks (NULL->chunk transition) bchunk==NULL but rsignal==0 -> no IPI is ever sent

Resulting state: zone off ZoneAry, z_NFree==0, z_RChunks != NULL, z_RSignal==1, z_RCount==0. Every recovery path misses it:

  • allocation path (:971) only walks ZoneAry;
  • slab_cleanup() (:1627) only walks ZoneAry;
  • kfree_remote() (:1337) runs only via IPI β€” none was sent;
  • a local free on the owner cpu would re-insert it (:1602-1603) but nothing forces one to happen;
  • subsequent remote frees of the same zone read RSignal==1, push with bchunk != NULL and take the else if (rsignal) branch (:1544) β€” RCount up then down, still no IPI β€” so every remaining chunk that is freed remotely piles onto the stranded z_RChunks and the entire 128KB zone (32 wired pages + KVA + kernel_map entry) is lost forever;
  • accounting shows nothing: the freeing cpu already decremented ks_use[].memuse at :1499-1501.

check_zone_free() cannot recycle the zone because z_NFree can never reach z_NMax (stranded chunks are never drained into z_NFree).

Reproduction (single-tenant QEMU guest, 6 vCPU, DF 6.5-DEVELOPMENT)

Driver: KLD slabstrand.ko β€” producer lwkt thread pinned cpu0 kmalloc()s 2048-byte chunks (zones owned by cpu0); consumer threads pinned cpu1+cpu2 kfree() them, driving the z_CpuGd != gd remote path at ~51K ops/s/thread; kern.slabstrand.census walks kernel_map counting VM_SUBSYS_KMALLOC entries/KVA; ground-truth metrics: KMALLOC KVA (KB) and v_wire_count.

  1. Natural rate bound (stock kernel #0): 5x60s cross-cpu campaigns, 15.4M ops β€” KMALLOC KVA flat at 82,220KB (zero strands). The precondition (a remote free reading RSignal==0) is common β€” 51,948 in 30s β€” but completing the straddle needs the remote to be preempted for the ~30-60ns the owner spends in [swap..remove]; interrupts at ~1-4KHz per cpu put the observed natural rate below 1/17M ops in our window.
  2. Injected reproduction (instrumented kernel #1): the ONLY change to protocol behaviour is DELAY(50us) inserted between the rsignal read and the push when a knob is set β€” a legal emulation of the remote being preempted in that window (kernel_instrumented.c). 6x60s campaign: KMALLOC KVA 80,944 -> 417,200KB (+336MB, monotonic) and v_wire_count +84,647 pages (+330MB wired); after the workload stopped, rings drained, 20s settle: nothing returned (417,200 stays). Hundreds of zones permanently stranded; no panic (stranding is not an INVARIANTS violation β€” it is a protocol-level leak, not corruption).
  3. Fix (same workload, same injection): a. runtime knob implementing the fix decision logic β€” KVA flat. b. kernel #2 with fix.diff applied byte-for-byte (only the injector knob added for testing; inject=50us now delays every remote free β€” harsher than (2)): 5.4M remote frees, KVA dead flat at 80,044KB across six 60s runs and after settle; the fix's post-push IPI decision fired 144,525 times and every one was recovered. fix_validation.log.

Impact

Permanent, unbounded, aggregation-only kernel memory leak -> gradual system-wide memory/KVA exhaustion -> kmem_slab_alloc() panic ("kernel_map ran out of space!") / OOM-style denial of service. Trigger: ordinary cross-CPU alloc/free churn (any workload where kernel objects allocated on one cpu are freed on another β€” migrating threads, interrupt vs syscall cpu splits, network teardown paths). Not memory corruption, not an info leak, not privilege escalation. Natural per-event rate is low (nanosecond window), but each success is permanent and compounds; a patient multi-day workload (or a VM-host jittery environment where virtualization steal-time widens preemption windows by orders of magnitude) accumulates leaks without bound.

The fix (fix.diff β€” remote side, decision after the push)

Hold z_RCount across the push unconditionally (makes post-push reads of z_RSignal/z_CpuGd safe), then decide the IPI after the push by re-reading z_RSignal: the cpu that performs the NULL->non-NULL z_RChunks transition and sees RSignal set sends the passive IPI regardless of what its stale pre-read said. This closes the window completely: a zone removed from ZoneAry always has RSignal==1, so the transition pusher always notices and kfree_remote() re-attaches the zone. Cost: one extra atomic add/subtract pair per remote free. Validated verbatim on kernel #2 (see fix_validation.log).

Honesty notes

  • The first module version double-freed due to a PoC ring bug (two consumers on one SPSC ring); the INVARIANTS kernel caught it immediately ("memory chunk already free!", panic_pocbug_iteration.txt). Fixed (per-consumer rings); the final sources in this pack are clean.
  • The hits counter on instrumented kernel #1 (post-push RSignal==1 with pre-read==0) has false positives (stale RSignal==1 is legal on on-list zones), which is why the pack's ground truth is the merge-immune KMALLOC-KVA / wire-count census, not the counter.
  • Natural (un-injected) reproduction was NOT observed in ~17M ops; all massive-leak demonstrations use the 50us injector. The injector only widens an existing window (preemption between two adjacent operations); it does not change the protocol or force any state.

Guest hygiene

Guest reset to clean with-src snapshot after this run.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Same injected 6x60s campaign on the fix.diff-verbatim kernel: kmalloc_kva_kb flat at 80044KB across all runs and after settle (vs +336MB on stock protocol); wire flat; the fix decision fired 144525 times under injection with every zone recovered; throughput unchanged (~900K ops/run).

fix_validation.log (campaign4); kernel_fixed.c; fix.diff
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #2: Mon Aug 31 08:53:06 UTC 2026 (fix.diff applied verbatim + injector knob only)

Confirmed kernel references

Detail

Exploit chain

unprivileged workload causing cross-cpu kmalloc/kfree churn (migrating threads, irq-vs-syscall cpu split) -> remote kfree reads z_RSignal==0 -> owner exhausts zone (swap RSignal=1, drain, TAILQ_REMOVE) inside the remote's read..push window (naturally ~50ns, widened by any preemption/virtualization steal) -> remote pushes chunk, stale rsignal==0, no IPI -> zone permanently off ZoneAry with NFree==0 -> all subsequent remote frees of that zone strand too (bchunk!=NULL path never IPIs) -> entire zone (128KB KVA, 32 wired pages, kernel_map entry) leaked forever -> aggregate unbounded -> eventual kmem_slab_alloc panic/OOM DoS.

Evidence (decisive lines)

['run.log sections [1]-[5]: stock flat at 82220KB over 15.4M ops; instrumented+50us inject: kmalloc_kva_kb 80944->417200 (+336MB) and wire +84647 pages, retained after settle', 'fix_validation.log: kernel #2 with fix.diff verbatim, same injection on all 5.4M remote frees: kmalloc_kva_kb dead flat 80044KB, hits(fix decisions)=144525 all recovered', 'VERDICT.md: full interleaving analysis with path:line for every recovery path that misses the stranded zone', 'kernel_instrumented.c / kernel_fixed.c / fix.diff: test kernel, fixed kernel, production fix', "panic_pocbug_iteration.txt: honesty note - first PoC iteration's own double-free caught by INVARIANTS"]

PoC changes

Original sketch was syscall-driven (too slow: ~1K chunks/s); rewrote as in-kernel KLD with lwkt threads pinned via TDF_FIXEDCPU (51K+ ops/s/thread). First version double-freed (two consumers, one SPSC ring) - INVARIANTS caught it; final version uses one SPSC ring per consumer. Added kernel_map census sysctl (VM_SUBSYS_KMALLOC entries/KVA) as merge-immune ground truth after zone-count noise from adjacent-entry merging. Injector (DELAY in the legal preemption window) added to the guest's kernel copy only.

Verified recommended fix

In _kfree() remote path: hold z_RCount across the RChunks push unconditionally and make the IPI decision AFTER the push by re-reading z_RSignal (IPI on the NULL->non-NULL transition when RSignal is set) - see fix.diff.

Verdict

Lost-wakeup race in the slab allocator remote-free interlock (z_RSignal pre-read at kern_slaballoc.c:1511 vs IPI decision at :1539 racing the owner's zone removal at :991-1001) permanently strands whole 128KB slab zones with their remotely-freed chunks in z_RChunks: no IPI is ever sent, the zone leaves ZoneAry, and no recovery path (alloc path :971, slab_cleanup :1627, kfree_remote :1337, local free :1602) can ever reach it again. Each stranded zone permanently leaks 128KB KVA + 32 wired pages + a kernel_map entry, invisible to accounting. Reproduced on the 6-vCPU guest with a pinned-thread KLD hammer: natural window too rare to observe in 15.4M ops (precondition - reading RSignal==0 - fires on ~1/30 remote frees, 51,948/30s, but the ~50ns straddle never completed); with a 50us preemption injected between the two adjacent remote-side operations (a legal interleaving any interrupt can produce) the leak is massive and permanent: +336MB KMALLOC KVA and +330MB wired pages over 6 minutes, none returned after drain+settle. Fix (decide the IPI AFTER the push by re-reading z_RSignal under an unconditionally-held z_RCount) validated twice, including on a kernel with fix.diff applied byte-for-byte: 5.4M injected remote frees, zero net KVA/wire growth, 144,525 recovered would-be strands. Impact ceiling: unbounded permanent kernel memory leak -> gradual memory exhaustion DoS from unprivileged cross-CPU churn; not corruption, not info disclosure, not privesc.