Software Security

Kernel UaF to Root #1: Freeing an Object That’s Still in Use

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

The moment things go wrong in kernel exploit development, the evidence you needed disappears.

You trigger the bug. The heap doesn’t land the way it did last time. A pointer you were counting on now aims somewhere else, the kernel panics, and the machine reboots, taking with it the exact state you were trying to inspect. So you run it again. And it behaves a little differently, because the memory layout that decides success or failure is never quite the same twice.

Most of the work happens right here: re-running, re-triggering, and hoping this attempt reproduces the last one closely enough to learn anything from it. It’s debugging by re-run-and-pray, and it’s slow precisely when you most need it to be fast.

The bug we’ll use to show all of this is CVE-2023-20938, a use-after-free (UaF) in Android’s Binder driver, patched and publicly disclosed. We run it on a Pixel 6 with Android 12 and kernel 5.10 (arm64). Nothing here is a zero-day. What’s new here is watching it happen.

So instead of running it over and over, we recorded it once.

Time travel debugging captures a complete execution, every instruction and every memory access, into a trace you can replay forwards and backwards and query at any point. It records the whole machine at once, so both the processes we run and the kernel driver they attack land in one trace, and you follow the bug across the userspace/kernel boundary without attaching a second debugger. A crash stops destroying your evidence, too: the instant before it is still there in the recording, exactly as it happened, ready to rewind to. (The tool we used is eShard’s Epoch; you’ll watch it work throughout the series, not take our word for it.)

Over the next posts we build the entire exploit chain, recording each stage the same way:

  1. Trigger the use-after-free.
  2. Reliably leak a live kernel heap address, so KASLR (kernel address-space layout randomization) no longer hides where our objects live.
  3. Turn the dangling pointer into a constrained “unlink” write.
  4. Escalate that into an arbitrary read.
  5. Walk kernel structures to zero the right credentials and land a root shell.

Every one of those steps is usually hand-waved in a writeup (“spray until it lands,” “the pointer is stale, trust me.”). We’re not going to assert any of it. We’re going to rewind to the exact instruction and show it: the free, the reallocation that reclaims the slot, the stale read, the overwrite.

We start where every exploit starts, by proving the bug is real: send the Binder driver a malformed transaction that trips its error path into over-releasing one of our own objects, so the kernel frees it while a pointer to it is still queued, then read that freed slot back through the queued delivery. Free it, then use it, and catch the exact moment the freed memory is read. The trick is getting the kernel to do the freeing for you.

‍

A minute on Binder

Binder is Android’s core IPC mechanism, the channel where almost every interaction between an app and the system travels over. Processes exchange transactions (messages), and a transaction can hand another process a reference to one of your objects. The kernel tracks each shared object as a binder_node, and it reference-counts it: while something holds a reference the node stays alive; when the last reference goes away, the kernel frees it.

The reference that keeps it alive lives in the other process. When you share an object, the kernel keeps the binder_node in the owner’s process and hands the other process a separate object (a binder_ref, reached through a userspace integer called a handle) pointing back at it. Node and ref each carry their own reference count. This two-count picture is a simplification, refined in The real reference counts below, but faithful enough to follow the bug. The diagrams below show a ref count and a node count moving independently.

Userspace drives all of this through a single ioctl (BINDER_WRITE_READ): the commands it sends the driver are the BC_* codes (BC_TRANSACTION, BC_ACQUIRE, BC_RELEASE), and what the driver hands back are the BR_* codes (BR_TRANSACTION, BR_FAILED_REPLY).

Binder object model: the binder_node lives in the owner (Server), the binder_ref in the other process (Client), reached via a handle

Binder object model: the binder_node lives in the owner (Server), the binder_ref in the other process (Client), reached via a handle

A shared object is two kernel objects: the binder_node in the owner’s process and a binder_ref in the other, reached from userspace through a handle integer. Each carries its own reference count, and the node is freed only when both reach zero.

An unprivileged app talks directly to a privileged kernel driver, and that driver’s safety rests on getting a reference count exactly right. CVE-2023-20938 is where it doesn’t.

