Software Security

Kernel UaF to Root #2: Making the Freed Slot Hand Back an Address We Chose

Quentin Buathier
|
-
|
Oct 2026
Back to all articles
SHARE

In the first blog post, we freed a binder_node while a transaction was still queued and pointing at it, then watched the kernel read that freed slot back. The read worked: the kernel handed us bytes out of memory it had already released. But the bytes were an accident. Whatever happened to sit in the slot (the dead node’s leftovers, or some unrelated allocation that reclaimed it) decided what came back. Useful as proof the bug is real; useless as a weapon.

This post turns that dangling read into a leak of a live kernel address, a pointer we choose, one we can hand to the next primitive as a target. That single address is how we get around KASLR (Kernel Address Space Layout Randomization). On arm64, KASLR randomizes where the kernel heap sits as well as the kernel image, and the leak tells us where our own heap objects live.

  1. Free a batch of binder_node slots exactly as Post 1 did, and spray binder_ref objects into them in the narrow window right after the free. Every ref is aimed at a single node we create up front and deliberately keep alive.
  2. A binder_ref reclaims a freed slot from the same kmalloc-128 pool, and its node pointer sits at the exact offset the dangling read surfaces.
  3. Drain the queued reads. The read that returned junk last post now returns that ref’s node pointer: a live kernel address we chose.

‍

Why a stale read isn’t a leak yet

The kernel randomizes its memory layout at boot, so every structure we’ll eventually want to corrupt sits at an address that changes from boot to boot. Nothing can be hard-coded. Before we can write anywhere useful, we need to learn one real kernel address at runtime and orient everything else from it.

Post 1’s stale read doesn’t give us that. The bytes are either the dead node’s leftovers or whatever unrelated allocation reclaimed the slot, and neither reliably spells out a pointer we can use. It proves reuse happens; it doesn’t hand us an address.

So we change what reclaims the slot. If we can steer a specific kernel object of our choosing into the freed binder_node’s place (one whose contents we understand, one that contains a live kernel pointer at the right spot), then the same stale read stops returning garbage and starts returning that pointer.

‍

The object that fits the slot

Recall where the stale read looks. Delivering the queued transaction makes the kernel read the freed node’s ptr field at offset +0x58 and surface it to userspace as tr->target.ptr. In Post 1 those were the freed node’s own stale bytes. We need another kernel object that fits that 128-byte slot and holds something we want at its +0x58.

A binder_ref fits perfectly. It’s the companion object from Post 1, the reference the kernel creates in the other process when a node is shared. It’s 104 bytes, which lands in the kmalloc-128 size class, the very same cache a binder_node comes from. Same cache means a freed node’s slot is a candidate slot for a fresh ref. And its layout lines up:

struct binder_ref {                 /* allocated at offset 0x00 */
        struct binder_ref_data data;    /* 0x00 */
        struct rb_node rb_node_desc;    /* 0x10 */
        struct rb_node rb_node_node;    /* 0x28 */
        struct hlist_node node_entry;   /* 0x40 */
        struct binder_proc *proc;       /* 0x50 */
        struct binder_node *node;       /* 0x58  <- overlaps binder_node.ptr */
        struct binder_ref_death *death; /* 0x60 */
};

binder_ref->node is a kernel pointer to the binder_node this ref refers to, and it sits at offset 0x58. Exactly where the stale read looks. Land a binder_ref in the freed slot and the queued read no longer returns junk: it returns ref->node, the kernel address of a live node that the ref points back at.

binder_node / binder_ref overlap at offset 0x58: a binder_ref reoccupying the freed slot exposes ref->node where the stale read looks

binder_node / binder_ref overlap at offset 0x58: a binder_ref reoccupying the freed slot exposes ref->node where the stale read looks

The freed slot was a binder_node whose ptr lived at +0x58. A binder_ref reclaiming the same slot puts its node field at that identical offset. The queued read still reads +0x58, but now the bytes there are ref->node, a pointer to a binder_node that is very much alive.

