Dirty Page

#pagespray#cross-cache
Vuln Classesuafdouble-freeoob
Padding Slabsmsg_msgiovecuser_key_payload
Requiresuaf-or-double-free-or-oob-primitivepage-spraying-callsite-access
Givesheap-groomingkaslr-leakcontrol-flow-hijackroot-privileges

This section briefs about the dirty page, the novel technique to exploit UAF. This section simplifies the detailed concepts from https://www.kylebot.net/papers/dirtypage.pdf.


1. The DIRTY PAGE Model

Three object roles, all chosen independently of the actual vulnerability:

  • Padding objects — cheap, size-controllable objects you can spray fast to fill a slab. Paper’s go-to picks: msg_msg, iovec, user_key_payload.
  • Vulnerable object — the object that actually triggers the bug (UAF / Double-Free / OOB).
  • Victim object — the object you want corrupted at the end. This is what you’ll be reading/writing once the page is reclaimed.

Walking through it for a Double Free bug:

  1. Occupy the slab. Spray padding objects, then allocate the vulnerable object and victim object back-to-back, then spray padding again — so S ends up with 1 vulnerable + 1 victim + N-2 padding objects, with vulnerable/victim sharing page P.
  2. Fake out the allocator’s free count. Free all N-2 padding objects, then trigger the double-free bug so the vulnerable object gets freed twice. The slab allocator now counts N deallocations total — even though the victim object was never actually freed and you still hold a live reference to it.
  3. Slab gets discarded. Because the allocator thinks all N objects in S are free, it reclaims the whole slab and returns its pages — including P — to the page allocator. The victim object’s memory is now a page-level dangling reference: same idea as a UAF, just one abstraction layer up.
  4. Reclaim the page from userspace. Trigger a page-spraying callsite (§2) repeatedly until it reclaims P and lets you write raw page-level data into it. Whatever lands in P overwrites the victim object’s fields directly. In the paper’s own walkthrough, the victim is a pipe_buffer and the attacker ends up controlling pipe_buffer->ops.

For a UAF bug this collapses nicely: the vulnerable object is the victim object, so there’s no need to fake a free count — just free everything in the slab naturally and the freed UAF object is your page-level dangling reference.


2. Why This Works — Page-Spraying Callsites

Step 4 needs a kernel codepath that (a) allocates raw pages directly (not through SLUB) and (b) lets userspace put arbitrary data into those pages. The paper finds these split into two call modes — and both exist for legitimate performance reasons, which is exactly why this root cause can’t just be patched out.

Copy-Write calls — allocate a raw page, then copy user data straight into it.

  • Raw page-level buffer — pipe subsystem. pipe_write() allocates a page and copies directly into it:
struct pipe_buffer {
    struct page *page;
    unsigned int offset, len;
    const struct pipe_buf_operations *ops;
    ...
};

static ssize_t pipe_write(..., struct iov_iter *from) {
    for (;;) {
        if (!page) {
            page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT);
            ...
        }
        buf->page = page;
        copied = copy_page_from_iter(page, 0, PAGE_SIZE, from);
    }
}

3. Requirements

  • A UAF, Double-Free, or OOB bug
  • Access to at least one page-spraying callsite from userspace to actually reclaim and write the freed page.

4. Known Page-Spraying Callsites

21 callsites identified via an LLVM backward-callgraph analyzer and then validated with Syzkaller.

Callsite Syscall Notes
pipe_write write classic pipe page
packet_snd / packet_mmap / packet_set_ring sendmsg / mmap / setsockopt AF_PACKET, used in the CVE-2022-2585 case study below
rds_message_copy_from_user sendmsg  
unix_dgram_sendmsg / unix_stream_sendmsg sendmsg size-restricted
netlink_sendmsg sendmsg memory-area restricted
tcp_send_rcvq / tcp_send_rcvq(inet6) sendto  
tun_build_skb / tun_alloc_skb / tap_alloc_skb write needs /dev/net/tun or tap device access
fuse_do_ioctl ioctl size-restricted
io_uring_mmap mmap  
array_map_mmap / ringbuf_map_mmap mmap eBPF, size-restricted
aead_sendmsg / skcipher_sendmsg sendmsg AF_ALG crypto
mptcp_sendmsg sendmsg  
xsk_mmap mmap needs XDP setup

5. Why Page Spray Is More Stable Than Object Spray

Object spray has to win a race: the freed slot sits in whatever slab is currently active, and the kernel keeps making unrelated allocations into that same active slab the whole time. Page Spray sidesteps this — you spray padding until the slab holding your vulnerable/victim objects retires and a fresh slab becomes active, so all of the kernel’s background allocation noise goes into the new active slab while your target slab sits untouched. The only requirement left is that nothing else allocates into that specific slab before you finish freeing all N objects in it.