Two processes cooperate to trigger it, and both are ours: this isn’t an app attacking another app; the target is the kernel driver itself. We run a Server that owns the node and a Client that manipulates its reference count. The two roles have to live in separate processes, for a reason we’ll see once we look at the cleanup.

The dance opens with a bit of bootstrapping. First the two processes have to find each other and open a Binder channel at all, just to talk, before any exploit step. That happens the ordinary Android way: one side publishes a service, the other looks it up, and each ends up holding a handle to the other. That handshake is where the Client gets its handle to the Server, srv_handle in the code below. We take it for granted here and unpack it in How the two processes find each other. On top of that channel, the Client shares one of its own throwaway nodes with the Server (the reverse of the share that comes next) for one reason only: it leaves the Server holding a handle it can later use to send the soon-to-be-freed node to the Client. It’s a return channel.

‍

Anatomy of a normal transaction

Before we break a transaction, it helps to know what a healthy one is made of, because the bug is entirely about mishandling these pieces.

Every transaction is addressed to a target: a handle the sender holds, which the kernel resolves to a destination binder_node and the process that owns it. That’s how the driver knows whose Binder region to deliver into. Keep that in mind; the bug turns on which process a transaction is addressed to, separately from which objects it carries.

A transaction carries two things: a data buffer of raw bytes (the message payload) and an offsets array that tells the kernel where the binder objects sit inside that data. Each entry in the offsets array is an 8-byte binder_size_t holding one object’s byte position within the data buffer, and offsets_size is the length of that array, in bytes. So two objects embedded in the payload mean an offsets array of 2 × 8 = 16 bytes, each entry pointing at one of them.

What an offset points at is a flat_binder_object, the in-band descriptor for one shared object. Its type says what kind: BINDER_TYPE_BINDER (the sender is handing over one of its own objects, named by a raw pointer) versus BINDER_TYPE_HANDLE (the sender is passing along a handle to an object it received earlier). (The type field spans other kinds too, such as weak variants and the file-descriptor and buffer types BINDER_TYPE_FD/FDA/PTR, none of which we need here.) Alongside the type sit the object’s pointer or handle and a cookie, an owner-chosen value the kernel carries with the node.

Transaction buffer layout: a data buffer holding flat_binder_objects and an offsets array whose entries point at them

Transaction buffer layout: a data buffer holding flat_binder_objects and an offsets array whose entries point at them

A well-formed transaction: the offsets array’s 8-byte entries hold byte positions into the data buffer, each pointing at a flat_binder_object. Crossing into the target, the kernel translates each object: BINDER_TYPE_BINDER becomes BINDER_TYPE_HANDLE.

When a transaction is sent, the kernel copies the whole thing (data and offsets) into a buffer it allocates inside the target process’s mmap’d binder region. That allocation is t->buffer, the transaction buffer, and it belongs to the receiver: once the receiver has processed the message it hands the buffer back with BC_FREE_BUFFER. (Whether the sender waits for the receiver depends on the transaction: a one-way transaction is fire-and-forget, so the sender doesn’t wait for a reply, while a synchronous transaction blocks the sender until the reply comes back.)

Crossing into the target, the kernel doesn’t just copy bytes; it translates every object. It walks the offsets array, and for each flat_binder_object it rewrites a BINDER_TYPE_BINDER into a BINDER_TYPE_HANDLE, looks up (or creates) the owning binder_node in the sender, and creates a reference to it, a binder_ref, in the target, so the node can’t vanish while the buffer still refers to it.

So a carried object’s reference is bound to the buffer’s lifetime. That is why handing the buffer back with BC_FREE_BUFFER later drops that reference, and why, if the Client wants a reference that outlives the buffer, it must take an independent one of its own with BC_ACQUIRE first.

‍

The bug, in the kernel’s own words

When a transaction fails partway through, the kernel has to roll back: release whatever it already set up in the transaction buffer. That rollback releases a node reference:

/* drivers/android/binder.c — binder_transaction() */
if (!IS_ALIGNED(tr->offsets_size, sizeof(binder_size_t))) {
        /* ... */
        return_error = BR_FAILED_REPLY;
        goto err_bad_offset;
}
/* the object-translation loop runs *below* this point... */

err_bad_offset:
        /* ...but on error we jump straight here, into the rollback: */
        binder_transaction_buffer_release(target_proc, NULL, t->buffer,
                                          buffer_offset, true);
