Kernel UaF to Root #4: Reading Any Address by Making a Page Change What It Is

Last post turned a leaked coordinate into a write. We forged a binder_node, tripped the kernel into unlinking it, and hlist_del stored our value at our address, *WHERE = WHAT, with the unlink’s *(WHAT + 8) = WHERE as the price of admission. We can now change kernel memory at will.
But a write is only half a primitive, and on its own it’s a blind one. To spend a write you have to know what to overwrite and where it lives: the address of the current task’s credentials, the exact field inside a struct whose offset drifts from build to build, the pointer you mean to redirect. The finale of this series gets root by walking kernel structures from a leaked binder_node all the way to a cred, then zeroing the fields that stand between an app and root. You cannot walk a structure you cannot read. So before we can spend the write, we need to read kernel memory, any address, on demand.
This post builds that arbitrary read primitive, and it earns it with the nastiest heap trick in the series so far: cross-cache reuse. Posts 2 and 3 reclaimed a freed binder_node’s 128-byte slot with another object from the same kmalloc-128 pool, a ref or a fake node. This time the object we want in that slot doesn’t come from kmalloc-128 at all. It comes from a different allocator cache entirely, and getting it there means steering a whole freed page out of one cache and into another. That is Post 3’s closing promise, come due: record once, walk to the exact reuse, and watch a freed page come back as something else.
- Free enough binder_nodes to empty whole kmalloc-128 pages, not just slots but entire pages, so the page allocator can hand them to the epoll subsystem’s private cache.
- That cache fills them with its per-watch bookkeeping objects, called epitems. One lands squarely over a freed node’s slot, and the dangling read from Post 1 now surfaces its pointers, leaking the kernel addresses of the struct file it watches and of a second epitem watching the same file.
- Spend one write from Post 3 to corrupt that file’s f_inode so it points into the leaked epitem.
- Two ordinary syscalls, epoll_ctl and ioctl(FIGETBSZ), walk a pointer chain that now runs through fields we control, handing us the 32 bits at any kernel address we name.
A minute on epoll and pipes
Everything here rides on epoll, so a quick primer before we start bending it.
Epoll is Linux’s mechanism for watching many file descriptors at once, the thing behind every event loop that has to know, efficiently, which of a thousand sockets just became readable. A process calls epoll_create1 to get an epoll instance (a kernel object, reached through its own fd), then registers file descriptors on it with epoll_ctl. That call has three operations, and the difference between them is the whole game:
- EPOLL_CTL_ADD: start watching an fd on this instance. The kernel allocates a new epitem (one small object representing “this fd, watched by this epoll instance”) and links it to both the epoll instance and the watched file.
- EPOLL_CTL_MOD: change the settings of an fd we already watch. The kernel rewrites the existing epitem in place. No allocation, no free, just an overwrite of its event field.
- EPOLL_CTL_DEL: stop watching. The kernel frees the epitem.
An epitem carries event.data, a 64-bit value userspace hands in and the kernel simply stores. Its intended use is boring: an app stashes a pointer or an index there so that when the fd fires, epoll_wait hands the value back and the app knows which connection it was. For us, two properties matter. First, event.data is a full 64-bit word we control completely. Second, EPOLL_CTL_MOD lets us rewrite it in place, at a known kernel address, without allocating or freeing anything, a clean, side-effect-free poke into a kernel object. That poke becomes the read gadget’s trigger.
The other half of the cast is what a pipe hangs off. pipe() gives you two file descriptors, a read end and a write end, and behind each fd sits a struct file, the kernel’s record of one open file. Both of a pipe’s files point at a single inode (the kernel’s per-file identity object), and that inode points at a super_block (the mounted filesystem it belongs to). Pipes live on a tiny in-kernel filesystem called pipefs, whose super_block records a block size of 0x1000, one page. That three-hop chain, file → f_inode → i_sb → s_blocksize, is exactly what one unglamorous ioctl walks:
/* fs/ioctl.c — ioctl(fd, FIGETBSZ, &out) returns the filesystem block size */
case FIGETBSZ:
if (!inode->i_sb->s_blocksize)
return -EINVAL;
return put_user(inode->i_sb->s_blocksize, (int __user *)argp);FIGETBSZ (“get block size”) reads file->f_inode, then inode->i_sb, then sb->s_blocksize, and copies that last value back to userspace as a 32-bit int. On a normal pipe it returns 0x1000, every time. Two syscalls do all the work: epoll_ctl to set a value, ioctl(FIGETBSZ) to follow a chain and hand a value back, once we’ve bent the chain to run through memory we own.
A different kind of reuse
Now the heap problem, and a closer look at what a cache actually is.
Every reuse so far has been same-cache. The kernel’s SLUB allocator sorts small allocations into size-class caches (kmalloc-128, kmalloc-256, kmalloc-512, and so on), rounding each request up to the smallest class that fits. That is the entire reason a binder_ref (Post 2) or a sendmsg control buffer (Post 3) could drop into a freed binder_node’s slot: all three land in kmalloc-128 (the ref at 104 bytes, the node and the control buffer at a full 128), so they draw from one shared freelist, and a freshly freed slot is the next one handed out. Reclaiming it was as simple as allocating something else no larger than 128 bytes.
But epitem doesn’t come from a size-class cache. Subsystems that allocate one particular struct over and over can ask for a dedicated cache of their own, a kmem_cache sized and named for exactly that object. Epoll does this: every epitem comes from a cache called eventpoll_epi. So do the objects around it, where a struct file comes from filp and an inode from inode_cache. These are separate pools with separate freelists, and that separation is strict: an object freed into kmalloc-128 cannot be handed back out by eventpoll_epi, even though an epitem is also 128 bytes. Same size, different cache, no direct reuse. Freeing a binder_node and then spraying epitems the way we sprayed refs would simply miss, because the epitems come off a freelist our freed slots were never on.
That wall between caches is why cross-cache reuse is an awkward technique. The object that lands in our freed slot didn’t come from the same freelist we freed into; it came from a different cache entirely, by a more roundabout route, and reasoning about that route, or even observing it on a live kernel, is hard. But the wall has a door, one level down.
Emptying whole pages
Both caches sit on top of the same page allocator, the system-wide manager that hands out physical memory a page (4 KB) at a time. A slab cache doesn’t own its pages forever; it borrows them. When a cache needs room it asks the page allocator for a page and carves it into slots; when a page falls completely empty, the cache can hand it back to the page allocator, which is then free to give that same physical page to whoever asks next, including a different cache. That is the door: free a kmalloc-128 page all the way empty, get it returned to the page allocator, and let eventpoll_epi be the one to claim it next.
The catch is completely empty. A kmalloc-128 page holds 32 slots; SLUB only returns a page to the page allocator once all 32 are free (and even then keeps a handful of empty pages in reserve rather than returning them, a threshold called min_partial). A single freed slot, or thirty-one of thirty-two, keeps the page on the cache’s own partial list, ineligible for crossing. To move memory between caches we have to empty pages wholesale.
So we free at scale. Each binder handle we tear down frees two kmalloc-128 objects, a binder_node and the binder_ref that named it. Freeing 768 handles frees 1536 objects: 48 full pages’ worth, 32 slots to a page. Free that many and, even with background kernel noise pinning the odd slot, dozens of pages come up all-32-free, clear the min_partial reserve, and fall back to the page allocator. Then we make epoll allocate. The epitem spray asks eventpoll_epi for room; eventpoll_epi asks the page allocator for pages; and the pages it gets are the ones our freed binder_nodes and binder_refs just vacated. The cache carves each reclaimed page into 128-byte epitem slots laid over the exact addresses that held those freed objects a moment earlier.

