---
id: 9949
canonical_url: "https://phying.com/item/9949"
title: "[CVE-2023-20938] Kernel UaF to Root #1: Freeing an Object That’s Still in Use"
type: "vulnerability"
cve: "CVE-2023-20938"
cvss: null
severity: "high"
vendor: "Google"
product: "Android Kernel (Binder)"
affected_versions: "Android 12, Kernel 5.10 (Pixel 6)"
source_domain: "eshard.com"
source_url: "https://www.eshard.com/blog/kernel-uaf-1-trigger"
published_at: "2026-10-15T00:00:00Z"
snapshot_at: "2026-10-26T05:50:00Z"
archived_by: "Phying News (https://phying.com/item/9949)"
lang: "en"
---

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

*Author: Quentin Buathier · Published: October 2026 · Source: [eshard.com](https://www.eshard.com/blog/kernel-uaf-1-trigger)*

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](https://nvd.nist.gov/vuln/detail/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](https://cdn.prod.website-files.com/69b01787a35e34bba5ee9674/6ace5979d34e2045a73dcb0f_64564282.png)  
*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](https://cdn.prod.website-files.com/69b01787a35e34bba5ee9674/6ace5979d34e2045a73dcb12_fa5d5555.png)  
*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](https://cdn.prod.website-files.com/69b01787a35e34bba5ee9674/6ace5979d34e2045a73dcb19_b28530c8.png)  
*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](https://cdn.prod.website-files.com/69b01787a35e34bba5ee9674/6ace5979d34e2045a73dcb1f_76901eab.png)  
*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.

| Step | Action | ref.strong | local_strong_refs | internal_strong_refs | Kernel function |
|---|---|---|---|---|---|
| 1 | Server shares node (`BINDER_TYPE_BINDER`) | **1** (created) | 0 | **1** | `binder_translate_binder` → `binder_inc_ref_for_node` → `binder_inc_node(1,1)` |
| 2 | Client `BC_ACQUIRE` | **2** | 0 | 1 | `binder_inc_ref_olocked` (strong 1→2, no `inc_node`) |
| 3 | Client `BC_FREE_BUFFER` | **1** | 0 | 1 | `binder_dec_ref_for_handle` (strong 2→1, no `dec_node`) |
| 4 | Client sends one-way txn to node | 1 | **1** | 1 | `binder_get_node_refs_for_txn` → `binder_inc_node(1,0)` |
| 5 | Client sends error txn (misaligned) | 1 | **0** | 1 | `binder_transaction_buffer_release` → `binder_dec_node(1,0)` |
| 6 | Client `BC_RELEASE` | **0 → destroyed** | 0 → freed | **0 → freed** | `binder_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](https://phying.com/item/9949) · Original source: [eshard.com](https://www.eshard.com/blog/kernel-uaf-1-trigger)