/* binder_transaction_buffer_release() — for each object in the buffer */
case BINDER_TYPE_BINDER:
case BINDER_TYPE_WEAK_BINDER: {
        struct flat_binder_object *fp = to_flat_binder_object(hdr);
        struct binder_node *node = binder_get_node(proc, fp->binder);
        /* ... */
        binder_dec_node(node, hdr->type == BINDER_TYPE_BINDER, 0);
        binder_put_node(node);
} break;

Look at the order. The check that fails, a misaligned offsets_size, jumps to err_bad_offset before the loop that would normally translate the transaction’s objects and take a reference on each. So the forward path never incremented these nodes. But the rollback doesn’t know that: it walks the buffer, finds a BINDER_TYPE_BINDER object, looks the node up in target_proc, and calls binder_dec_node on it anyway.

A decrement with no matching increment: a failed transaction’s cleanup releases a reference it never took.

The rollback reaches that never-translated object because of how it decides where to stop. binder_transaction_buffer_release computes the end of its walk like this:

off_end_offset = is_failure && failed_at ? failed_at
                                         : off_start_offset + buffer->offsets_size;

failed_at is meant to say “we got this far before an object failed,” so clean up everything up to here and nothing past it. But we jumped to err_bad_offset on the alignment check, before the translation loop ever ran, so failed_at (the caller’s buffer_offset, passed straight through) is still 0. Zero is falsy, so the ternary takes the other branch: off_start_offset + offsets_size, the whole buffer. “Failed before processing anything” gets read as “clean up everything.” The loop walks every object the buffer describes and releases each one.

And it resolves each of them in the transaction’s target process (binder_get_node(target_proc, fp->binder), the Server), not in the sender’s. That’s the concrete reason the two processes must differ: the malformed transaction names the node by the Server’s id, and only a lookup in the Server’s node table finds it.

Contrast that with a rollback doing its job. When the translation loop has run, every object it touches was rewritten to BINDER_TYPE_HANDLE and had a reference taken. On error, the cleanup sees BINDER_TYPE_HANDLE, calls binder_dec_ref_for_handle, and unwinds exactly that reference, which is correct. The bug only bites because our objects were never translated. They are still BINDER_TYPE_BINDER, so the cleanup takes the binder_dec_node path instead.

‍

‍Reaching that path from userspace

Triggering it is almost anticlimactic: we send a transaction whose offsets_size simply isn’t a multiple of 8. (bs below is our binder state: the small struct that bundles the open /dev/hwbinder file descriptor with its mmap’d region, threaded through every call.)

struct flat_binder_object fbo = { 0 };
fbo.hdr.type = BINDER_TYPE_BINDER;
fbo.binder   = 0xBEEF0000;   
/* name the node by the Server's OWN id, not the handle we received (see Inside the binder_node) */

/* 15 bytes: 8 bytes of real offset (0) + 7 bytes of poison */
uint8_t offs[2 * sizeof(uint64_t) - 1];
memset(offs, 0x00, sizeof(uint64_t));                        /* offset = 0 */
memset(offs + sizeof(uint64_t), 0xFF, sizeof(uint64_t) - 1); /* poison     */

/* addressed to srv_handle (the Server): the rollback resolves fbo.binder in the
 * *target* process's node table, which must be the Server that owns the node.
 * offsets_size = 15 is misaligned -> err_bad_offset -> the rollback above */
binder_transaction(bs, /*one_way=*/true, srv_handle,
                   &fbo, sizeof(fbo), offs, sizeof(offs));

Fifteen bytes is deliberate: offsets_size is a byte count that must be a multiple of 8, so 15 is malformed.

The rollback doesn’t divide it by 8 and count objects; it walks the array with a loop that reads one 8-byte offset per iteration for as long as the running position stays below off_start_offset + offsets_size. At 15 bytes that condition is true exactly twice. The first iteration reads a real offset (0) pointing at our flat_binder_object and performs the binder_dec_node we came for; the second reads our 7 poison bytes plus one trailing, uninitialized byte of kernel buffer as a single, absurd offset, finds nothing there, and the loop ends. The poison is what pins the decrement to exactly one. Without it, leftover buffer bytes could form a second valid offset and throw the count off.