Cross-cache reuse: whole kmalloc-128 pages of freed binder_nodes are returned to the page allocator, which hands them to the eventpoll_epi cache, which carves them into epitem slots at the same addresses
A page changes what it is. It starts full of binder_nodes in kmalloc-128; we free all 32 slots so the page empties; SLUB returns the empty page to the page allocator; and when epoll needs room, eventpoll_epi claims that same physical page and re-cuts it into epitem slots. The addresses are unchanged: an epitem now sits exactly where a freed binder_node (or the binder_ref that named it) used to.
Landing an epitem, leaking a pair
The spray itself is just epoll_ctl(EPOLL_CTL_ADD), over and over. Each call mints one epitem; do it across 192 pipes with two epoll instances apiece and we get 384 epitems trying to find room, enough that some settle onto the cross-cache pages, while the scan over the stale reads that follows only needs a single hit.
/* the spray: one epoll_ctl(ADD) = one epitem out of eventpoll_epi.
* some are carved onto pages our freed binder_nodes just vacated. */
for (int j = 0; j < PIPE_ALLOC; j++)
for (int i = 0; i < EPITEM_ALLOC_PER_PIPE; i++)
epoll_ctl(epoll_fd[j][i], EPOLL_CTL_ADD, pipe_rd[j], &event);When an epitem lands over a freed binder_node’s slot, the dangling transaction from Post 1 reads it back: same bug, same read, new occupant. Recall the two fields that read surfaces: the freed node’s +0x58 (delivered as target.ptr) and +0x60 (cookie). Overlaid on an epitem, those two offsets fall on its fllink, the list_head at offset 0x58 that threads the epitem into the watched file’s list of watchers. So the read hands us the epitem’s two list pointers: fllink.next at +0x58 and fllink.prev at +0x60.
Where those pointers point is the leak. Epoll links every watcher of a file into a circular doubly-linked list rooted at file->f_ep_links (+0xE0 in the struct file). With two epitems watching each pipe, that circle has exactly three members (file->f_ep_links ↔ epitem_1->fllink ↔ epitem_2->fllink ↔ back to the file), and each epitem’s two pointers therefore hold one address of the file and one of the sibling epitem. That is the whole reason we spray two per pipe rather than one or thirty: with two, every epitem we read is guaranteed to leak one live struct file address and one live epitem address, no matter which one we hit.

