cross-cache crossover on hardened 7.0 hits 100% if you stop trying to be clever
i needed cross-cache to work on a hardened 7.0 box for a UAF chain i was
building, and every recipe i could find assumed shrink, memory pressure, or
both. on stock Ubuntu 26.04 LTS Resolute, kernel 7.0.0-15-generic,
the GFP_KERNEL_ACCOUNT / plain-GFP_KERNEL partition
between kmalloc-cg-256 and the kmalloc-rnd-XX-256
bucket family turns out to be bridgeable from an unprivileged user at 100%
rate. drain a couple thousand msg_msg objects out of cg-256,
immediately fire 4096 add_key payloads into rnd-XX-256, and
buddy LIFO re-hands every cg-256 slab page that reached it back to
rnd-XX-256. seven of eight runs landed cleanly at 100%; the eighth was a 42%
outlier from background contention. no shrink, no sudo,
no memory pressure, sub-100 ms drain-spray window.
getting there took three wrong turns.
the mitigation pair this beats
two slab-side mitigations, working together, break the textbook "spray content gadget into freed vuln slot" pattern on modern hardened distros:
CONFIG_RANDOM_KMALLOC_CACHES. plainkmalloc(size, GFP_KERNEL)hashes the call site and routes to one of 16 random buckets,kmalloc-rnd-00-256throughkmalloc-rnd-15-256. distinct call sites land in different buckets unless the hash collides.__GFP_ACCOUNThard-routing.GFP_KERNEL_ACCOUNT(and__GFP_RECLAIMABLE,__GFP_DMA) skip the random pick and route to a single dedicated cache:kmalloc-cg-256,kmalloc-rcl-256,kmalloc-dma-256. the routing decision is one AND-and-branch ininclude/linux/slab.h:
kmalloc_type(flags, caller):
if ((flags & KMALLOC_NOT_NORMAL_BITS) == 0)
return KMALLOC_RANDOM_START + hash_64(caller ^ random_kmalloc_seed, 4)
if (flags & __GFP_DMA) return KMALLOC_DMA
if (flags & __GFP_RECLAIMABLE) return KMALLOC_RECLAIM
else return KMALLOC_CGROUP
this matters because almost every useful spray gadget on a current kernel is account-tagged: msg_msg, pipe buffers, anon socks, plenty of fs/net structures. they all live in kmalloc-cg-N. the vulnerable allocations, the ones a UAF or OOB actually targets, are mostly plain GFP_KERNEL, and those land in some kmalloc-rnd-XX-N, a different bucket from the spray. cross-cache is the only bridge.
the bridge has been folklore since Wu et al's papers and the dirty-cred chain. what i couldn't find anyone demonstrate empirically was the part that actually matters: does a cg-N slab page really get re-handed to rnd-XX-N by buddy on a current hardened distro, reliably enough to lean on in an exploit. Linux 7.0+ also rearchitected SLUB into the sheaves model (Babka, 2024-2025), which moved the per-CPU and per-NODE plumbing around in ways the old recipes were never written for.
visibility first: slab_observe.ko
no experiment without observation first. there's no public tool that lets you watch SLUB metadata on a running 7.0+ kernel. /proc/slabinfo is too coarse. /sys/kernel/slab/<cache>/ doesn't expose individual slab pages. to know whether page P moved from cache A's slab list to cache B's, you have to walk those lists yourself.
slab_observe.ko is a 360-line out-of-tree module that does that. it mirrors the layout of struct kmem_cache, struct slab, and the new struct slub_percpu_sheaves, then exposes five debugfs files:
target. write a cache name (e.g.kmalloc-rnd-04-256)dump. per-CPUmainsheaf object pointers + sheaf metadata (size, capacity, spare/rcu_free pointers)probe. write a kernel address, get back the slab page + slot index that contains itdump_slabs. walknode[0]->partialandnode[0]->fulllists, print each slab's PFN/addr/inuse/objects/freelistdump_all. O(max_pfn) linear PFN scan: report every slab page whoseslab_cachepointer matches the target. ~37 ms on a 6 GB VM
the fiddly part is locating kmem_cache::node. the struct's tail is config-dependent (kobj, random_seq, useroffset/usersize, and so on), so there's no portable offset. i derive it once at compile time off BTF:
$ bpftool btf dump file /sys/kernel/btf/vmlinux format raw \
| grep -A 25 "STRUCT 'kmem_cache' "
[129103] STRUCT 'kmem_cache' size=8400 vlen=25
'cpu_sheaves' bits_offset=0
'flags' bits_offset=64
...
'list' bits_offset=768
'kobj' bits_offset=896
'random' bits_offset=1408
'remote_node_defrag_ratio' bits_offset=1472
'random_seq' bits_offset=1536
'useroffset' bits_offset=1600
'usersize' bits_offset=1632
'node' bits_offset=1664 # ← byte 208
on Resolute 7.0.0-15-generic with the stock Ubuntu config, node sits at byte 208. the LKM hard-codes that and reaches node[0] by pointer arithmetic from the kmem_cache address it pulls out of slab_caches. re-derive it for other kernels.
once slab pages are visible by PFN, the cross-cache question reduces to set arithmetic. snapshot cache A's PFN set, do something, snapshot cache A again plus cache B, take the symmetric differences.
slab_observe.c: full LKM source, drop in a Makefile and insmod.
v6: shrink-driven, 48.8%, the wrong primitive
first cut. allocate 384 msg_msg objects with 200-byte bodies (~256 B per alloc, so they hit kmalloc-cg-256). snapshot cg-256 PFNs while the holder is alive. kill the holder with msgctl(IPC_RMID) on every queue. snapshot. spray 4096 add_key "user"-type keys with 200-byte payloads; those alloc as plain GFP_KERNEL and route to whichever kmalloc-rnd-XX-256 the call site hashes to. snapshot all 16 rnd buckets. diff.
first run: 62 hits out of 127 freed pages. 48.8%. plausible, well above noise, not deterministic. i wrote it up and nearly moved on.
the denominator was a lie. without an explicit echo 1 > /sys/kernel/slab/kmalloc-cg-256/shrink (root-only sysfs op), the kernel left 23 of those "freed" objects parked in per-CPU sheaves and on node->partial with inuse=0. they never reached buddy, so they couldn't cross to rnd-XX-256, because they hadn't moved at all. the real rate was 62 out of 104 actual buddy-returners, not 127. the missing 38% didn't leak somewhere else. it never left.
if your "freed" set still includes objects sitting in the source cache's hot paths, the hit rate reads low and you go chasing a problem that isn't there. anyway: shrink is not required to drive empty slabs back to buddy. SLUB's __slab_free() calls discard_slab() on its own whenever nr_partial > min_partial (default min_partial = 5). drain 2048 msg_msg objects and nr_partial blows past 5 right away; the next 100+ empty slabs auto-discard to buddy. shrink only mops up the residual ~5 partials the cache keeps back as bounce buffers. for an exploit that's noise.
xcache_v6.sh, cg256_holder.py, rnd256_sprayer.py.
v7: pressure-swept, the wrong direction
with shrink-driven out, the next question was whether memory pressure helps. folklore says yes: reclaim drives shrink_slab, shrink_slab drives discard_slab, more pressure means more pages back to buddy. so i swept it. 0 MB, 100 MB, 500 MB, 1500 MB, 3000 MB of populated anon mmap, allocated between drain and spray.
| pressure | filled | freed | hit rate |
|---|---|---|---|
| 0 MB | 134 | 104 | 95.2% |
| 100 MB | 134 | 104 | 91.3% |
| 500 MB | 134 | 104 | 3.8% |
| 1500 MB | 134 | 104 | 1.9% |
| 3000 MB | 134 | 104 | 1.9% |
the rate collapsed. at 500 MB the pressure mmap had fired enough buddy allocations between drain and spray that the freed cg-256 pages got eaten by the pressure itself before add_key ever reached them. the pressure was a buddy-side competitor, not a slab-side accelerant. on this kernel and this configuration the literature advice to "apply memory pressure for cross-cache" is exactly inverted.
pressure applied before the holder phase, as a steady-state condition rather than churning during the experiment, did no harm. 100% at 2 GB pre-allocated. it's the active allocation between drain and spray specifically that scatters buddy.
xcache_v7_pressure.sh, xcache_natural.sh (the "drain only, no shrink, no pressure" measurement that confirmed natural discard returns ~82% of pages by itself).
v8: collapse the timing gap, get 100%
the fix was structural. v6 and v7 glued the phases together in shell: kill -USR1 $holder; sleep 3; sync; sleep 1; spray. that 4-second window was where everything went wrong. the rest of the system consumes buddy pages constantly, and if your spray isn't at the front of the freed-page LIFO within ~100 ms, somebody else gets there first.
v8 puts the whole sequence in one Python coprocess. bash reads FILLED / DRAINED / SPRAYED markers off the coprocess stdout via coproc and snapshots between markers, no timing penalty. inside the coprocess:
print("FILLED qids=2048")
input() # bash: snapshot cg-256 here
t0 = time.time()
for q in qids:
libc.msgctl(q, IPC_RMID, None) # drain: 6 ms total
print(f"DRAINED in {(time.time()-t0)*1000:.1f}ms")
t0 = time.time()
for i in range(4096):
libc.syscall(SYS_add_key, b"user", desc, payload, 200,
KEY_SPEC_PROCESS_KEYRING)
print(f"SPRAYED in {(time.time()-t0)*1000:.1f}ms")
input() # bash: snapshot rnd-XX-256 here
drain takes 6 ms (2048 msgctls), spray takes ~50 ms (4096 add_keys). the first add_key hits buddy within ~6 ms of the last msgctl. buddy LIFO is that hot.
five consecutive runs:
RESULT pressure=0MB filled=136 freed=104 rnd_added=247 hits=104 rate=100.0%
RESULT pressure=0MB filled=136 freed=104 rnd_added=247 hits=104 rate=100.0%
RESULT pressure=0MB filled=136 freed=105 rnd_added=246 hits=105 rate=100.0%
RESULT pressure=0MB filled=136 freed=104 rnd_added=246 hits=104 rate=100.0%
RESULT pressure=0MB filled=136 freed=104 rnd_added=246 hits=104 rate=100.0%
eight runs of an unprivileged-only variant, no sudo anywhere in the script (no shrink baseline, no chmod): seven at 100%, one at 42% from background contention. median 100%, mean ~93%.
xcache_v8.sh, xcache_v8_unpriv.sh, xcache_unified.py.
what does interfere
once 100% looked stable i tried to break it, running concurrent allocators during the drain-spray window:
| concurrent noise | hit rate |
|---|---|
| none | 100% |
| 1 GB/sec anon mmap churn | 100% |
| add_key churn @ 100/loop | 100% |
| add_key churn @ 1000/loop | 93% |
| add_key churn @ 5000/loop | 98% |
| 5-second sleep gap (bash glue) | 0-95% (variable) |
the 1 GB/sec anon mmap noise didn't move the needle. that's surprising until you remember buddy partitions by migrate-type. anon allocations come from MIGRATE_MOVABLE, slab pages live in MIGRATE_UNMOVABLE, and the two pull from different free lists. a pathological anon allocator can't compete with add_key for the same pages because they're in different pools. the only path for anon noise to hurt is secondary effects (kswapd, compaction triggering UNMOVABLE-to-MOVABLE fallback), and i never saw that fire.
slab-pool noise (concurrent add_key from another process) did degrade things a little. at 1000 keys/loop the rate dropped to 93%. at 5000/loop it climbed back to 98%, because the noise allocator's larger batches drained UNMOVABLE pcp lists faster than they refilled, which paradoxically let our spray win the buddy race more often. both conditions are still usable.
so cross-cache on hardened-distro 7.0+ isn't the probabilistic primitive the literature makes it sound like. the flakiness is shell glue, not kernel-side. collapse the timing gap and it's deterministic.
what it actually buys you
the classical Wu / dirty-cred / pipe-primitive recipe needs (a) a UAF on a vulnerable object in cache A, (b) the slab page for that object empties out, (c) discard returns it to buddy, (d) the spray cache B picks it up and you re-fault the dangle pointer into your controlled content.
steps (b) through (d) are the part that's been dodgy on hardened distros. (b) is bug-specific lifetime control. (c) is what i just demonstrated: msgctl(IPC_RMID) on a 2048-msg holder pushes nr_partial well past min_partial, ~104 slabs auto-discard, no privilege needed. (d) is be the next allocator to ask. buddy LIFO does the rest.
here's the caveat that matters. a freed cg-256 page comes back as a fresh rnd-XX-256 slab page, so the slab metadata (freelist head, randomization seed for SLAB_FREELIST_RANDOM) is reset and the contents are zeroed (INIT_ON_ALLOC=y). whatever cache-B allocations land next populate the slots, not msg_msg residue from the old cg-256 slab. so this doesn't leak msg_msg content into cache B. what it does is make a freed cache-A object's slot get reused by a cache-B alloc carrying attacker-controlled content from the cache-B allocator.
concretely: say you have a UAF dangle to a freed kmalloc-rnd-XX-256 object, its slab page has emptied and gone back to buddy, and you drain msg_msg (cg-256) then spray to allocate in rnd-XX-256 again. wrong direction. the right one is drain cg-256, then immediately trigger something that allocates in the same rnd-XX-256 cache the dangle lives in, so the dangle lands in a freshly allocated rnd-XX-256 object whose contents you don't directly control. that's still useful when the "trigger something" is you spraying the same callsite that holds the dangle. plan accordingly.
caveats
measured on resolute-desk: Ubuntu 26.04 LTS, kernel 7.0.0-15-generic, 6 GB RAM, no KASAN, all default Resolute mitigations on (CONFIG_RANDOM_KMALLOC_CACHES=y, INIT_ON_ALLOC=y, SLAB_FREELIST_RANDOM=y, SLAB_FREELIST_HARDENED=y, HARDENED_USERCOPY=y, RANDOMIZE_KSTACK_OFFSET=y, and the rest). i haven't replicated on RHEL/Fedora. the mechanics should hold, since it's the same SLUB code, but Fedora ships kernel.unprivileged_userns_clone=0 by default (doesn't matter here) and RHEL ships its own kernel patches that may add buddy-side mitigations i haven't audited.
slab_observe.ko means building and inserting an LKM, so the measurement needs root, but the cross-cache primitive itself is fully unprivileged. the LKM only proves the primitive exists. the exploit doesn't touch it; in production you trust that buddy LIFO holds.
the 100% rate assumes no other UNMOVABLE allocator steals pages between drain and spray. on a loaded multi-tenant box, expect more variance. on an idle desktop it's very tight.
size class shouldn't matter. i tested at 256 because it's a target for several known bug classes, but the mechanism is size-agnostic. cg-1k ↔ rnd-XX-1k should behave the same, modulo whatever the dominant allocator on cg-1k happens to be (pipes, on Resolute).
code
the full kit:
| file | role |
|---|---|
| slab_observe.c | introspection LKM. target / dump / probe / dump_slabs / dump_all |
| cg256_holder.py | msg_msg holder, signal-driven free |
| rnd256_sprayer.py | add_key sprayer, revoke-on-signal |
| xcache_unified.py | coprocess that does drain+spray inline |
| xcache_v8.sh / xcache_v8_unpriv.sh | working bash drivers (the second one has zero sudo in it) |
| xcache_v6.sh / xcache_v7_pressure.sh / xcache_natural.sh | earlier drivers preserved for comparison |
buddy LIFO on UNMOVABLE is the whole machine. collapse the timing gap and there's nothing else to tune.
. _SiCk · afflicted.sh