We don’t care which sprayed ref lands in which slot; we point every one of them at the same node, one we create up front and deliberately keep alive. So no matter which freed slot a ref reclaims, +0x58 holds the address of that one node. We know exactly which object we’re leaking the address of, because we own it.

‍

Spraying refs into the freed slots

To land binder_ref objects we need the kernel to mint them, on demand, into freshly freed slots. The mechanism is the ordinary one from Post 1’s transaction anatomy: when a process shares one of its own objects (a flat_binder_object of type BINDER_TYPE_BINDER) with a target that doesn’t yet hold a reference to that node, the kernel translates the object and creates a new binder_ref in the target process to keep the node reachable.

The kernel keeps one ref per (process, node) pair: share the same node with the same process twice and the second send just bumps the existing ref’s count. Share it with a different process and the kernel has to allocate a fresh binder_ref, a fresh kmalloc-128. So the way to spray many refs is to share one node with many separate processes:

/* the owner shares one node it owns (0xBAAFBAAF) with each sub-process;
 * each distinct target forces a fresh binder_ref -> a fresh kmalloc-128.
 * sub_handle[i]: a handle to each sub-process, collected during setup
 * (see "The choreography behind the spray"). */
struct flat_binder_object fbo = { 0 };
fbo.hdr.type = BINDER_TYPE_BINDER;
fbo.binder   = 0xBAAFBAAF;   /* the live node we want the address of */
fbo.cookie   = NODE_COOKIE;
uint64_t off = 0;            /* the one object sits at offset 0 of the buffer */

for (int i = 0; i < 20; i++)
        binder_transaction(bs, /*one_way=*/true, sub_handle[i],
                           &fbo, sizeof(fbo), &off, sizeof(off));

Twenty sends to twenty distinct processes, twenty new refs, each carrying ref->node = &(the 0xBAAFBAAF node) at +0x58. If we time the spray to fall right after the UaF frees a batch of node slots, those twenty allocations are served straight from the freelist we just replenished. SLUB, the kernel’s slab allocator, reuses freed slots last-in-first-out, so spraying immediately after the free is what lands our refs in the very slots we opened.

binder_ref spray reclaiming freed kmalloc-128 slots, every ref’s node pointer aimed at one live target node

binder_ref spray reclaiming freed kmalloc-128 slots, every ref’s node pointer aimed at one live target node

The UaF frees a batch of kmalloc-128 slots; the spray immediately allocates a binder_ref for each. LIFO reuse drops the fresh refs back into those slots. Every ref that lands sets +0x58 to the address of the one node we kept alive, so any surviving stale read now surfaces that address.

The freed slots and the sprayed refs are balanced on purpose: we open exactly as many slots as we spray refs, so the freelist ends up fully reclaimed with no orphaned holes. That tidiness barely matters here, but it matters a great deal to the next post’s much larger heap spray. (Why the counts work out to an exact match is in To go further.)

‍

Reading back an address

The read side is unchanged from Post 1: we drain the queued transactions whose target nodes we freed, and each delivery makes the kernel read the stale slot:

for (int i = 0; i < LEAK_UAF_COUNT; i++) {
        uint64_t cookie = 0, target_ptr = 0;
        read_uaf(bs, &cookie, &target_ptr);      /* target_ptr = bytes at slot +0x58 */
        if (check_kernel_addr(target_ptr))
                *leaked = target_ptr;            /* a binder_ref landed here */
}

Each target.ptr is now one of two things. If nothing reclaimed that particular slot, it still holds the freed node’s old ptr, a userspace-looking sentinel like 0xBEEF0000. If a sprayed ref did land there, it holds ref->node: an ARM64 kernel address, something like 0xffffff80_xxxxxxxx on this target. Telling them apart is a single range check: kernel pointers have their top bits set, userspace addresses start at 0x00:

bool check_kernel_addr(uint64_t addr) {
        return (addr >> 40) == 0xffffff;   /* ARM64 kernel range on this build */
}

The spray is probabilistic (the allocator doesn’t guarantee our refs reclaim our slots on any given attempt), so if no read comes back in the kernel range, we tear the whole arrangement down and try again. When one does, we’re done: we hold the live kernel address of a node we own.