The file/epitem circular list: with two epitems per pipe, each epitem’s fllink pair holds one pointer to the struct file and one to the sibling epitem, so a stale read of either leaks one file and one epitem address
Two epitems watching one pipe form a three-member circle with the file’s f_ep_links. Each epitem’s fllink pair (next at +0x58, prev at +0x60) points at one neighbour that is the file and one that is the sibling epitem. The stale read surfaces both, so a single hit leaks a live struct file address and a live epitem address at once.
We don’t know which of the leaked pair is which, but their allocators give them away: an epitem and a struct file come from different caches with different slot sizes, so two cheap alignment tests on the low bits sort them (the derivation is in Which address is which, below). Subtracting the member offset from each then gives the two struct base addresses.
/* the stale read now surfaces an epitem's two fllink pointers */
uint64_t cookie = 0, target_ptr = 0;
read_uaf(bs, &cookie, &target_ptr); /* +0x60, +0x58 of the reused slot */
/* one is the file (filp stride 0x140), one the sibling epitem (low byte 0x58/0xD8) */
uint64_t file_addr = is_file(cookie) ? cookie : target_ptr;
uint64_t epitem_addr = is_epitem(cookie) ? cookie : target_ptr;
file_addr -= 0xE0; /* STRUCT_FILE_EPLINKS_OFF -> struct file base */
epitem_addr -= 0x58; /* STRUCT_EPITEM_FLLINK_OFF -> epitem base */Two live kernel addresses, both of objects we still hold open.
Corrupting the inode
Now we spend one write. We want the pipe’s struct file to believe its inode lives inside the epitem, positioned specifically so the inode’s i_sb field lands on the one field of the epitem we can rewrite at will, event.data.
The arithmetic is forced by the offsets. f_inode sits at file + 0x20. The kernel reads i_sb at inode + 0x28. And event.data is at epitem + 0x78. So if we make f_inode point at epitem + 0x50, then the kernel’s i_sb read (f_inode + 0x28) lands on epitem + 0x50 + 0x28 = epitem + 0x78, which is event.data. One write does it:
uint64_t what = epitem_addr + 0x50; /* epitem+0x78(event.data) - 0x28(i_sb) */
uint64_t where = file_addr + 0x20; /* &file->f_inode */
write64(what, where); /* *(file+0x20) = epitem+0x50 */(In the real exploit the process that computes these values doesn’t call the write itself; it hands the WHAT and WHERE to its parent to execute, for a reason The choreography behind one read explains below. The store that lands is the same either way.)
Here is where Post 3’s constraint comes home. The write primitive is an hlist_del, and whenever its WHAT is non-zero the unlink drags a second store along: *(WHAT + 8) = WHERE. Our WHAT is epitem + 0x50, very much non-zero, so the side effect fires, storing WHERE at WHAT + 8 = epitem + 0x58. That address is the epitem’s fllink.next. The side effect scribbles file + 0x20 over one of the list pointers we just leaked from.
And it does not matter in the slightest. Post 3 said the way to live with the side effect is to aim it somewhere harmless, “a location whose corruption is inert for what the kernel does next,” and promised the read primitive was built with that in mind. This is that case, exactly. The read gadget’s pointer chain runs f_inode → i_sb → s_blocksize; it never touches fllink. The corrupted list pointer only matters when the kernel eventually walks that list, on close() or process exit, and by then the exploit has long since finished.
The read gadget
With f_inode redirected, the pipe’s FIGETBSZ chain no longer runs through a real inode. It runs through the epitem:
- file->f_inode → epitem + 0x50 (what we wrote)
- f_inode + 0x28 (the kernel’s i_sb) → epitem + 0x78 = event.data (what we control)
- i_sb + 0x18 (the kernel’s s_blocksize) → *(event.data + 0x18) → returned to userspace
The last hop is the primitive. Whatever address we put in event.data, the kernel adds 0x18, reads there, and returns the low 32 bits. So to read an address, we set event.data = addr - 0x18 with epoll_ctl(MOD) (that in-place poke from the primer, no allocation, no noise) and then call ioctl(FIGETBSZ), which walks the chain and returns the 32 bits at addr.

