How Google Mitigates Cross-Cache Attacks?

#internals#slub#cross-cache#mitigation#slab_virtual#google_mitigation

Prerequisite:

  • This note assumes the mental model from Linux SLUB Allocator (struct kmem_cache, struct slab, the alloc/free call chains, the buddy allocator underneath).
  • Source of Google’s Hardened Linux: https://github.com/thejh/linux/tree/slub-virtual-v6.6

1. The problem

Linux SLUB Allocator already explains out how kfree()/kmem_cache_free() figure out which cache owns a pointer: virt_to_page(object) -> page_slab(page) -> slab->slab_cache.

+---------------------------+     +------------------+      +------------------+
| free ptr X (cache A)      | --> | physical page P   | --> | slab->slab_cache | |                           |     |                   |     |                  |
| virt_to_page(X)           |     |                   |     | (page_slab(P))   | +---------------------------+     +------------------+     +-------------------+

That’s exactly what a cross-cache attack abuses:

+-----------------------------------+
| 1. cache A's slab on page P       |
|    gets fully freed               |
+-----------------------------------+
                 |
                 v   P returned to the buddy allocator
+-----------------------------------+
| 2. buddy allocator later hands    |
|    P to cache B instead           |
+-----------------------------------+
                 |
                 v   slab->slab_cache on P now correctly says B
+-----------------------------------+     +-----------------------------------+
| 3. attacker still holds UAF       | --> | X -> P -> slab_cache == B         |
|    pointer X, typed as cache A    |     | (type confusion)                  |
+-----------------------------------+     +-----------------------------------+

slab->slab_cache on P now correctly says B. The kernel has no memory of A ever being there. Physical pages are a shared resource, whoever holds them last, owns them, and a stale pointer from an earlier owner becomes a live type-confusion primitive.

SLAB_VIRTUAL’s Idea: Do not use the physical page to figure out which slab an object belongs to. Instead, give each kmem_cache its own fixed range of virtual addresses. Because each cache has a separate virtual address range, we can look at an object’s address and immediately know which cache it belongs to, regardless of which physical page is currently backing that address.


2. The address space layout

arch/x86/include/asm/pgtable_64_types.h:202-219:

#define SLAB_PGD_ENTRY   _AC(-3, UL)
#define SLAB_BASE_ADDR   (SLAB_PGD_ENTRY << P4D_SHIFT)
#define SLAB_END_ADDR    (SLAB_BASE_ADDR + P4D_SIZE)

#define STRUCT_VIRTUAL_SLAB_SIZE (32 * sizeof(void *))              // 256 bytes
#define SLAB_VPAGES      ((SLAB_END_ADDR - SLAB_BASE_ADDR) / PAGE_SIZE)   // 2^27
#define SLAB_META_SIZE    ALIGN(SLAB_VPAGES * STRUCT_VIRTUAL_SLAB_SIZE, PAGE_SIZE)  // 32GB
#define SLAB_DATA_BASE_ADDR (SLAB_BASE_ADDR + SLAB_META_SIZE)

#define is_slab_virtual_addr(ptr) ((ptr) >= SLAB_DATA_BASE_ADDR && (ptr) < SLAB_END_ADDR)
#define is_slab_virtual_meta(ptr) ((ptr) >= SLAB_BASE_ADDR && (ptr) < SLAB_DATA_BASE_ADDR)

SLAB_PGD_ENTRY = -3 reserves one fixed top-level (PGD/P4D) slot — P4D_SIZE = 512GB (1UL << 39) — the same slot on every boot, every machine, never randomized.

That 512GB isn’t one flat pool of object data. The starting of it is metadata: one struct virtual_slab slot (256 bytes) per potential 4KB data page across the whole window, 2^27 pages × 256 bytes = 32GB exactly,. Only after that does the object-data region begin.

0xfffffe8000000000  +----------------------------------------+  SLAB_BASE_ADDR
                     | metadata region              (32 GB)   |
                     | struct virtual_slab records            |
                     | is_slab_virtual_meta()                 |
0xfffffe8800000000   +----------------------------------------+  SLAB_DATA_BASE_ADDR  |                                        |
                     | object data region                     |
                     | actual kmem_cache_alloc() pointers     |
                     | is_slab_virtual_addr()                 |
