Post

2. [Bad epoll CVE-2026-46242] Heap! — The SLUB Allocator and Cross-Cache Attack

2. [Bad epoll CVE-2026-46242] Heap! — The SLUB Allocator and Cross-Cache Attack

GOAL

Understand, from a heap perspective, why the 8-byte write-after-free obtained in Part 1 cannot be exploited with same-cache reuse alone, and how it leads — through a cross-cache attack — to taking control of a struct file.


Series Introduction

This is the second part of a three-part series analyzing CVE-2026-46242 (Bad Epoll).

  • Part 1 - The Root of the Bug: epoll Close-vs-Close Race and UAF
  • Part 2 (this article) - Heap! : The SLUB Allocator and Cross-Cache
  • Part 3 - Opening the Kernel: From Arbitrary Read to ROP and Root

0. Recap of Part 1 — What We Have and What We Need

The conclusion of Part 1 was this:

An 8-byte write-after-free into a freed struct eventpoll slot.

More precisely, when the close-vs-close race that simultaneously closes ep_waiter / ep_target succeeds, what we obtain is this single line:

An 8-byte write of 0 at offset 160 (refs.first) of a freed struct eventpoll object in the kmalloc-192 size class.

So how do we get from here to root?

The answer lies in the fact that we can design “what this write destroys.” And that design depends entirely on how the kernel hands out and recycles heap memory. So in this part we set the exploit aside for a moment and build up from the SLUB allocator.

Here is the journey of this part:

1
2
3
4
5
6
7
8-byte write-0 (kmalloc-192)
      │  ① SLUB basics          → how a slot gets reused
      │  ② same-cache reclaim   → refill the freed slot with our object
      │  ③ dangling struct file → the real prize the write-0 leaves behind
      │  ④ cross-cache          → seize the whole memory the struct file points to
      ▼
a struct file the attacker controls byte-by-byte

A quick note before we start: the term Buddy will show up in this part. The buddy allocator is the memory allocator that manages physical memory pages. If a page is the actual unit of memory, the buddy allocator is the manager that hands out those pages and takes them back.


1. SLUB / slab Allocator Basics — How the Kernel Hands Out Memory

Like userspace malloc, the kernel also frequently allocates and frees small pieces of memory. The SLUB allocator is what handles this.

kmalloc-N caches and slabs

The kernel prepares caches by size in advance: kmalloc-8, kmalloc-16, kmalloc-32, …, kmalloc-192, kmalloc-256, and so on.

When you call kmalloc(200, …), it hands you a slot from kmalloc-256, the nearest class that is ≥ 200.

Each cache manages memory in units called slabs. A slab is, simply put, an array of equal-sized objects laid out at regular intervals on one (or several) physical page(s).

1
2
3
4
5
slab of the kmalloc-192 cache (one page)
┌──────┬──────┬──────┬──────┬──────┬──────┐
│ slot │ slot │ slot │ slot │ slot │ ...  │   each slot = 192 bytes
└──────┴──────┴──────┴──────┴──────┴──────┘
   0     192    384    576    768   ...

One object goes into one slot. For example, struct eventpoll (a size that fits kmalloc-192) occupies one slot of a kmalloc-192 slab.

freelist — a linked list of empty slots

To track which slots are currently free, SLUB uses a freelist: it links the empty slots together into a linked list. The interesting part is that this “next free slot pointer” is stored inside the empty slot’s own space. So a freed object often contains the next freelist pointer inside it. (This becomes important later, in the UAF.)

per-CPU active slab, partial / full

For performance, SLUB keeps one slab per CPU that it currently allocates from (the “active slab”). When a kmalloc comes in on that CPU, it lock-lessly pops a slot from this active slab’s freelist. Slabs are classified by state:

  • full: all slots in use
  • partial: only some in use (a reuse candidate)
  • empty: all free → this page can be returned to the buddy allocator

The allocation / free cycle

To sum up, the flow is:

1
2
3
kmalloc()  → pop one free slot from the freelist → use as object
kfree(obj) → push that slot back onto the freelist (free)
kmalloc()  → pop that just-pushed slot again → reuse!  ★

(pop: take the one on top) (i.e., free → push onto freelist → the very next allocation pops it → the same address is reused)

The line marked ★ is the seed of this entire part: the slot you just freed is very likely grabbed again immediately by the next allocation in the same cache.

kmalloc-192 slab and freelist

Dedicated caches vs. general-purpose (kmalloc-*)