The corrupted FIGETBSZ chain: ioctl reads file->f_inode (redirected into the epitem), then i_sb (which now lands on the attacker-controlled event.data), then s_blocksize at event.data+0x18, returning 32 bits from the target address
The read gadget. ioctl(FIGETBSZ) follows file → f_inode → i_sb → s_blocksize, but f_inode now points into the epitem, so the kernel’s i_sb read lands on event.data, a field we set with epoll_ctl(MOD). Point event.data at addr - 0x18 and the final s_blocksize read returns the 32 bits at addr.
/* read32: aim the fake i_sb at addr-0x18 in each of the pipe's epitems,
* then one FIGETBSZ walks the chain and returns *addr */
struct epoll_event evt = { .events = 0, .data.u64 = addr - 0x18 };
for (int i = 0; i < EPITEM_ALLOC_PER_PIPE; i++)
epoll_ctl(epoll_fd[i], EPOLL_CTL_MOD, pipe_rd, &evt);
uint32_t out = 0xdeadbeef;
ioctl(pipe_rd, FIGETBSZ, &out); /* out = *(uint32_t *)addr */Two wrinkles finish it. Only one pipe’s file was corrupted, the one whose address we leaked, so an ioctl on any other pipe still returns the honest 0x1000. That doubles as a hit test: we walk the pipe groups, and the first whose FIGETBSZ returns something other than 0x1000 is the corrupted one. (The test has one blind spot: if the probed value is itself 0x1000, the corrupted pipe looks honest and the scan runs past it. The read still returns the right value, since every pipe answered 0x1000, but the index only gets cached on a later read that returns something else.) We cache its index so every later read skips straight to it: an epoll_ctl(MOD) on each of the pipe’s two epitems (we don’t know which of the two epoll fds owns the epitem that f_inode points into, so we set both) and one ioctl, nothing more. And because FIGETBSZ yields only 32 bits, a 64-bit read is just two of those 32-bit reads, low word then high:
uint64_t read64(uint64_t addr) {
uint32_t lo = read32(addr); /* read32 = the epoll_ctl(MOD)+ioctl gadget above */
uint32_t hi = read32(addr + 4);
return (uint64_t)hi << 32 | lo;
}
That is an arbitrary kernel read: name any address, get its bytes. Paired with Post 3’s write, we now have both halves: read and write anywhere in kernel memory. Post 5 puts them to work.
Enter Epoch
Same-cache reuse was already hard to observe. Cross-cache is worse, and for a specific reason: the object that reclaims your slot never appears on the freelist you freed into. You free binder_nodes into kmalloc-128; the thing that lands there arrives from eventpoll_epi, by way of the page allocator, a whole level below either cache. To confirm the reuse even happened you have to line up three moving parts at once (a kmalloc-128 page going fully empty, the page allocator taking it back, and eventpoll_epi claiming that exact physical page for its next epitem) across two caches that, from the inside, know nothing about each other. There is no single freelist to watch. The claim “an epitem landed on a freed node’s page” is a statement about physical pages crossing subsystem boundaries, and nothing on a live kernel is built to show you that.
The read side is equally indirect. When ioctl(FIGETBSZ) returns a wrong value, or faults, the effect is a pointer-chase removed from its cause. The value came from s_blocksize, which came from i_sb, which we had replaced with event.data, which we set with an epoll_ctl on a different fd, following an f_inode a write primitive corrupted several steps earlier. And the usual bind from the earlier posts only tightens here: any probe you add to watch the heap perturbs the page reclamation you’re trying to measure, and one wrong pointer takes the machine down with the evidence.
In Epoch, none of that indirection can hide. You walk to the exact moment a kmalloc-128 page is returned to the page allocator and then reassigned to eventpoll_epi, and watch the page’s identity flip, physically, at the instruction that reassigns it, an epitem afterward sitting at an address that held a binder_node before. Then you confirm the corruption end to end: the write that set f_inode, the epoll_ctl(MOD) that set event.data, and the FIGETBSZ that walked from one to the other. And when a read misbehaves, you don’t guess; you start at the value FIGETBSZ returned and slice backward along the chain: this came from s_blocksize at this address, which is event.data we set here, reached through f_inode written there, by this write. The pointer-chase that made the bug opaque on a live kernel becomes a chain you follow link by link, in a timeline that never moves. Cross-cache reuse, the least reproducible moment in the whole chain on a live box, is pinned here to one instruction that replays identically on every open.
We follow one slab cell across its whole life, at a single address that never moves, and watch its owner change from the binder driver to epoll.

