[Paper Reading] Cross-Cache Attacks for the Linux Kernel via PCP Massaging
Here are my notes on a new kernel heap attack technique presented at NDSS 2026.
1. Background
SLUB Allocator
The default memory allocator used in the kernel. Allocation goes through kmem_cache_alloc() and kmalloc(), and deallocation through kfree().
SLAB
A structure holding individual objects. New objects are allocated and freed through a freelist.
A slab itself is allocated in units of pages, and is managed by the page frame allocator.
kmem_cache
The structure that governs the SLUB allocator as a whole. Caches are divided into dedicated caches, which can only allocate one specific object type (e.g. task_struct), and generic caches, which can allocate arbitrary objects (e.g. kmalloc-xx).
Internally, slabs are managed through kmem_cache_cpu and kmem_cache_node.
kmem_cache_cpu
The per-CPU structure. The main slab is tracked separately, while the remaining slabs are stored as a list in partial.
kmem_cache_node
The per-NUMA structure. Slabs are stored in a partial list.
Detailed behavior
Selecting the slab to allocate from proceeds in four stages. When a stage fails, the allocator falls through to the next one, with each successive stage being further down the slow path.
1. Select kmem_cache_cpu->slab.
2. Select a slab from kmem_cache_cpu->partial.
3. Select a slab from kmem_cache_node->partial.
4. Allocate a new slab from the page frame allocator and select it.
The object is then allocated from the selected slab.
Page Frame Allocator
Also known as the Buddy Allocator, this is the allocator that manages physical memory.
Memory is allocated in units of pages, and the size is determined by an order. Orders start at 0, and an order-k allocation has a size of 2^k * page_size (4096 bytes).
Each order is managed through a freelist called free_area. If a request for an order-k page arrives while the corresponding freelist is empty, the allocator takes a page from the freelist of the next higher order (k + 1), splits it in half, and returns the first half. The first half is called the left-buddy and the second half the right-buddy.
I omit the discussion of zones and migration types.
Per-CPU-Pageset (PCP) lists
The Buddy Allocator likewise uses per-CPU structures for performance, and those are the PCP lists. With PCP lists in place, the Buddy Allocator behaves as follows.
When a request for an order-k page arrives:
1. Check the order-k PCP list. If a page is available, allocate it (rmqueue_pcplist()) and return.
2. If the list is empty, obtain a batch of order-k pages from the Buddy Allocator (rmqueue_bulk()) and add them to the order-k PCP list.
3. Allocate a page from the order-k PCP list and return.
To summarize, the three layers connect as follows:
1. Allocate at the SLUB allocator level.
2. If that fails, allocate at the PCP list level.
3. If that fails, pull a batch from the Buddy Allocator to refill the PCP list, then allocate from the PCP list.
2. Approach
Main Idea
A. Using a timing side-channel, it should be possible to distinguish whether rmqueue_pcplist() (the slower path) or rmqueue_bulk() (the slowest path) was invoked.
B. If that distinction is possible, it can be used to make physical memory addresses contiguous across different caches, making OOB cross-cache attacks reliable.
A. Timing side-channel
The figure above shows the time taken for a timing object when each of the following succeeds: SLUB allocation (fast allocation), rmqueue_pcplist() (slower allocation), and rmqueue_bulk() (slowest allocation).
As the figure shows, there is a distinguishable timing gap between rmqueue_pcplist() and rmqueue_bulk(). Exploiting this makes it possible to carry out heap massaging with high probability of success.
The timing side-channel makes use of two kinds of objects: timing objects and persistent objects. A timing object is one that is freed immediately after allocation; this property allows the SLUB state to be inferred. Based on the inferred information, persistent objects are allocated permanently to drive the allocator into a desired new state. I will refer to this overall procedure as probe and drain.
B. Cross-cache Massaging (PCPLOST)
I split the explanation into three cases.
Terminology:
n_v: the order of the slab that the vulnerable object belongs to
n_t: the order of the slab that the target object belongs to
N: objects per slab
i) n_v == n_t
I use n_v == 0, n_t == 0 as the example.
1-1. Run probe and drain against the vulnerable cache, draining its main slab and partial slabs. As allocations continue, the PCP list is drained as well, which eventually causes rmqueue_bulk() to be invoked.
1-2. An order-1 page is then split and stored as a batch in the PCP list (pages (1), (2), (3) in the figure), and rmqueue_bulk() returns page (1). The key point here is that (1), (2), and (3) are physically contiguous pages.
2-1. Run probe and drain against the vulnerable cache again, detecting when a new page is taken from the PCP list (page (2)). Allocate N - 1 objects, then allocate one vulnerable object; the vulnerable object lands at the end of page (2).
2-2. Run probe and drain against the target cache, detecting when a new page is taken from the PCP list (page (3)). Then fill page (3) with target objects. Since pages (2) and (3) were adjacent in memory, the target object successfully becomes the OOB target of the vulnerable object.
3. The layout is now ready for an OOB exploit.
Note
A base value of 512 is used (adjusted as base >> page_order) to drain the PCP list before probe and drain, so that splits occur at higher orders. This preemptively rules out the case where "the Buddy Allocator's free_area already holds order-0 pages in batch-size quantity," making higher-order splits more likely to happen.
Of course, since the batch size shrinks as the page order grows, the number of pages to drain is set to a correspondingly smaller value.
ii) n_v > n_t
I use n_v == 1, n_t == 0 as the example.
1. Run probe and drain against the vulnerable cache, draining the order-1 PCP list.
2. Detect the rmqueue_bulk() call. Pages (1), (2), and (3) enter the order-1 PCP list, and page (1) is returned. Here, page (3) is the left-buddy produced by splitting an order-2 page. The right-buddy is moved to the order-1 free_area.
3. Then, through N * batch - 1 spray allocations plus 1 vulnerable object allocation, place the vulnerable object at the end of page (3).
4. Run probe and drain against the target cache, draining the order-0 PCP list.
5. Detect the rmqueue_bulk() call. Pages (1) through (5) enter the order-0 PCP list, and page (1) is returned. The right-buddy that was split off in step 2 (page (2)) then enters the order-0 PCP list.
6. With further target object allocations, a target object lands in page (2), which is memory adjacent to the vulnerable object.
7. The layout is now ready for an OOB exploit.
What about | n_v - n_t | > 1?
Massaging works the same way by detecting the target object's rmqueue_bulk() calls multiple times.
iii) n_v < n_t
I use n_v == 0, n_t == 1 as the example.
1. Run probe and drain against the vulnerable cache to drain the PCP list. In this case, all PCP lists up to n_t must be drained. Only then will the subsequent rmqueue_bulk() split a page of order higher than n_t, allowing the same method as before to apply.
2. Detect the rmqueue_bulk() call; an order-2 page split occurs. Pages (1) through (3) enter the order-0 PCP list, and page (1) is returned. Here, page (3) is the left-buddy with respect to the order-2 split.
3. Through N * batch - 1 spray allocations plus 1 vulnerable object allocation, drain the order-0 PCP list and place the vulnerable object at the end of page (3).
4. Run probe and drain against the target cache, draining the order-1 PCP list.
5. Detect the rmqueue_bulk() call. The page (1) allocated here is the right-buddy from step 2. In other words, page (3) allocated in step 2 and the current page (1) are adjacent.
6. The layout is now ready for an OOB exploit.
What about | n_t - n_v | > 1?
As in case ii), this should generalize in the same way. However, while the attacker can detect rmqueue_bulk() calls, they have no information on how many such calls need to occur. Continuing to allocate vulnerable objects in this state risks triggering an unintended crash and failing the attack.
That said, the vulnerable and target objects are the attacker's to choose, so this case can be avoided by picking a different object.
C. Minimizing false positives
A single timing sample falling within a given range is not taken as proof that an event occurred. Instead, the attacker performs enough allocations to exhaust the region that would have been supplied had the event actually occurred, then checks whether the subsequent timing sample matches expectations. If it does, the allocation event (rmqueue_pcplist() / rmqueue_bulk()) is judged with high probability to have actually occurred.
This approach reduces false positives at the cost of discarding some valid hits, but aside from the slight memory overhead from the extra allocations, it is not a significant loss from the massaging perspective.
D. Pivoting temporal vulnerabilities
For vulnerability classes such as UAF and DF, pivoting allows them to be converted into OOB and exploited.
i) UAF
Pivoting from UAF to OOB proceeds as follows:
-
Make the UAF-vulnerable page and the target cache page adjacent.
-
Allocate the vulnerable object and immediately free it, creating a dangling pointer.
-
Allocate a pivot object in the same slot. Depending on the pivot type, an in-cache or cross-cache primitive may be used.
-
Corrupt the pivot object's length field to obtain an OOB write primitive.
-
If the pivot object happened to sit at the end of the slab, the target cache can be attacked.
ii) DF
Similar to UAF, with one additional step, giving a DF → UAF → OOB pivot chain.
3. Evaluation
RQ1. How reliable is PCPLOST?
Results are broken down by adjacency, adjacency + object alignment, whether CPU pinning is used, and whether the kernel is idle.
For n_v == n_t, the success rate exceeds 90% across the board.
For n_v > n_t, only the cases without CPU pinning drop, to the 40–50% range. This means that while the attacking process was descheduled, other processes accessed the free_area and interfered with the massaging procedure.
For n_v < n_t, the success rate comes out very low. As noted earlier, the main reason is that the attacker has no advance information on how many rmqueue_bulk() invocations need to occur from the vulnerable object's side.
To summarize: whether CPU pinning is used, and whether one ends up in the n_v < n_t case, both depend on the attacker's skill. Assuming a competent attacker, PCPLOST can be regarded as a highly reliable attack technique with a success rate above 90%.
RQ2. Can UAF and DF be pivoted successfully?
Testing was done with a custom kernel module containing a UAF vulnerability.
i) UAF → OOB
struct msg_msg, which lives in the kmalloc-cg-* caches, was chosen as the pivot object, and struct shm_file_data in kmalloc-32 as the target object.
The pivoting proceeds as follows:
1. Corrupt the struct msg_msg->m_ts field to inflate the message size.
2. Based on the inflated size, a subsequent memcpy() call produces an OOB.
msg_msg is used as the example, but any object with a manipulable length field followed by a memcpy()-based data copy primitive can serve as a pivot object. In other words, whether pivoting succeeds depends entirely on the class of vulnerability at hand.
ii) DF → OOB
As mentioned earlier, once the additional DF → UAF pivot is performed, a successful exploit follows just as in case i).
RQ3. Can cross-cache defenses be bypassed?
Two mitigations are discussed: SLAB_VIRTUAL and SLAB_FREELIST_RANDOM.
i) SLAB_VIRTUAL
A mitigation that maps slabs into a separate virtual address region rather than the physmap area, and ensures those virtual addresses can never be used by a different kmem_cache, thereby blocking cross-cache attacks.
a) Spatial vulnerabilities
The table shows success rates above 90% for both same-order and cross-order cases.
SLAB_VIRTUAL blocks cross-cache slab reuse, but it has the drawback that addresses allocated from different kmem_caches remain virtually contiguous. This actually makes adjacent addresses easier to predict, so it fails to defend against OOB vulnerabilities.
Note
After discussing with the SLAB_VIRTUAL authors, the paper's authors confirmed the existence of a version with guard pages (SLAB_VIRTUAL_GP). That version successfully blocks linear OOB (contiguous writes), but has the drawback that it does not block non-linear OOB (offset-based writes).
b) Temporal vulnerabilities
Because a) makes virtually contiguous allocation across caches possible, the UAF / DF → OOB pivoting described earlier proceeds identically. However, due to the nature of SLAB_VIRTUAL, an additional constraint arises: the vulnerable object's cache and the pivot object's cache must be the same.
ii) SLAB_FREELIST_RANDOM
SLAB_FREELIST_RANDOM randomizes the order of the freelist within a slab, so when a vulnerable object is allocated, its position within the slab is unknown. The earlier approach of allocating N - 1 spray objects followed by the vulnerable object no longer guarantees that the vulnerable object lands in the last slot of the slab.
For OOB reads this is not a problem — simply repeat until it works. For OOB writes, however, a failure can cause a crash, so a different bypass is needed.
As the figure above shows, no matter where the vulnerable object sits within the slab, a target object is guaranteed to exist at a point SLAB_SIZE bytes past the end of that object. The only requirement is that the target slab be larger than SLAB_SIZE, which is guaranteed in two cases: n_v == n_t and n_t > n_v. Since the vulnerable object's position no longer needs to be known, SLAB_FREELIST_RANDOM is neutralized.
The payload is constructed by repeating a corruption pattern. This makes whichever target object gets overwritten a valid trigger.
Since the vulnerability itself only needs to be triggered once, unexpected crashes from incorrectly corrupting a vulnerable object can be avoided.
When n_t < n_v
The same method applies, but the write may extend past the boundary of the target slab, introducing an additional chance of a crash.
This can be resolved by reducing the write size to the size of the target slab. That in turn creates cases where no target object is overwritten at all; for those cases, simply repeating the same attack resolves it.
When the number of writable bytes is limited
In general, an attacker may only be able to write a small number of bytes. In that case, the limited overwrite is combined with the method above and PCPLOST itself is repeated. Since PCPLOST reliably produces the cross-cache layout, the only cost is a longer exploit runtime, and a stable crash-free exploit remains achievable.
The advantage of a vulnerability that can write zeroes
For some target objects, whether the vulnerability can write zeroes becomes an important requirement. Nulling out pointers increases the chances of corrupting the object reliably while avoiding a crash.
RQ4. Is it applicable to real-world vulnerabilities?
The technique was evaluated against 6 real-world vulnerabilities.
As described above, exploitation was possible for OOB vulnerabilities, and for UAF vulnerabilities pivoting allowed successful exploitation as well.
The SLAB_FREELIST_RANDOM, SLAB_VIRTUAL, and SLAB_FREELIST_HARDENED mitigations were also bypassed — the combination enabled differs per CVE, but each of the three has at least one bypass case.
4. Summary
I would frame the value of the attack technique the paper proposes along three axes.
Novelty
The work leverages a timing side-channel at the PCP list level (distinguishing the slower path from the slowest path) that prior related work (PSPRAY, SLUBStick, …) had not attended to, and chains it into side-channel → contiguous memory allocation → OOB exploit primitive.
Reliability
Beyond the reliability of PCPLOST itself (over 90% success rate), the paper explains how it combines with the various crash-free techniques discussed later (particularly the SLAB_FREELIST_RANDOM section) to yield high reliability in real-world settings.
Pivoting
Rather than stopping at a plain OOB exploit primitive, the additional UAF / DF → OOB pivoting broadens the technique's coverage, and this is what allows the SLAB_VIRTUAL mitigation to be neutralized even starting from a UAF bug.
Reference
Original paper: https://www.ndss-symposium.org/wp-content/uploads/2026-f862-paper.pdf