One more thing. Not every object lives in a kmalloc-* (general-purpose) cache. For certain frequently used structures, the kernel builds a dedicated cache (a dedicated kmem_cache).

  • struct eventpoll → general-purpose kmalloc-192
  • struct file → dedicated filp cache (filp_cachep)

💡 This fact — that eventpoll is general-purpose while file is dedicated, i.e., the two live in different caches — is the decisive reason cross-cache shows up later. For now, just remember this one line.

  • General-purpose: a cache shared by many kinds of small kernel objects grouped by size. (kmalloc-192 can hold various 192-byte objects.)
  • Dedicated: a cache built for one specific structure. (the filp cache is dedicated space for struct file.)

2. Turning a UAF into a Weapon — reclaim & spray

What is a dangling pointer?

A pointer that still points to memory that has already been freed.

1
2
3
4
struct eventpoll *p = kmalloc(...);   // p points to a valid object
kfree(p);                              // the memory is returned, but
                                       // p still holds that address → dangling!
p->refs = ...;                         // access to freed memory → UAF

The essence of Use-After-Free is exactly this: touching freed memory again through a dangling pointer.

Why this becomes a weapon — reclaim & spray

A dangling pointer’s power shows when the attacker refills that freed slot with another object.

The slot you just freed gets grabbed again by the next allocation in the same cache. So right after freeing, the attacker mass-allocates (sprays) useful objects of the same size to overwrite that slot with an object of their choosing. This is called reclaim or spray.

1
2
3
4
[freed slot]   ← the dangling pointer points here
      │  attacker sprays a flood of objects into the same cache
      ▼
[an object the attacker knows]  ← now the dangling pointer touches "something meaningful"

When this reuse happens within the same cache, it’s called same-cache reclaim.

bad epoll’s first move — setting the stage with same-cache reclaim

bad epoll uses this same-cache reclaim first. Right after the race frees epoll_race_target (kmalloc-192), it immediately creates a new epoll to take that slot back.

epoll_uaf_target = epoll_create1() reclaims the just-freed kmalloc-192 slot → same-cache reclaim (epoll_race_targetepoll_uaf_target)

This way, Part 1’s 8-byte write now zeroes epoll_uaf_target’s refs.first (offset 160). And this epoll_uaf_target has ep_uaf_waiter watching it, so it carries an epitem representing that relationship.

Here’s the key. Zeroing refs.first cuts the front link of the waiter list, and that epitem’s epi->ffd.file — the struct file pointer to the watched target file — is left uncleaned, remaining dangling.

Now we can overwrite that struct file slot through this dangling pointer. But here we hit a wall.


3. Why Cross-Cache Is Needed in bad epoll

What we ultimately want to seize is struct file. But we cannot simply reuse the same-cache spray from Section 2. There are two reasons.

1. eventpoll and struct file live in different caches.

The struct file our dangling pointer points to lives in the dedicated filp cache. Meanwhile the struct eventpoll we freely allocated and freed earlier lives in the kmalloc-192 cache.

1
2
3
4
5
kmalloc-192
└─ eventpoll

filp
└─ struct file

They use different caches. So no matter how many objects we spray into kmalloc-192, those objects cannot take a struct file slot in the filp cache.

2. The filp cache is SLAB_TYPESAFE_BY_RCU.

The filp cache carries a special flag (SLAB_TYPESAFE_BY_RCU). This cache guarantees that even when a slot is freed, it is only ever reused as a struct file type. In other words, you cannot shove an arbitrary attacker-controlled byte blob into that slot; same-cache reuse pins the type.

So what do we do?

“Don’t reuse the slot.”

Instead: struct file → [slot in the filp slab] → [the page that slot sits in] → detach that page from the slab allocator → return it to the buddy allocator → reallocate it for another purpose.

That is — not “let’s swap the slot that held struct file for another object,” but “let’s pull the entire page that slot was in out of the slab, then reuse that page for a purpose of our choosing.”

This lets us bypass the different-cache problem and create the possibility of re-securing the very memory region where struct file used to live.


4. How the Cross-Cache Attack Works — page-level reuse

The big picture

Every slab cache ultimately gets its physical pages from the buddy (page) allocator. And when all objects on a slab page are freed, that page is returned to the buddy allocator and becomes a free page that can go back out for any purpose. Cross-cache exploits this property.

The key is: the physical memory the dangling pointer points to stays the same, but the identity of what sits on top of it changes from struct file → a pipe_buffer page.

And since a pipe can be byte-controlled by the user via read() / write(), we end up able to fill the memory the dangling struct file points to with whatever values we want.

Cross-cache: filp slab page → buddy → pipe page

bad epoll’s actual steps