0xffffff0000000000   +----------------------------------------+  SLAB_END_ADDR

3. Two separate metadata systems, and why there are now two

In normal SLUB, struct slab and struct page are basically two ways of looking at the same memory. struct slab is placed directly on top of the folio’s struct page. So, conceptually:

non-virtual SLUB:

+--------------------------------------+
| struct page  ==  struct slab         |   (same bytes, two types)
+--------------------------------------+
                 |
                 v
        +----------------+
        | physical page  |
        +----------------+

That’s why folio_slab(folio) can simply treat the struct page memory as a struct slab:

(struct slab *)folio

In the normal, non-virtual SLUB design, this works because a virtual address and its physical page have a fixed relationship through the kernel’s direct map. SLAB_VIRTUAL changes this. An object’s virtual address is no longer tied to a particular physical page in the same simple way. Because of that, SLAB_VIRTUAL needs to keep the slab metadata separate from the physical page metadata.

  • The struct virtual_slab describes the slab from the virtual-address perspective, while the struct folio describes the physical memory page backing it.
  • ``` SLAB_VIRTUAL:

+—————————+ backing_folio +—————————+ | struct virtual_slab | ————–> | struct folio / struct page | | (metadata VA region, | | (kernel’s normal memmap, | | found by data-VA | | indexed by PFN) | | arithmetic, §4) | +—————————+ +—————————+ | v +—————-+ | physical page | +—————-+


The virtual address and physical page are now treated as two separate pieces of information. Two genuinely separate objects now, so an explicit pointer has to bridge them. 

`struct virtual_slab` itself (`mm/slab.h:130-146`):
```c
struct virtual_slab {
	struct slab slab;
	struct virtual_slab *compound_slab_head; /* multi-page slabs: points at page-0's entry */
	unsigned long align_mask;
};

4. Allocation call chain

mm/slub.c
kmem_cache_alloc(s, ...)
  -> ... -> new_slab(s, ...) -> allocate_slab() -> alloc_slab_page(s, ...)     
       if (slab_virtual_enabled())
         -> alloc_slab_page_virtual(s, ...)                                   
              -> get_free_slab(s, oo, ..., freed_slabs)                        
                   -> (reuse hit)  pop s->virtual.freed_slabs{,_min}           
                   -> (reuse miss) alloc_slab_meta(order, ...)                 
              -> alloc_pages(gfp_flags | __GFP_ZERO, order)                    [buddy allocator  same call SLUB Allocator uses]
              -> folio_set_slab(folio, slab)
              -> map data pages: slub_get_ptep() + set_pte_safe() per page
       else //conventional path
         -> alloc_pages(flags, order) 

alloc_slab_meta() (mm/slub.c) runs only on a reuse miss and claims a brand-new VA range. slub_addr_current is a one global cursor for Virtual SLUB Memory Range, shared by every cache, that only moves forward. Thais cursor ensures no VA ranges can overlap between caches. Recycling only happens later, and only back onto the same cache’s freed_slabs

SLUB data VA space, over time:

+-----------+-----------+-----------+---------------------
| cache A   | cache B   | cache A   |    unclaimed VA
| range 1   | range 1   | range 2   |
+-----------+-----------+-----------+---------------------
                                     ^
                              slub_addr_current
                            (only ever moves right)

The metadata slot for a given data VA is found by direct index arithmetic (virt_to_slab_virtual_raw(), mm/slab.h), same as the kernel’s own struct page array, just applied to virtual pages instead of physical frames. For a multi-page slab, only page 0’s metadata slot is the real head and other slabs point back to first slot.

order-2 slab (4 pages):