One recording of cross-cache reuse: the access history of a single cell at lin:0xffffff813752d080 (phys 0x17752d080), owned first by the binder driver where binder_update_ref_for_handle touches it as a binder_ref at #79,902,910, then freed when __slab_free returns its page to the allocator at #79,903,567, then claimed by epoll when clear_page runs under __alloc_pages from allocate_slab from ep_insert at #82,241,846, and finally an epitem whose event.data do_epoll_ctl writes at #230,260,121, the same address owning a binder_ref before and an epitem after.
One cell, two caches: the page that held a binder_ref in kmalloc-128 is freed, falls back to the page allocator, and comes back as an epoll epitem, the same address re-owned by a different cache.
The spine is that cell’s access history. arm64 linear-maps the kernel heap, so a slab cell’s virtual address is a fixed function of its physical page: this cell sits at lin:0xffffff813752d080 (physical 0x17752d080) for the whole run. The identity we track is that shared address, not a second one. Early on, the binder driver owns it. Double-click the earliest history rows and the timeline lands at #79,902,910, where binder_update_ref_for_handle reads the cell as a live binder_ref, an object out of kmalloc-128.
Then the page empties. At #79,903,567, __slab_free writes a freelist pointer into the cell: the last binder_ref on that page is gone, and the now-empty page falls back to the page allocator. The crossing is the next stop. Travel to #82,241,846 and open the backtrace: clear_page is running under ep_insert → allocate_slab → __alloc_pages. The eventpoll_epi cache has just pulled that freed page off the allocator’s freelist and zeroed it for its own slots. A cache that never shared a freelist with kmalloc-128 now owns the page, because the handoff happened a level below both, at the page allocator.
After the flip the same cell is an epitem. ep_insert initializes it, and at #230,260,121 do_epoll_ctl writes its event.data. That write aims the read gadget: the epitem this page became is the one the read primitive uses next, and its event.data is the word the read figure’s i_sb resolves to. One address, two owners, proven by the function at each era rather than asserted.
That reused page is now an epitem, and the read primitive abuses it. We walk the first arbitrary read the exploit performs (the sanity check that reads back the sentinel it planted) one hop at a time, and prove by identity that the bytes FIGETBSZ hands to userspace are the exact bytes living at the address we aimed it at.