‍

Enter Epoch

The spray either landed or it didn’t, and “didn’t” looks exactly like “landed but I read it wrong.” A target.ptr that comes back as 0xBEEF0009 (one of our 0xBEEF…-tagged nodes) could mean the slot was never reclaimed, that a ref landed in some other slot, or that our freed-versus-sprayed count was off by one. Telling those apart means correlating a fistful of kernel objects at once: which slot each freed node held, which process each sprayed ref went to, where each ref actually landed. Live, reading out where the refs went is itself allocator traffic that shifts the layout you’re trying to pin, and one wrong dereference walks off a stale pointer and reboots the box mid-measurement.

Record the run and the slot holds still.

One recording of the leak: a binder_node in slot 0xffffff812e0bb480 with ptr 0xBEEF0009 at +0x58, Go to memory on that operand, the freed slot now holding kernel pointer 0xffffff80da599d80, the access history showing node.ptr overwritten by the sprayed ref->node, then time-travel to the read ldr x9,[x26,#0x58] returning 0xffffff80da599d80, which resolves to a live binder_node whose +0x58 is 0xBAAFBAAF.

One freed slot, three identities: a binder_node, then a sprayed binder_ref, then a stale read that hands the ref’s kernel pointer to userspace.

The recording walks that identity shift one access at a time. A binder_node sits in slot 0xffffff812e0bb480; at +0x58 its ptr reads 0xBEEF0009, at +0x60 the cookie 0x42424242. Right-click the +0x58 operand, pick Go to memory, and the Memory view opens on that exact slot without typing an address. By now the slot is freed, and +0x58 no longer holds 0xBEEF0009: it holds 0xffffff80da599d80, a kernel pointer.

The access history explains how a pointer got into a field that held a sentinel. First 0xBEEF0009 is written, the node’s own ptr. Then the spray reclaims the freed slot as a binder_ref and writes ref->node = 0xffffff80da599d80 into that same +0x58. Then a different process reads +0x58 back as if the slot were still the node.

Double-click that post-free read and you time-travel to it: ldr x9, [x26, #0x58], with x26 holding the freed slot, and x9 comes back 0xffffff80da599d80, a live kernel address handed to userspace. Follow the address one step further: it is itself a live binder_node, its own +0x58 reading 0xBAAFBAAF and +0x60 the 0x42424242 cookie.

Post 1’s trace showed the opposite case. There, nothing wrote the freed slot, so the stale read returned the node’s own leftover bytes. Here the spray’s write is the whole point, and the victim read surfaces exactly what the ref wrote. The ref->node write and the read that discloses it both sit at fixed points in the same recording, ready to cross-reference on every replay.

HOSTED TRACE. Try it yourself. This leak is hosted free at ttd.eshard.com. Open Binder UAF (CVE-2023-20938) - leak a kernel address, where the spray’s ref->node write and the leak read are already bookmarked. Land on the ref-write, then step to the read that pulls that kernel address back out of the freed slot, in your own browser or by pointing an agent at it over Epoch’s MCP.

‍

To go further

A few threads we pulled loose from the main line, for readers who want the depth.

The rest of the binder_ref, and why +0x58 specifically

The overlap falls out of the struct’s shape: the fields ahead of node (a 16-byte binder_ref_data, two 24-byte rb_nodes, a 16-byte hlist_node, and the 8-byte proc) sum to exactly 0x58, so node lands right where a binder_node’s ptr sits, and death immediately after at 0x60 lands on the binder_node’s cookie. So both fields the stale read copies out come from the ref: node as target.ptr and death as cookie. death is normally NULL, since no sub-client asks for a death notification, so +0x58 is the field that carries the address.

The choreography behind the spray

The main line says “share one node with twenty processes,” which glosses over some plumbing. Post 1’s minimal trigger collapsed everything into a single Server (the node owner) and Client (the ref manipulator); the leak needs more hands, but the kernel rule forcing the split is the same.

First, two separate populations of nodes are in play, and they’re easy to conflate:

  • One live target node (0xBAAFBAAF), created up front, kept alive throughout the leak, and never touched by the UaF. It is the node whose address we’re after; every sprayed ref points at it.
  • A batch of throwaway victim nodes, ten of them, freed exactly as Post 1 freed its node. Their twenty freed slots (ten nodes + ten refs) are what the spray reclaims. We never read their contents for the address; they exist only to open slots.

Four roles cooperate, all ours:

  • an orchestrator owns the victim nodes, triggers their UaF free, and later drains the reads;
  • a node owner owns the live 0xBAAFBAAF node and does the sharing that mints the refs;
  • a main client spawns the sub-clients and releases them at the end; it also primes 0xBAAFBAAF for the next post, queuing a pending synchronous transaction against it (the dangling reference the write primitive builds on) and then running Post 1’s error path on it;
  • a pool of twenty sub-clients, each a separate process, so each forces a distinct binder_ref.

“Orchestrator triggers the UaF free” is shorthand for the entire Post 1 dance: freeing the ten victims is that same two-process trigger, run behind the scenes. The orchestrator drives it through its own client, just like last post’s Server/Client pair. The node owner first collects a handle back to every client, then shares the target node with the main client before the UaF so that allocation doesn’t compete with the spray. Then, on a signal, in the moment right after the free, it fires the twenty sends that mint the refs. Pipe-based signals serialize the whole thing so the spray always falls after the free and before the read. It reads as a lot of processes, but every one exists to satisfy a single kernel rule: one ref per process per node.

Simplified leak choreography: free, then spray refs into the freed slots, then drain the reads

Simplified leak choreography: free, then spray refs into the freed slots, then drain the reads

The spray’s timing, stripped to essentials: the free (orchestrator), the spray that reclaims the freed slots (node owner → sub-clients), and the reads that surface the leaked address (orchestrator), serialized by signals so the spray always falls in the window between.

Why ten nodes and twenty refs

The counts are chosen so the freelist ends up exactly balanced. Freeing one node through the UaF releases the binder_node and the binder_ref the client held on it, two kmalloc-128 objects per freed handle. So 10 freed handles open 20 slots, and we spray exactly 20 refs to fill them. Too few refs and many freed slots stay empty, lowering the odds any stale read hits a ref and forcing more retries. The next post’s write primitive also runs its own, much larger heap spray, and it needs a clean freelist to work against. If the leak phase left orphaned free slots lying around, that later spray would scatter into them and miss its target. Balancing 20 = 20 here reclaims every slot the leak opened, handing the write primitive a tidy heap.

One address, then everything

The leak buys a single fact, the runtime address of one binder_node we control, but that fact is the pivot for the whole rest of the chain. The main client leaves a pending transaction queued against the target node. When the next post frees that node, the transaction becomes a dangling pointer to it in the owner’s queue, the same kind of dangling reference Post 1 left behind, except we queue it synchronously here, for a reason the next post relies on. That slot, at an address we know, is what the next post’s write primitive builds on.

It’s all build-specific

The mechanism is general; the numbers are not.

Constant Value In
binder_ref->node +0x58 struct binder_ref
binder_node.ptr +0x58 struct binder_node
Object size 128 bytes kmalloc-128
Kernel-range test (addr >> 40) == 0xffffff ARM64

All of it is Android 12 on a Pixel 6, kernel 5.10, arm64. On another build the structs are laid out differently and the offsets would have to be re-derived; the idea of finding a same-cache object whose useful pointer coincides with the read offset carries over, the constants don’t.

‍

Next

We now hold a live kernel address we chose: a binder_node whose slot we can free and whose location we know. In the next post we spray a fake binder_node into that slot and trip the kernel into unlinking it, converting the leak into a constrained write primitive: an hlist_del that writes *WHERE = WHAT (and, as a side effect of the unlink, *(WHAT + 8) = WHERE). The address this post leaked is the slot we’ll plant that fake node in, and the value that lets the forgery survive the kernel’s own safety check.

‍

AI-ASSISTED REVERSE ENGINEERING

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.

Thanks! We will contact you shortly.
Something went wrong. Please try again, or write to us at contact@eshard.com