Valid offsets_size (16) vs the exploit’s misaligned 15 with 7 poison bytes

Valid offsets_size (16) vs the exploit’s misaligned 15 with 7 poison bytes

A valid offsets_size is a multiple of 8 and the transaction runs normally; 15 is misaligned, so the kernel bails straight to the rollback that over-releases the node.

‍

One reference too few

By itself, one unbalanced decrement isn’t a free. We set the stage first. When the Server shared the node, the kernel handed the Client its own handle to that node, uaf_handle below, distinct from the srv_handle that merely reaches the Server’s process. Using it, and before triggering the error, the Client queues an ordinary one-way transaction at the node:

/* a one-way transaction targeting the node leaves a pointer to it queued */
binder_transaction(bs, /*one_way=*/true, uaf_handle, NULL, 0, NULL, 0);

which raises the node’s own reference count to 1 and leaves that transaction sitting in the Server’s queue, still pointing at the node. Being one-way, it sits unread in the Server’s queue (the process’s todo list); the Client never waits. But that count isn’t the only thing keeping the node alive: the Client’s handle holds a separate, legitimate reference, the BC_ACQUIRE it took when it received the node.

The acquire and the buffer-free go together. A received transaction occupies room in the Client’s Binder mmap region, so the Client hands the buffer back (BC_FREE_BUFFER) to reclaim it; across the real exploit’s many nodes, leaving buffers unfreed would exhaust that region and stall the sends that follow. But freeing the buffer would normally drop the delivery’s reference and destroy the ref; the earlier BC_ACQUIRE is exactly what lets the ref survive the free. So neither move is optional and neither stands alone: acquire to keep the ref, free to keep the mmap space. The error-path decrement then zeroes only the other reference, the one the queued transaction took. The node survives, because the handle’s reference is still there.

Then the Client does the most ordinary thing imaginable: it releases the handle (a BC_RELEASE). That drops the last reference, and the node is freed synchronously, right there in the release call, with no deferral and no waiting on anyone else. The illegitimate decrement already happened, so the node frees while the transaction is still queued and pointing at it.

The node is gone. The pending transaction still points at it.

Reference-count timeline across the node’s lifecycle into the use-after-free

Reference-count timeline across the node’s lifecycle into the use-after-free

Two counts govern the node’s life: the reference held by the Client’s handle (ref) and the node’s own count from the queued transaction (node). The bug zeroes only the second; the node frees when the handle is released. After that, a different allocation can reuse the slot, and the Server’s queued read hands back whatever that new occupant wrote.

‍

Reading a freed object

Now the Server just does what a server does and reads its queue:

struct binder_transaction_data *tr = binder_read_until(bs, BR_TRANSACTION);
*cookie     = tr->cookie;       /* bytes at freed-node offset +0x60 */
*target_ptr = tr->target.ptr;   /* bytes at freed-node offset +0x58 */

Delivering that pending transaction makes the kernel fill tr->target.ptr and tr->cookie from the transaction’s target node: on every delivery the kernel copies that node’s ptr (+0x58) and cookie (+0x60) into the data it hands userspace. Normally that’s harmless bookkeeping, because the target node is alive. Here the target node is the one we just freed, so the same copy reads those fields straight out of released memory. That read is the use-after-free. At first the fields still hold their old values: we read back the very cookie the Server stamped on the node when it shared it (0x42424242), straight out of freed memory. But that slot is now an ordinary kmalloc-128 chunk back on the free list, and the next allocation of that size drops straight into it. Once something else lands there, the same queued read hands back that allocation’s bytes instead, the kernel serving one object’s memory through a pointer to an object it already released. That reused read is the opening we build on: for now it’s whatever happens to land in the slot; later we’ll make it hand back something we chose.

One thing we deliberately don’t do: hand that buffer back with BC_FREE_BUFFER, the way a receiver normally would. Freeing it would send the kernel’s cleanup walking back into the node we just freed, reading and maybe re-freeing memory that is no longer ours: a kernel panic, and the whole device reboots. A denial-of-service, at best. We don’t need it; the read has already happened. (A later post turns that very buffer-release into the write primitive, safely, once the freed slot holds something we control.)