+-------------+-------------+-------------+-------------+
| meta slot 0 | meta slot 1 | meta slot 2 | meta slot 3 |
| (real head) | compound_   | compound_   | compound_   |
|             | slab_head=0 | slab_head=0 | slab_head=0 |
+-------------+-------------+-------------+-------------+
       ^_____________|_____________|_____________|
        every other slot's compound_slab_head points back at slot 0
  • **slub_get_ptep()** (mm/slub.c) walks through the page tables — PGD → P4D → PUD → PMD → PTE — and creates any missing page-table levels along the way.It is implemented separately from the kernel’s usual p4d_alloc() / pud_alloc() helpers because those helpers use GFP_KERNEL. SLUB instead needs to use the exact gfp_flags provided by the caller. Importantly, slub_get_ptep() only makes sure that a PTE entry exists and is safe to use. It does not map the PTE to a physical page. The caller does that separately.

  • get_free_slab() pops the cache’s freed_slabs list if non-empty (VIRTUAL_SLAB_REUSED), otherwise calls alloc_slab_meta() for a fresh range (VIRTUAL_SLAB_NEW). Note that it only pulls from that cache’s own list

  • **alloc_slab_page_virtual()** first allocates the physical folio using:

alloc_pages(gfp_flags | __GFP_ZERO, order)
  • It then maps each physical page into the slab’s virtual address range using slub_get_ptep() and set_pte_safe().

  • The __GFP_ZERO is intentional. The code always gives the slab zero-filled pages, so if an old slab is reused after a UAF, an attacker cannot easily get their own data placed in those pages. So this provides an additional UAF protection on top of SLAB_VIRTUAL’s virtual-address isolation. Its non-virtual counterpart, alloc_slab_page(), does the same basic physical-page allocation.

  • alloc_pages() call — the buddy allocator itself is completely unmodified

  • Finally, the call chain stitches together the slab metadata, its virtual address range, and the physical backing folio, and returns the newly allocated slab to the caller.


5. Free call chain

kmem_cache_free(s, x)
  -> s = cache_from_obj(s, x)                                        
  -> slab_free(s, virt_to_slab(x), x, ...)                            
       -> do_slab_free() -> __slab_free()                             
            -> (slab now fully empty) discard_slab() -> free_slab() -> __free_slab()
                 if (slab_virtual_enabled())
                   -> __free_slab_virtual(s, slab)
                        -> ptep_clear() for every page in the slab   
                        -> queue_slab_tlb_flush(slab)
                             -> slub_tlbflush_worker() (kthread, deferred)
                                  -> THEN __free_pages(folio, order)   
                                  -> THEN freed slub is added to cache free list          
                 else
                   -> normal folio-based free                          

__free_slab_virtual() is minimal

static void __free_slab_virtual(struct kmem_cache *s, struct virtual_slab *slab)
{
	for (i = 0; i < pages; i++) {
		pte_t *ptep = slub_get_ptep(slab_base + i * PAGE_SIZE, 0, false);
		ptep_clear(&init_mm, addr, ptep);          // unmap — and that's it
	}
	mm_account_reclaimed_pages(pages);
	unaccount_slab(&slab->slab, order, s);
	/*
	 * We might not be able to a TLB flush here (e.g. hardware interrupt
	 * handlers) so instead we give the slab to the TLB flusher thread
	 * which will flush the TLB for us and only then free the physical
	 * memory.
	 */
	queue_slab_tlb_flush(slab);
}
  • Only the PTE is cleared here. The physical page and the virtual address range are not freed immediately; they are released later by a deferred worker.
  • This is required for safety. kmem_cache_free() can be called from almost any context, including interrupt handlers or while holding a spinlock with interrupts disabled.
  • flush_tlb_kernel_range() can send an IPI to other CPUs and wait for them to respond. Doing this directly inside kmem_cache_free() could cause a deadlock.

Instead, the expensive work is deferred to slub_kworker, a dedicated kernel worker thread. A worker thread runs in a normal process context where it is safe to wait for other CPUs.


6. Why this actually stops cross-cache attacks

  • The slab is determined from the object’s virtual address, not from the cache supplied by the caller.
  • Each cache has its own dedicated virtual address range. Therefore, an object allocated from cache A always has an address inside cache A’s range. Even if an attacker tries to free it using cache B, virt_to_slab() uses the object’s address to find the correct slab and its associated cache.
  • So the attacker cannot make an object from cache A be treated as an object from cache B simply by supplying cache B.

Resolved purely from addr’s own bits ,index arithmetic. So zero dependency on whatever s the caller passed.