Curated security research, vulnerabilities, advisories and tools for practitioners.

[CVE-2023-20938] Kernel UaF to Root #1: Freeing an Object That’s Still in Use

Summary

A use-after-free in Android’s Binder driver, recorded once and replayed at will. Part 1 of a series building a full kernel exploit chain with time travel debugging.
CVE
CVE-2023-20938
Published
Collected

📦 Full-Text Content Snapshot

R2 Archive · Full Article Preserved242 lines · 20.9 KB
📄 Raw .md⬇️ Download .md

Author: Quentin Buathier · Published: October 2026 · Source: eshard.com

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.)

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

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

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. On top of that channel, the Client shares one of its own throwaway nodes with the Server 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.

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). 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

Figure 2: Transaction buffer layout: data buffer holding flat_binder_objects and offsets array pointing at them.

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.

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 release is binder_transaction_buffer_release:

c
case BINDER_TYPE_BINDER:
case BINDER_TYPE_WEAK_BINDER: {
        struct flat_binder_object *fp;
        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:

c
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 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.

Reaching that path from userspace

Triggering it is straightforward: we send a transaction whose offsets_size simply isn’t a multiple of 8:

c
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 */

/* 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.

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

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

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, distinct from srv_handle. Using it, and before triggering the error, the Client queues an ordinary one-way transaction at the node:

c
/* 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);

This 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 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 (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

Figure 4: Reference-count timeline across the node’s lifecycle into the use-after-free.

Reading a freed object

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

c
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!

Time Travel Debugging with Epoch

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, ASCII BBBB).

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-clicking that post-free read, #1,573,750, time-travels 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.

Deep Dive: Binder Internals

The real reference counts

A binder_node carries two strong counts:

  • local_strong_refs is raised when a transaction targets the node (the pending one-way transaction does exactly this).
  • internal_strong_refs is raised when a remote binder_ref acquires the node, counting refs that currently hold a strong reference.

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

StepActionref.stronglocal_strong_refsinternal_strong_refsKernel function
1Server shares node (BINDER_TYPE_BINDER)1 (created)01binder_translate_binder → binder_inc_ref_for_node → binder_inc_node(1,1)
2Client BC_ACQUIRE201binder_inc_ref_olocked (strong 1→2, no inc_node)
3Client BC_FREE_BUFFER101binder_dec_ref_for_handle (strong 2→1, no dec_node)
4Client sends one-way txn to node111binder_get_node_refs_for_txn → binder_inc_node(1,0)
5Client sends error txn (misaligned)101binder_transaction_buffer_release → binder_dec_node(1,0)
6Client BC_RELEASE0 → destroyed0 → freed0 → freedbinder_dec_ref_olocked → binder_dec_node(1,1) → binder_free_node → kfree

Step 5 is the bug: local_strong_refs goes 1 → 0 with no prior increment. Step 6 drops ref.strong to 0, which drops internal_strong_refs to 0, freeing the node synchronously.

Why the free is synchronous

A binder_node carries a flag: has_strong_ref. When a node first gains a strong reference, the kernel queues a BINDER_WORK_NODE item on the owner’s (Server’s) work queue. If processed on a read, it flips has_strong_ref to 1, causing future frees to be deferred.

We avoid this: after handing out nodes with write-only ioctls (read_size = 0), the Server never reads its queue while nodes are alive. The BINDER_WORK_NODE job is never processed, has_strong_ref stays 0, and the Client’s BC_RELEASE frees the node synchronously, while the pending transaction remains queued as a dangling pointer!

Inside the binder_node

c
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 and async_todo carry the struct on to 0x80 */
};

Heap reuse and hardening

The target kernel includes SLUB defenses: CONFIG_SLAB_FREELIST_HARDENED and CONFIG_SLAB_FREELIST_RANDOM. Neither prevents this exploit: we never corrupt freelist pointers. We reclaim freed slots through standard kmalloc calls whose last-in-first-out (LIFO) behavior reliably returns the released chunk.


📦 Full-Text Markdown Snapshot archived by Phying News · Original source: eshard.com