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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2721 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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=1only ifz_RChunks==NULL, then - :994 drains, and :1000-1001
TAILQ_REMOVEs the zone fromZoneArywhen 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 walksZoneAry;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 withbchunk != NULLand take theelse if (rsignal)branch (:1544) βRCountup then down, still no IPI β so every remaining chunk that is freed remotely piles onto the strandedz_RChunksand the entire 128KB zone (32 wired pages + KVA + kernel_map entry) is lost forever; - accounting shows nothing: the freeing cpu already decremented
ks_use[].memuseat :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.
- 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. - Injected reproduction (instrumented kernel #1): the ONLY change to
protocol behaviour is
DELAY(50us)inserted between thersignalread 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) andv_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). - 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
hitscounter 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
fixedSame 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
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.
No comments yet.