One recording of the first arbitrary read: ioctl(FIGETBSZ) on the corrupted pipe loads i_sb from x22 = 0xffffff813752d0d0 (our epitem+0x50, from Post 3’s write), the disclosing ldr x8,[x8,#0x18] reads our chosen address 0xffffff81375745d8 and x8 becomes 0xBAAFBAAF, put_user sttr w10,[x9] hands w10 = 0xBAAFBAAF to the EL0 buffer 0x7cc01fff24, and Go to memory on the gadget’s own [x8,#0x18] operand opens the node slot at 0xffffff81375745d8 where the qword reads 0xBAAFBAAF, the token our node owner stamped in the node’s ptr field.
The read gadget in one recording: FIGETBSZ walks our corrupted f_inode → i_sb → s_blocksize chain and hands back 0xBAAFBAAF, the exact token we planted at the address we aimed the read at.
The recording opens on the gadget already walking through memory we own. ioctl(FIGETBSZ) on the corrupted pipe reaches the FIGETBSZ handler in do_vfs_ioctl, and the kernel’s i_sb read, ldr x8, [x22, #0x28], finds x22 = 0xffffff813752d0d0 in hand. That is not a real inode: it is epitem + 0x50, the value Post 3’s write stored into this pipe’s file->f_inode (#171,234,202, over in the writer sub-process; we only reference it here, we don’t travel to it). f_inode points into our epitem before the gadget takes a single step.
Follow the chain one hop and it lands on the field we control. f_inode + 0x28, the kernel’s i_sb, reads 0xffffff81375745c0, which is event.data, the 64-bit word we set from userspace with epoll_ctl(MOD), placed exactly 0x18 below the address we mean to read. The disclosing load ldr x8, [x8, #0x18] at transition #230,261,577 then dereferences i_sb + 0x18 = 0xffffff81375745d8, and x8 comes back 0xBAAFBAAF. The kernel just read from an address we chose.
Then the value crosses the boundary and the identity closes. At put_user, sttr w10, [x9] runs with w10 = 0xBAAFBAAF and x9 = 0x7cc01fff24, an EL0 buffer: the kernel copies our disclosed word out to userspace (#230,261,608). Right-click the gadget’s own [x8, #0x18] operand, pick Go to memory, and the grid opens at 0xffffff81375745d8, the very address that load dereferenced. That address is the leaked binder_node’s ptr field (node base 0xffffff8137574580, +0x58), and the qword sitting there reads 0xBAAFBAAF, the sentinel token our own node-owner process stamped on that node. Because the Memory view was opened from the gadget’s operand, the grid is the read target by construction: the bytes FIGETBSZ returned are the same bytes.
The address the gadget dereferences is the address we aimed it at, event.data + 0x18, and the Memory grid holding 0xBAAFBAAF was reached from that same operand, not typed in by hand. The value handed to userspace is the token we planted, read back out of the field we planted it in. The corrupted f_inode, the fake i_sb we set through event.data, the disclosing s_blocksize load, and the userspace hand-off all sit at fixed points in one recording, replayable on demand. The finale’s hosted full-chain trace runs this same read gadget, so you can walk it yourself or point an agent at it over Epoch’s MCP.
When the inode points into the wrong place
Get the corruption’s arithmetic wrong and there’s no tidy error; the kernel takes the bad pointer at face value and falls over deep in the ioctl path, far from the one userspace line that set it up. We’ll walk one representative slip of a small family (a fake inode built at the wrong offset, or from the wrong object) because the crash it produces looks generic (a data abort in the FIGETBSZ handler) yet its true cause is a single wrong choice at the write, and a backward walk pins it from the fault alone.
A fake inode built from the wrong object. The corruption has to place the fake inode inside the epitem, so its WHAT is computed from the epitem’s base address. Compute it from the file’s base instead (a plausible slip, since both addresses are sitting right there from the leak) and f_inode is redirected to file + 0x50: still a valid kernel address, but pointing into the struct file itself rather than the epitem. Now the chain reads i_sb from file + 0x78, which is an ordinary zero field of the file, so i_sb comes back NULL. FIGETBSZ then reads s_blocksize at NULL + 0x18, and the kernel takes a data abort at address 0x18.
Live, that’s a bad-pointer oops in do_vfs_ioctl with a near-NULL faulting address and nothing tying it to your code; the faulting address 0x18 appears nowhere in your source. On a recording it unwinds cleanly. You start at the fault: s_blocksize read from 0x18, so i_sb was NULL. Step back one hop: NULL came from f_inode + 0x28, so f_inode itself was file + 0x50, an address inside the file where one inside the epitem belongs. Then the same memory-history question Post 3 leaned on (who wrote f_inode?) names the culprit: the write primitive stored it. Set the stored value against the two addresses the leak reported (one file, one epitem) and you have the mistake: it is the file’s base plus 0x50, so the fake inode was built from the file’s base, not the epitem’s. One wrong operand at the write, a NULL dereference three hops later in the kernel, and the backward slice finds it without re-running the exploit.
Cross-cache stretches the distance between cause and effect even further than Post 3’s bugs did (a wrong base at the write, a fault deep in an unrelated syscall’s pointer chase), and that distance is exactly what a recording collapses to a few backward steps.
To go further
A few threads we pulled loose from the main line, for readers who want the depth.
The cross-cache spray, in more detail
The main line said “free 768 handles” and “spray 384 epitems” without justifying the numbers; here is why they are what they are.
Why 768. A page crosses caches only when it is entirely empty, and min_partial (about 5) lets a cache keep its first several empty pages in reserve rather than returning them. So the freeing has to overwhelm both thresholds. Each handle frees two kmalloc-128 objects (a node and its ref), so 768 handles free 1536 objects, 48 pages at 32 slots each. Background kernel activity pins the odd slot and keeps a few pages from emptying, and the min_partial reserve swallows a few more, but with 48 pages in play, dozens still come up fully empty and cross to the page allocator. The count is chosen to clear the reserve with margin to spare.
Why the pipes and epoll instances come first. The epitem spray has to allocate after the pages are freed, so eventpoll_epi pulls its pages from the ones we just returned. But the pipes and epoll instances are created first, before the free, because pipe() and epoll_create1, while they don’t touch kmalloc-128 themselves, do make page-level allocations of their own, and if those ran after the free they’d compete for the very pages we want eventpoll_epi to claim. Allocate them up front and they’re out of the way.
Why two epitems per pipe, and 384 total. Two per pipe is what guarantees a clean leak: in a three-member circle (file + two epitems), every epitem’s fllink pair holds exactly one file pointer and one sibling pointer, so any hit yields one of each. One epitem per pipe would leak two file pointers (ambiguous); three or more would leave middle epitems pointing only at siblings, with no file pointer at all. The total is a coverage knob: 384 epitems (192 pipes × 2) is 12 slab pages of spray against 48 freed pages, and empirically lands a valid cross-cache pair on about 16.9% of the stale reads (roughly 130 of 768), versus 4.4% at half the count. The linear scan needs only a single hit, so 384 buys comfortable margin.
One attempt, no retry, and only the setup gets that one shot. Everything in this section (empty the pages, spray epitems, leak a pair, corrupt f_inode) is one-time setup. If no valid pair turns up, the exploit bails rather than looping: retrying doesn’t help, because each extra round of allocations fragments and pins more slab pages, so a second attempt runs against a dirtier heap than the first. And a fresh userspace process won’t fix that: the fragmentation lives in the kernel’s global slab state, which a process exiting doesn’t reset. The only real reset is rebooting the device (or, in our recording workflow, reloading a clean VM snapshot). Once the setup does land, though, it never needs repeating: the corruption is established once, and the read primitive is reusable. Every subsequent read is just epoll_ctl(MOD) + ioctl(FIGETBSZ), no re-spraying, no re-leaking, as many times as you want.
Which address is which
The two heuristics that sort the leaked pair fall straight out of slab geometry. An epitem lives in eventpoll_epi’s 128-byte slots, which start at page offsets 0x00, 0x80, 0x100, …; the leaked pointer is &epitem->fllink at +0x58 into the object, so its page-relative address is 0x58, 0xD8, 0x158, 0x1D8, …, and the low byte is always 0x58 or 0xD8. A struct file lives in the filp cache, whose slots stride 0x140 bytes; the leaked pointer is &file->f_ep_links at +0xE0, so subtracting 0xE0 yields a multiple of 0x140 within the page. Both must also pass the ARM64 kernel-range test (addr >> 40 == 0xffffff). If both leaked values look like epitems, or one of them fails its test, we reject the read and keep scanning, because the code insists on exactly one file and one epitem before it trusts the pair.
The choreography behind one read
The read runs from a long-lived forked child: a reader service that performs the cross-cache leak once and then serves (size, address) requests over a pipe channel, returning the value. Its first read scans every pipe group to find the single corrupted file, caches that index, and skips straight to it thereafter.

Read primitive choreography: a forked reader service performs the cross-cache leak, sends WHAT/WHERE up to the parent, which relays it to the writer service to corrupt f_inode, then serves read requests by epoll_ctl(MOD) + ioctl(FIGETBSZ)
One read primitive, end to end: the reader service sprays epitems, leaks a file/epitem pair, and hands the WHAT/WHERE up to the parent, which relays it to the writer service (Post 3) to corrupt f_inode. Then the reader settles into a request loop where each 32-bit read is a pair of epoll_ctl(MOD)s (one per epitem) to set event.data, then one ioctl(FIGETBSZ) that walks the chain. The corruption is set up once; every later read is that handful of syscalls.
There’s a structural reason the reader service doesn’t perform the write itself: only the parent holds channels to both the reader and the writer services, so routing the write through the parent keeps the plumbing simple, and it keeps the reader service’s corrupted pipe fds confined to the one process that created them, well away from the writer’s fork-heavy spray pool, where a forked process that inherited them would close them on exit and panic the kernel. The reader service ships the WHAT and WHERE up to the parent, which owns no corrupted fds, and lets it call the write.
It’s all build-specific
Every constant here is Android 12 on a Pixel 6, kernel 5.10, arm64. The ideas carry over to any build: empty whole pages of one cache so the page allocator re-hands them to another; find a dedicated-cache object with an attacker-writable field that overlaps the read offset; and repurpose a benign, pointer-chasing ioctl as the gadget that walks your corrupted chain. The offsets would all have to be re-derived.
Next
We can now read kernel memory at will, and pair it with the constrained write from Post 3, both halves of the primitive a kernel exploit is after. What’s left is to spend them. The finale leaks one more live binder_node (the same trick from Post 2) and walks from it through the kernel’s own structures to the record of who we are, then flips the handful of fields that gate root. One recording holds the whole chain end to end: we follow it by hand and watch an unprivileged shell become root as the credential writes land.
Bring your own target to Epoch
Our team will show you how Epoch fits the way you work.
Want to explore first? Test the Epoch engine at ttd.eshard.com. Free, no sign-up, no credit card.