(free the struct file → fully empty the slab page that memory was in → actually return the page to the buddy allocator → have a pipe take that page → the dangling pointer now points at memory the pipe uses)

1. Emptying the entire victim slab page

Freeing just the victim doesn’t free the whole page. For example, if a page holds 10 struct files:

1
2
3
4
one slab page
┌────┬────┬──────┬────┬────┬────┬────┬────┐
│file│file│victim│file│file│file│file│file│
└────┴────┴──────┴────┴────┴────┴────┴────┘

And closing only the victim:

1
2
3
┌────┬────┬──────┬────┬────┬────┬────┬────┐
│file│file│ FREE │file│file│file│file│file│
└────┴────┴──────┴────┴────┴────┴────┴────┘

the page itself is still in use. So the attacker creates many struct files around the victim in advance. Then the target slab page gets packed with struct file objects.

1
2
3
┌──────┬──────┬────────┬──────┬──────┬──────┐
│ file │ file │ victim │ file │ file │ file │
└──────┴──────┴────────┴──────┴──────┴──────┘

And if we close all of them?

1
2
3
┌──────┬──────┬────────┬──────┬──────┬──────┐
│ FREE │ FREE │  FREE  │ FREE │ FREE │ FREE │
└──────┴──────┴────────┴──────┴──────┴──────┘

Now there are no live struct files left in this slab page — it becomes an empty slab.

Filling the objects around the victim in this step is called cross_cache_enclosing_objs.

2. An empty slab isn’t returned to the system right away — pushing the empty slab to buddy

Here’s where the SLUB allocator matters. You’d think an empty slab immediately returns its page — but it doesn’t.

Roughly: “empty slab → kept in SLUB’s partial list → reused later if needed.”

So the attacker keeps producing empty slabs and piling them onto the partial list. Eventually it exceeds cpu_partial, the per-CPU limit on how many partial slabs are kept around. Then SLUB can be induced to do: “partial list → no longer kept here → returned to buddy.” So the victim slab, too, is eventually pushed from SLUB → buddy allocator.

3. Waiting out the RCU delay

The filp cache uses the SLAB_TYPESAFE_BY_RCU property. Simply put, it’s a kind of safety mechanism: just because this object was freed does not mean its memory may be reused for another purpose immediately. So the actual page return can also be tied to an RCU grace period and delayed.

Conceptually: “empty slab → removed from SLUB → RCU callback registered → wait a moment → rcu_free_slab()free_slab() → buddy allocator.”

That is, the attacker must wait until the page actually goes back to the buddy allocator. So usleep(CROSS_CACHE_DRAIN_US) is used to wait out this timing.

4. A pipe takes that page

By this point the situation is: the original filp slab held the victim struct file, and this page has been fully emptied and returned to the allocator. Now this page is a free page managed by the buddy allocator. The attacker wants to reallocate this page for another purpose — and here we use a pipe!

  • What happens when you write data to a pipe?

A pipe internally uses pipe_buffer and backing pages to store data. When the attacker writes a lot of data to the pipe, the kernel secures several pages as the pipe’s backing storage.

The attacker uses F_SETPIPE_SZ to make the pipe large, so it secures many pages at once. Then the victim page that went into the buddy allocator has a chance of being reused as one of them.

5. The result

The dangling struct file pointer now points at memory the pipe freely reads and writes. It has become a forged struct file.

  • At first:
1
dangling struct file pointer → victim → filp slab page
  • Free the victim, then free all the surrounding struct files too:
1
2
3
┌──────┬──────┬──────┬──────┬──────┐
│ FREE │ FREE │ FREE │ FREE │ FREE │
└──────┴──────┴──────┴──────┴──────┘

→ empty slab → pushed off the partial list → RCU delay → returned to buddy allocator → attacker writes data to the pipe → the same physical page is reallocated as a pipe backing page

  • Result:
1
2
3
4
5
6
7
8
dangling struct file pointer
          │
          ▼
    [pipe backing page]
          │
          ├── attacker can read / write
          │
          └── can manipulate the actual memory contents

6. Why is a forged struct file possible?

Because the dangling pointer doesn’t disappear.

The victim struct file object itself was freed, but a pointer that used to point at that object remains, and a pipe takes that memory back. So the attacker can set the contents of that page to whatever values they want through the pipe.

From the kernel’s point of view, there may still be code that thinks that memory is a struct file. That’s how a fake struct file — a forged struct file — can be produced.


5. Applying It in bad-epoll — 4 epoll objects / 2 pairs (trigger pair + victim pair)