With every step now on the table, here is the trigger back together:

Two-process UaF trigger sequence: Server (node owner) and Client (ref manipulator), both processes we control

Two-process UaF trigger sequence: Server (node owner) and Client (ref manipulator), both processes we control

The trigger end to end: the Server shares a node (0xBEEF0000); the Client acquires it under its own handle, raises then drops the node’s reference count, frees it, and the Server is left reading a pointer into freed memory.

‍

Enter Epoch

The free is deterministic: it lands at the same instruction every run. Whether a useful reallocation drops into the freed slot does not, because that turns on the exact state of the kernel heap at one instant, and that state is never the same twice. So the moment we care about is a heisenbug: freed memory coming back with its old bytes still in it. Add a printk to watch the heap and you perturb the allocations you’re trying to observe; break in with a debugger and the timing shifts; guess wrong and the kernel walks off garbage and reboots, taking the evidence with it.

Record the run once and it stops slipping away. You don’t re-trigger anything; you rewind: walk back to the kfree, open the slot in memory, and read its access history. After the free, nothing writes the slot again, so when the kernel reads it the original bytes are still sitting there. The layout is frozen in the recording and identical on every replay, so a moment you could never catch live becomes a fixed point you revisit at will.

One recording of uaf_simple: rewind to the kfree of slot 0xffffff80f0188a80, open the freed 128-byte object in Memory, watch its access history show no write after the free, then time-travel to the load that reads 0xBEEF0000 back out of the same slot.

The use-after-free in one recording: the free, the untouched slot, and the load that hands its bytes to the app.

The recording lays the chain out move by move. kfree runs on the slot, and its pointer argument, 0xffffff80f0188a80, names the 128-byte object going back to the allocator. Open that address in the Memory view and the whole object is still legible: at +0x58 the node’s ptr field, 0xBEEF0000, at +0x60 the cookie 0x42424242, the ASCII BBBB we stamped.

The slot’s access history settles whether anything ever reclaims it. A single kfree() #1,047,683 divider splits the pre-free accesses from everything after, and on the far side there is no write, only a later read. The slot is never re-allocated.

Double-click that post-free read, #1,573,750, and you time-travel straight to it: ldr x9, [x26, #0x58], with x26 holding 0xffffff80f0188a80, the same slot kfree released, read again after the free. x9 comes back 0xBEEF0000. The kernel has handed the app the freed object’s own bytes.

HOSTED TRACE. Try it yourself. This recording is hosted free at ttd.eshard.com. Open Binder UAF (CVE-2023-20938) - minimal UAF demo, where the free and the stale read are already bookmarked. Rewind to the kfree, then step to the read that pulls 0xBEEF0000 back out of the freed slot, in your own browser or by pointing an agent at it over Epoch’s MCP. (The single-node UaF with no spray, hosted at its smallest; Post 2’s leak is the series’ flagship freemium trace.)

‍

To go further

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

How the two processes find each other

Before any of the trigger, the Server and Client have to establish a Binder channel, the same problem any two Android processes face when they want to talk. Neither knows the other’s binder_node addresses, and handles are process-local, so they can’t just name each other. Two steps bootstrap it:

  1. Name-service lookup. The Server registers under a known name; the Client looks that name up and receives a handle to the Server, which is srv_handle. On this target the lookup goes through /dev/hwbinder and a token-based service manager (ITokenManager); the details differ across Binder’s domains, but the shape is always the same: one well-known endpoint hands out handles to everyone else.
  2. Return channel. That lookup is one-directional: the Client can now reach the Server, but not the reverse. So the Client sends the Server a throwaway node of its own (an ordinary BINDER_TYPE_BINDER transaction), and the kernel mints a binder_ref in the Server pointing back at the Client.

Now the channel runs both ways: the Server can hand the soon-to-be-freed nodes to the Client, and the Client can send the Server its transactions (the pending one, the malformed one, the release). None of this is the bug; it’s just how two unrelated processes come to hold handles to each other, purely so they can talk.

‍

The real reference counts

The main line talks about a single “node” count, because that reads cleanly. The kernel’s actual model has three counters, and the bug lives in how they interact.