Across 15 real CVEs (UAF/DF/OOB mix), Page Spray successfully exploited 14 — including a mobile CVE and two cross-cache cases. The one failure (CVE-2016-10150) is a KVM UAF where alloc-and-free both happen inside a single ioctl() call.


6. Variants

  • UAF shortcut — victim object = vulnerable object, no double-free needed.
  • Direct cred overwrite — build a fake cred struct in userspace, spray it as page-level data during reclamation to overwrite the real cred in memory.
  • Page Table Overwrite - Check PMD Overwrite]]

8. Mitigation

The root cause (raw-page copy-write + zero-copy remap) is core to legitimate kernel I/O performance, so it can’t be removed outright. The general principle: stop a freed SLUB page from ever being handed back through direct page-level allocation. There are two different ways to enforce that:

GFP_DMA (paper’s own patch) — physical zone isolation.

Today, SLUB and Page Spray allocations both come out of the same pool:

ZONE_NORMAL
     |
     +---- SLUB pages
     |
     +---- alloc_pages() (Page Spray)

Free a SLUB page → it goes to the buddy allocator → Page Spray’s alloc_pages(GFP_KERNEL) can grab it straight back. The fix is a one-line change at every Page Spray callsite: swap GFP_KERNEL for GFP_DMA, forcing those allocations into a separate physical zone:

ZONE_NORMAL              ZONE_DMA
+----------------+       +----------------+
| SLUB pages     |       | Page Spray     |
+----------------+       +----------------+

But ZONE_DMA is small and reserved for real DMA use, not meant for production.

Slab Virtual (Google) — this is not the same idea, even though it sounds like it at first.

The obvious misunderstanding would be “give slabs their own reserved chunk of physical RAM” , same trick as GFP_DMA, different name. However, It isn’t the same. A virtual address by itself isolates nothing — multiple virtual addresses can legally point at the same physical page, that’s literally how shared memory works:

kernel VA A ----+
                 v
            physical page 5000
                 ^
kernel VA B ----+

So if Slab Virtual were only “SLUB gets its own virtual address range,” an attacker could still reach that same physical page through an ordinary-looking mapping and the isolation would do nothing. The address range isn’t what protects it.

What actually protects it is ownership: once a physical page is handed to SLUB under this scheme, it’s barred from ever transitioning back into the buddy allocator’s general free-page pool — not “reachable only through a special address,” but structurally prevented from being handed to alloc_pages() for anything else, for the rest of that page’s life:

Normal Linux:                      Slab Virtual:
SLUB page                          SLUB page
  |                                  |
  free                               free
  |                                  |
  v                                  v
buddy allocator                    stays under SLUB's own
  |                                 virtual-memory management
  v                                 (never re-enters buddy)
alloc_pages()  <-- Page Spray       X  Page Spray can't reach it
  gets it here

This is exactly why the paper calls out patching virt_to_phys() specifically — the kernel has to enforce that slab virtual addresses always resolve through SLUB’s own controlled mapping, so the relationship is a permanent, kernel-enforced rule rather than just a naming convention that happens to be true today.

“Can’t I just craft a different virtual address mapping to the same physical page?” No — not without a second kernel bug. Aliasing a physical page from userspace requires the kernel to have granted that mapping in the first place (that’s how legitimate shared memory works — the kernel decided to create it). Slab Virtual’s whole point is that the kernel never grants that mapping to anyone outside SLUB’s own bookkeeping, so there’s no mapping for an attacker to reuse.

  GFP_DMA Slab Virtual
Isolation layer Physical memory zone Page ownership, enforced via virt_to_phys()
Mechanism Different ZONE_* for allocation Freed slab pages never re-enter the buddy allocator
Analogy Separate room in the warehouse SLUB gets its own entrance and its inventory never leaves the building
Status PoC, ~0.02% overhead Out-of-tree (Google), ~4% overhead, breaks KFENCE/KASAN

9. References

  • Paper: Ziyi Guo, Dang K Le, Zhenpeng Lin, Kyle Zeng, Ruoyu Wang, Tiffany Bao, Yan Shoshitaishvili, Adam Doupé, Xinyu Xing. “Take a Step Further: Understanding Page Spray in Linux Kernel Exploitation.” USENIX Security 2024. https://www.kylebot.net/papers/dirtypage.pdf
  • Prior (obsolete on modern kernels) physmap-based page attack, cited as prior work: Xu et al., cross-referenced in §1 of the paper.
  • DirtyCred (related privileged-object exploitation technique, cited as related work): [30] in the paper’s references.