Let’s map everything so far onto the exploit layout. bad epoll uses four epoll objects in two pairs.

Trigger pair (race pair) — the side that causes the race

  • ep_race_target + ep_race_waiter (ep_race_waiter watches ep_race_target via EPOLL_CTL_ADD)
  • Closing these two simultaneously on different CPUs triggers Part 1’s close-vs-close race.
  • On race success, CPU 0’s hlist_del_rcu() writes 0 into the freed epoll_race_target->refs.first.
  • Note: a separate 4-level nested chain ep_race_fds[3] … ep_race_fds[0] is kept, and whether ep_loop_check() traverses this chain is used as the criterion for deciding whether the race succeeded.

Victim pair (UAF victim pair) — the side actually seized

  • ep_uaf_target + ep_uaf_waiter
  • ep_uaf_target is allocated right after epoll_race_target is freed, performing same-cache reclaim of that kmalloc-192 slot.
  • ep_uaf_waiter is set to watch ep_uaf_target, building the two-way hlist link that the 8-byte write will aim at.

Where the 8-byte write is aimed

1
2
3
4
5
6
7
8
9
10
close-vs-close race (trigger pair)
      │
      ▼
hlist_del_rcu(&epi->fllink)  →  *epi->fllink.pprev = 0
      │   (epi->fllink.pprev = &epoll_uaf_target->refs.first, offset 160)
      ▼
epoll_uaf_target->refs.first = 0   ← front link severed
      │
      ▼
epi->ffd.file (struct file pointer) left dangling  ★ struct file UAF

That is, the write produced by the trigger pair strikes offset 160 of the victim pair’s epoll_uaf_target, leaving a dangling pointer to a struct file.


6. Part 2 Wrap-up & Part 3 Preview

What we now hold

Through this part, the 8-byte write-0 has been amplified into:

a dangling struct file pointing at memory the attacker controls byte-by-byte through a pipe — i.e., a state where we can present a fake struct file, filled with values of our choosing, to the kernel as if it were real.

Why struct file is such a juicy target

struct file is one of the top targets in kernel exploitation, because the keys to prying the kernel open are concentrated inside it.

  • f_op — the file-operations function pointer table. Forging it turns operations like read / ioctl into indirect calls to an attacker-chosen address → the foothold for control-flow hijacking.
  • f_inode — this file’s inode pointer. Forging it to an arbitrary address makes /proc/self/fdinfo read that address as if it were an inode → the foothold for arbitrary read (AAR).
  • private_data — the per-file-type private-data pointer. Used in the ROP setup stage.

Part 3 preview — Opening the Kernel

In Part 3 we use this fake struct file as a lever to reproduce:

  1. Forge f_inode and read /proc/self/fdinfo (ep_show_fdinfofile_inodeinode->i_ino / i_sb->s_dev) to obtain a constrained arbitrary read (constrained AAR).
  2. Use the sigaltstack trick to lift the constraint and upgrade to a full arbitrary read (unconstrained AAR).
  3. Walk pointers up from init_task to find our own task_struct and the kernel address of the pipe page, defeating KASLR.
  4. Forge f_op and hijack control flow with a ROP chainroot.

Next — Part 3: Opening the Kernel On this pipe-controlled memory we paint a fake struct file, and reproduce end-to-end how arbitrary read is chained all the way to ROP to get a root shell.


Part 2 Summary

  • The kernel manages memory with SLUB, in size-based caches (kmalloc-N) and slabs (arrays of slots on a page); a kfree‘d slot is reused by the next kmalloc in the same cache.
  • UAF = dangling pointer + reclaiming that slot with your own object (reclaim/spray). bad-epoll first sets the stage with same-cache reclaim (epoll_race_targetepoll_uaf_target, kmalloc-192).
  • The 8-byte write-0 strikes epoll_uaf_target->refs.first (offset 160), leaving a dangling pointer to a struct file.
  • But struct file lives in the dedicated filp cache + SLAB_TYPESAFE_BY_RCU, so same-cache reuse can’t control it → cross-cache is needed.
  • Cross-cache is page-level reuse: empty the whole filp slab page and return it to buddy → re-secure it as a pipe_buffer page. As a result the dangling struct file points at memory we control through a pipe.
  • The layout is 4 epoll objects / 2 pairs (trigger pair = race, victim pair = UAF target), plus /dev/null spray, timerfd, false sharing, and adaptive timing for ~99% success.
  • The deliverable: a state where we can present a fake struct file. In Part 3 we take this to arbitrary read → ROP → root.
This post is licensed under CC BY 4.0 by the author.