A binder_node carries two strong counts of its own. local_strong_refs is raised when a transaction targets the node (the pending one-way transaction does exactly this), and when the owner processes a BINDER_WORK_NODE job. internal_strong_refs is raised when a remote binder_ref acquires the node, and it counts refs that currently hold a strong reference, not the sum of their strong counts.

Alongside those sits the binder_ref’s own ref.strong, over in the Client’s process. The node is freed only once both node counts are 0, the refs hlist is empty, and no weak or temporary references remain.

Here is the full trace for one UaF node, one row per step:

Action ref.strong local_strong_refs internal_strong_refs Kernel function
Server shares the node (BINDER_TYPE_BINDER) 1 (created) 0 1 binder_translate_binder → binder_inc_ref_for_node → binder_inc_node(1,1)
Client BC_ACQUIRE 2 0 1 binder_inc_ref_olocked (strong 1→2, no inc_node)
Client BC_FREE_BUFFER 1 0 1 binder_dec_ref_for_handle (strong 2→1, no dec_node)
Client sends one-way txn to the node 1 1 1 binder_get_node_refs_for_txn → binder_inc_node(1,0)
Client sends the error txn (misaligned offsets) 1 0 1 binder_transaction_buffer_release → binder_dec_node(1,0)
Client BC_RELEASE 0 → destroyed 0 → freed 0 → freed binder_dec_ref_olocked → binder_dec_node(1,1), then cleanup → binder_free_node → kfree

Two rows are the whole story. Step 5 is the bug: local_strong_refs goes 1 → 0 with no increment behind it. Step 6 is where the node actually dies: BC_RELEASE drops ref.strong to 0, which drops internal_strong_refs to 0, unlinks the ref from the node’s refs hlist, and, with every count now zero, frees the node synchronously, right there in the release handler.

Notice what doesn’t move internal_strong_refs: the BC_ACQUIRE at step 2 (ref.strong 1→2) and the BC_FREE_BUFFER at step 3 (2→1) leave it at 1 throughout, because it counts refs with strong > 0, not the strong tally itself; only the 0→1 and 1→0 transitions of ref.strong touch it. That’s exactly why the main text’s single ref counter is a faithful stand-in: across this sequence, what keeps the node alive on the handle side is just “does the Client still hold a strong ref,” one bit of state, not a running count.

‍

Why the free is synchronous

Besides its reference counts, a binder_node carries a flag: has_strong_ref. When a node first gains a strong reference, the kernel queues a small bookkeeping job, a BINDER_WORK_NODE item, on the node owner’s (the Server’s) work queue. The owner would process that job the first time it read its own binder queue, flipping has_strong_ref to 1. Once that flag is set, dropping the last reference no longer frees the node immediately; the kernel defers the free and notifies the owner instead.

We avoid the whole thing. After handing out the nodes with write-only ioctls, our Server just waits; it never reads its queue while those nodes are alive. A binder write issued with read_size = 0 never blocks and never drains the todo list; only a read would process queued work, and our Server never reads. So the nodes’ BINDER_WORK_NODE jobs are never processed, has_strong_ref stays 0, and the Client’s BC_RELEASE frees the node synchronously.

A synchronous free is also what makes the bug possible at all. Suppose the free were deferred. Deferral only kicks in once has_strong_ref is set, and the only thing that sets it is the Server reading its queue to process that BINDER_WORK_NODE job. But that same read also delivers the pending one-way transaction, the one we need left queued to read the freed slot later, and it delivers it now, while the node is still alive. A deferred-free world is therefore one where the Server has already consumed the pending transaction against a live node: no dangling pointer, no stale read, no use-after-free. The single act of not reading buys both halves at once: it keeps the pending transaction queued as a dangling pointer and holds has_strong_ref at 0 so the free lands synchronously, the node dying while that transaction still points at it. (The timeline figure above tracks the node count the bug attacks.)

‍

Inside the binder_node

Here is the part of the layout that matters, with byte offsets on this build:

struct binder_node {                     /* kmalloc-128 — 128 bytes */
        int                 debug_id;        /* 0x00 */
        spinlock_t          lock;            /* 0x04 */
        struct binder_work  work;            /* 0x08  the BINDER_WORK_NODE job */
        union {
                struct rb_node    rb_node;   /* 0x20  live: linkage in proc->nodes (rb-tree) */
                struct hlist_node dead_node; /* 0x20  dead: next/pprev on the dead-nodes list */
        };
        struct binder_proc *proc;            /* 0x38 */
        struct hlist_head   refs;            /* 0x40  every binder_ref pointing here */
        int  internal_strong_refs;           /* 0x48 */
        int  local_weak_refs;                /* 0x4c */
        int  local_strong_refs;              /* 0x50  the count the bug drives to 0 */
        int  tmp_refs;                       /* 0x54 */
        binder_uintptr_t ptr;                /* 0x58  <- stale read surfaces this as tr->target.ptr */
        binder_uintptr_t cookie;             /* 0x60  <- and this as tr->cookie */
        /* flag bitfields (has_strong_ref, has_async_transaction, ...) and
         * the async_todo list carry the struct on to 0x80 */
};

A binder_node is a 128-byte object (kmalloc-128). The two fields the stale read surfaces are the node’s ptr at +0x58 (delivered to userspace as tr->target.ptr) and cookie at +0x60. Those are the last two 8-byte data fields before the struct’s flag-and-bookkeeping tail (bitfields such as has_strong_ref and has_async_transaction, then the async_todo list), which carries the struct on to +0x80.

Earlier in the object, the pointers at +0x20/+0x28 are a union with two meanings. While the node is alive, a full rb_node (three words, +0x20..+0x38) holds its red-black-tree linkage inside the owner’s node tree (proc->nodes, keyed on ptr). Once the node is dead, a 16-byte hlist_node overlay reuses +0x20/+0x28 as plain next/pprev list pointers for the kernel’s dead-nodes list. Same storage, two structural meanings depending on whether the node is live.

When the Server shares the node it assigns it a binder id (0xBEEF0000 in our code). The kernel hands the Client a different, kernel-assigned handle for that node. The kernel never shows the Client 0xBEEF0000 (the Client knows the value only because we wrote both programs), and it uses its own handle for BC_ACQUIRE/BC_RELEASE and the pending transaction. The error-path trick is the exception: the kernel resolves the malformed transaction’s object in the target’s node table, by binder id, and the target is the Server.

The cookie does double duty. The 0x42424242 we stamp on every UaF node isn’t only our sentinel. When a node is shared, the kernel validates it: binder_translate_binder checks fp->cookie == node->cookie and rejects the object if they disagree, so a process can’t claim a node it doesn’t own. The very value we picked to recognize a freed slot later is the value the kernel insists on at translation time.

‍

Heap reuse and hardening

Reuse can happen on its own, but a heap spray (a burst of same-size allocations we control) is the controlled way to make an allocation of our choosing land in the freed slot, the difference between reading back whatever incidentally moved in and reading back bytes we placed there deliberately.

Freelist hardening doesn’t stop this. The target kernel ships the usual SLUB defenses, CONFIG_SLAB_FREELIST_HARDENED (freelist pointers stored XOR-obfuscated with a per-cache secret) and CONFIG_SLAB_FREELIST_RANDOM (new slab pages start in a shuffled order). Neither touches us. We never read, forge, or corrupt a freelist pointer; we reclaim freed slots the ordinary way, through plain kmalloc calls whose last-in-first-out order is exactly what we rely on. Hardening that protects the freelist’s integrity has nothing to say about legitimate reuse.

Everything here is Android 12 on a Pixel 6, kernel 5.10, arm64. The mechanism is general, but the specifics aren’t: struct offsets like cookie at +0x60 are build-specific and would have to be re-derived for a different kernel.

‍

Further reading

Google’s Android Offensive Security team has an excellent deep-dive on Binder internals at androidoffsec.withgoogle.com/posts/binder-internals.

‍

Next

This post proved the kernel reads a freed slot back through a pointer it already released: with nothing else reclaiming the slot, the read returns the very bytes we stamped, straight out of freed memory. Those bytes are incidental, not attacker-chosen. In the next post we make the read useful: we spray the freed slot deliberately so it discloses the live kernel address of a node we control, getting past KASLR’s heap randomization and turning a dangling reference into a real target.

‍

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

‍