Software Security

Kernel UaF to Root #3: Forging a Node the Kernel Will Write Through

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

Last post ended with a single hard-won fact: the runtime address of a binder_node we own. We sprayed binder_ref objects into a freed slot, read one back through the dangling transaction, and the kernel handed us ref->node, a real 0xffffff80_… kernel pointer. We knew where one of our objects lived in kernel memory.

Knowing where is not the same as writing there. A leaked address is a coordinate; it does nothing on its own. This post turns that coordinate into a write: we make the kernel store a value we pick at an address we pick. That is the primitive everything after it builds on. With a controlled write into kernel memory, you can flip a policy flag, corrupt a pointer the kernel will later follow, or (the finale of this series) zero the fields in a cred struct that stand between an app and root.

But the write we get is not the clean write-what-where of a textbook exploit. We get it by tricking the kernel into unlinking a linked-list node, and an unlink does two stores, not one. Most of the craft in this post is understanding that constraint and building a fake object that satisfies it.

  1. Free the node we leaked, so its 128-byte slot returns to the freelist with a transaction still queued against it, a dangling pointer at an address we now know.
  2. Spray a fake binder_node of our own design into that exact slot.
  3. Release the queued transaction’s buffer (the one move Posts 1 and 2 carefully avoided), which drives the kernel’s teardown to unlink our fake node from a list. That unlink is the write.

‍

From a known address to a slot we own

Post 2 left us more than the address of our node 0xBAAFBAAF: it left one move whose purpose only makes sense now. With the node still alive, Post 2’s main client queued a synchronous transaction against it. That was not idle plumbing: the instant we free 0xBAAFBAAF, that queued transaction becomes a pointer to its freed slot, sitting in the owner’s work list waiting to be delivered, the same kind of dangling reference Post 1 built its whole trigger around.

Why a synchronous transaction specifically? (Synchronous meaning two-way, so the sender blocks for a reply, as opposed to Post 1’s fire-and-forget one-way sends.) That’s the detail Post 2 flagged and left hanging. A one-way transaction is asynchronous, and queuing it marks the target node: it sets the node’s has_async_transaction flag and can enqueue work on the node’s async_todo list, both living inside the node, in the very 128 bytes we are about to free and overwrite with a fake object. Releasing an async transaction’s buffer later drives the kernel into an async-cleanup branch that opens with BUG_ON(!node->has_async_transaction) and BUG_ON(node->proc != proc); on our fake node both are zeroed, so the assertion fires and the kernel panics on the spot, before it ever touches async_todo.

Post 1 got away with one-way sends because it only ever read two stale fields out of the delivered message and never released that buffer; here we do release it with BC_FREE_BUFFER, which is where that branch trips. A synchronous transaction is never marked async, so releasing its buffer skips the branch entirely, leaving only a plain queued delivery we can trigger on our own schedule, cleanly.

So we free it. The node’s last reference goes away, the kernel calls kfree on it, and its 128-byte slot drops back onto the kmalloc-128 freelist. Unlike in Post 1, we know that slot’s address. Post 1 freed a node at an address it never learned and read back whatever incidentally reused it; here a transaction still points at our freed slot, ready to make the kernel touch it on command.

That known-address dangling slot is the foothold. Drop an object of our own design into it, and the kernel, processing the still-queued transaction, reads our bytes as a binder_node’s fields. Choose those fields correctly and one step of the teardown becomes our write.

The leaked address will do double duty, as we’ll see: it is both the freed slot we drop the fake object into (so the kernel operates on our bytes) and a value we must write inside that object, because a sanity check forces the node to point back at its own interior. The address we spent all of Post 2 earning is spent twice.

‍

Why our allocation lands in that slot

Why should our spray land in that freed slot rather than anywhere in kernel memory? Post 2 already answered it. The kernel’s SLUB allocator serves every 128-byte request from one kmalloc-128 freelist, and reuse is last-in-first-out: free a slot and the next same-sized allocation pops it straight back. Free-then-immediately-allocate is steerable, not luck; Post 2 used this to drop a binder_ref into the freed node’s slot. Here we lean on it harder, because the write only fires if our fake node wins the slot, so we spray aggressively to beat any unrelated allocation to it. (Why freelist hardening doesn’t get in the way is in To go further.)

‍

Forging a binder_node

Now the design of the fake object. We are going to hand the kernel 128 bytes and let it believe they are a binder_node. When the queued transaction is finally delivered and its buffer released, the kernel will walk this object through its reference-counting teardown, and we want that teardown to end in a write we control. So every field has to be chosen for one of two jobs: steer the kernel down the code path that writes, or survive the checks along the way.

Here is the whole payload, byte-planned against the target kernel’s binder_node layout:

Fake binder_node payload: which of the 128 bytes carry the write, which steer the kernel onto the writing path, which keep it alive along the way, and which is the read-back sentinel

Fake binder_node payload: which of the 128 bytes carry the write, which steer the kernel onto the writing path, which keep it alive along the way, and which is the read-back sentinel

The forged 128-byte object. dead_node.next/dead_node.pprev at +0x20/+0x28 carry the write’s WHAT and WHERE. proc = 0 and local_strong_refs = 1 steer the kernel onto the free path that unlinks the node. The work.entry self-loop at +0x08 and lock = 0 at +0x04 keep it from crashing before it gets there. ptr at +0x58 (surfaced to userspace as target.ptr) is the sentinel we read back to confirm the spray landed.

Reading it as intent rather than as a table:

  • dead_node.next at +0x20 = WHAT (the value we write), dead_node.pprev at +0x28 = WHERE (the address it lands at). These two are the write itself; The unlink that writes, below, shows how. Everything else exists to get the kernel to touch them.
  • proc = 0 at +0x38. A live binder_node belongs to an owning process; ours belongs to nobody. This is the switch that sends the teardown down the branch that unlinks the node from the kernel’s dead-nodes list, the branch with the write in it, instead of the normal per-process cleanup.
  • local_strong_refs = 1 at +0x50. The teardown only frees a node when its strong count falls to zero. Releasing the queued transaction’s buffer (BC_FREE_BUFFER) decrements this once; starting at 1 means that decrement reaches 0 and fires the free path. Start at 0 and the decrement underflows to -1, the count never reads zero, and the teardown returns without writing.
  • work.entry self-loop at +0x08. Before it frees the node, the kernel asserts the node’s work list is empty: BUG_ON(!list_empty(&node->work.entry)). An empty list, in the kernel’s representation, is one whose head points at itself. So we point both words of work.entry back at work.entry’s own address, which is the node’s address plus 0x08. This is the second use of the leaked address: without knowing where the fake node lives, we could not make it point at itself, and the BUG_ON would panic the kernel before we ever reached the write.
  • lock = 0 at +0x04. The node’s spinlock, which the kernel takes during teardown, sits just past the 4-byte debug_id at +0x00. Zero means unlocked; anything else deadlocks.
  • ptr = 0x4444444444444444 at +0x58. Pure instrumentation. This is the binder_node field the dangling read surfaces to userspace as target.ptr (it was ref->node last post), so after spraying we read it back through the UaF: if we see 0x4444…, our fake object won the slot.

Building that buffer in userspace is plain byte placement:

char payload[128];
memset(payload, 0, sizeof(payload));            /* lock=0, proc=0, refs=0, ... */

/* cmsg_len occupies the first 8 bytes and must equal the buffer size, or
 * sendmsg rejects the control message; its zero high half doubles as the
 * binder_node lock at +0x04, which must be unlocked. */
*(uint32_t *)&payload[0x00] = 128;

/* work.entry self-loop: both list words point at work.entry itself (node+0x08),
 * so the kernel's list_empty() check passes and the BUG_ON does not fire. */
*(uint64_t *)&payload[0x08] = node_addr + 0x08;
*(uint64_t *)&payload[0x10] = node_addr + 0x08;

*(uint64_t *)&payload[0x20] = what;             /* dead_node.next  -> WHAT  */
*(uint64_t *)&payload[0x28] = where;            /* dead_node.pprev -> WHERE */

*(uint64_t *)&payload[0x50] = 1;                /* local_strong_refs: 1 -> 0 frees */
*(uint64_t *)&payload[0x58] = 0x4444444444444444;  /* sentinel: spray verification */

One field the template leaves blank on purpose: as each spray thread fires, it stamps its own thread id into cookie at +0x60. That is how we later learn which thread’s allocation won the slot: the read that verifies the spray hands that id back to us.

‍

Getting those bytes into the freed slot

A userspace buffer is not a kernel allocation. We need the kernel to copy those 128 bytes into a fresh kmalloc-128 object, ideally the freed slot we’re aiming at. The mechanism is a small, well-known trick with sendmsg.

When you call sendmsg on a socket with an ancillary data buffer (the msg_control field, normally used to pass file descriptors or credentials between processes), the kernel copies that buffer into kernel space with a single kmalloc(msg_controllen). Set msg_controllen to 128 and the copy comes straight out of kmalloc-128, the same cache as our freed binder_node. A normal sendmsg frees that copy before it returns, but if the send blocks, the copy stays allocated for as long as the sender stays blocked. So sendmsg is an on-demand “allocate 128 controlled bytes in the kernel and keep them there” primitive.

One sendmsg is one allocation with one shot at the freed slot. The spray is just many of them at once: a pool of threads, each holding a socket pre-filled to the brim so its next send blocks while holding its allocation, all released simultaneously so a burst of kmalloc-128 requests hits the freelist together. Volume covers for the fact that SLUB is not perfectly predictable under concurrency; with enough simultaneous allocations, one of them reliably lands in our slot.

/* each spray thread, simplified: one blocking sendmsg = one kmalloc(128) */
struct iovec iov = { .iov_base = filler, .iov_len = 128 };
struct msghdr msg = { 0 };
msg.msg_iov = &iov;
msg.msg_iovlen = 1;

/* pre-fill the socket so the NEXT send blocks — its kmalloc(128) then
 * stays pending (and its bytes stay in the kernel) until someone drains it */
while (sendmsg(sock, &msg, MSG_DONTWAIT) > 0);

msg.msg_control    = payload;   /* our fake binder_node bytes         */
msg.msg_controllen = 128;       /* -> kmalloc(128), same cache as node */
sendmsg(sock, &msg, 0);         /* blocks here, holding the allocation */

The first 8 bytes of msg_control aren’t free real estate: they’re the cmsghdr’s cmsg_len, a __kernel_size_t (8 bytes on arm64) that must hold a valid control-message length, 128, or the kernel rejects the control message and immediately frees that allocation. Written as a 64-bit 128, that’s 0x80 in the low four bytes and zero in the high four, and the high four fall exactly on the binder_node’s lock at +0x04, which has to read zero (unlocked) anyway. The two requirements land on the same bytes and agree, which is why the fake-node fields we populate all start at +0x08.

‍

The unlink that writes

We don’t fire blind. If no sprayed object reclaimed the slot, releasing the buffer makes the kernel run its teardown over whatever stale bytes are there, writing through a garbage address, so first we verify. We read the dangling slot once more through the UaF and check +0x58: if it reads back our 0x4444… sentinel, the fake node is in place and the fields under it are ours. The cookie that comes back with it names the thread whose sendmsg won the slot, so we can single that thread out and never drain its socket. That eviction is what later keeps the teardown’s kfree the slot’s only free. Only if the sentinel is there do we release the buffer; if it isn’t, we tear the attempt down and retry the whole free-spray-check cycle rather than write through an address we don’t control.

/* verify the spray landed, then fire the unlink (pseudo-code) */
uint64_t cookie = 0, sentinel = 0;
read_uaf(bs, &cookie, &sentinel);          /* +0x60 cookie, +0x58 sentinel  */
if (sentinel != 0x4444444444444444ULL)
        return RETRY;                      /* nobody won the slot — start over */

uint32_t winner_tid = (uint32_t)cookie;    /* the thread whose sendmsg won    */
keep_socket_undrained(winner_tid);         /* never evict the winner's copy   */

binder_free_buffer(bs, buffer);            /* BC_FREE_BUFFER -> fires hlist_del */

Releasing the buffer drives binder_dec_node on the transaction’s target node, and on a freed slot that would be dangerous two ways at once. First, the kernel reads the slot as a live binder_node, treating whatever occupies it as refcounts and list pointers and dereferencing them. Second, if the teardown reaches the free path it kfrees the slot a second time: a double free that hands one address out twice, so two later allocations receive the same memory. Either one is a crash or silent corruption, which is why in Posts 1 and 2 we never freed the buffer; the stale read alone was the use-after-free there.

It is safe now, and only now, for two reasons. The slot holds a fake node whose fields we chose, so the binder_dec_node teardown runs cleanly down the path we want instead of faulting on garbage. And we keep the winning allocation alive (the sendmsg copy is never drained from its socket), so the teardown’s kfree is the slot’s only free, not the first of two. (Both halves of that, told as the strange life of one slot, are in To go further.)

The kernel drops the node’s strong count from 1 to 0, sees proc == 0, and decides the node is dead and unowned, so it removes it from the kernel’s list of dead nodes with hlist_del(&node->dead_node). That single call is our write.

An hlist_del unlinks a node from a doubly-linked list. Stripped to essentials, the kernel does this:

/* include/linux/list.h — what hlist_del boils down to */
static inline void __hlist_del(struct hlist_node *n)
{
        struct hlist_node *next  = n->next;      /* dead_node.next  = +0x20 = WHAT  */
        struct hlist_node **pprev = n->pprev;    /* dead_node.pprev = +0x28 = WHERE */

        WRITE_ONCE(*pprev, next);                /* *WHERE = WHAT                    */
        if (next)
                WRITE_ONCE(next->pprev, pprev);  /* *(WHAT + 8) = WHERE  (side effect) */
}

To unlink a node, you make its neighbours point past it: the previous node’s forward pointer skips ahead to next, and next’s back-pointer skips back to the previous node. The node’s own next and pprev fields tell the kernel who those neighbours are. And those two fields are dead_node.next at +0x20 and dead_node.pprev at +0x28, the two we filled with WHAT and WHERE. So the first store, *pprev = next, is *WHERE = WHAT: our value written to our address.

The hlist_del unlink as a write primitive: WHERE = WHAT, plus the mandatory (WHAT+8) = WHERE side effect

The hlist_del unlink as a write primitive: WHERE = WHAT, plus the mandatory (WHAT+8) = WHERE side effect

hlist_del reads the fake node’s dead_node.next (WHAT) and dead_node.pprev (WHERE), then writes *WHERE = WHAT. That is the write we want. But if WHAT is non-zero the unlink also stores WHERE at WHAT + 8, a second write we did not ask for.

‍

Why it is constrained, not free-form

Look at the second store. if (next), meaning if WHAT is non-zero, the kernel also runs next->pprev = pprev, which is *(WHAT + 8) = WHERE. So for any non-zero WHAT the primitive is two writes welded together (*WHERE = WHAT and *(WHAT+8) = WHERE), and that coupling is what keeps it from being a free-form write-what-where.

The consequence is a real constraint on how we can use it. If WHAT is any non-zero value, then WHAT + 8 had better be a writable kernel address. The kernel is about to store 8 bytes there, and if it can’t, it faults and the box goes down. Write a chosen pointer to a chosen slot, and you also scribble over whatever lives eight bytes past where that pointer points. You cannot, in general, drop an arbitrary 64-bit value at an arbitrary address and walk away clean.

If WHAT is zero, the if (next) guard is false and the side-effect store never happens, leaving a clean *WHERE = 0 with no collateral.

That covers most of what the rest of the chain needs. Turning off a security policy is writing zero to a flag. Neutralizing a cred struct into root is writing zero over its uid/gid fields. To prove the primitive in isolation, our worked example zeroes randomize_va_space, a single clean *WHERE = 0 (the knob was 2, we make it 0). We pick it for two reasons. Its value is readable from userland at /proc/sys/kernel/randomize_va_space, so we can watch it flip 2 → 0 and confirm the write actually landed, a self-checking demonstration rather than a leap of faith. And its address is fixed on our test device, so the exercise can hard-code a WHERE. It is a demo target only. randomize_va_space lives in the kernel image, and the real chain (Post 5) never needs an image address, only heap pointers it reads for itself. But a write is only as useful as your knowledge of where to aim it, and for an arbitrary kernel target we don’t have that yet. randomize_va_space is a convenient stand-in because it sidesteps the problem; earning the ability to aim at anything is the whole job of the next post’s read primitive.

When a later step needs a non-zero WHAT, that side-effect store is back in play; Living inside the constraint, in To go further, covers how to aim it. Either way, calling this “arbitrary write-what-where,” as shorthand writeups often do, papers over the one property that shapes every use of it.

‍

Enter Epoch

Everything above hinges on one claim that is hard to check the ordinary way: our fake object is sitting in that specific freed slot when the unlink fires. The sentinel gives us a yes/no from userspace, but it can’t tell us why an attempt failed, and this is a technique that fails often and quietly. Did the spray miss the slot entirely? Did an unrelated kernel allocation grab it first? Did the object land but at a moment when the freelist wasn’t where we thought? Did the write happen but the side effect corrupt something we didn’t notice, and the box will fall over ten seconds later? A bare “sentinel not found, retrying” collapses all of those into one shrug.

And, as in the earlier posts, you cannot open the patient without killing it, here for a reason specific to the spray: it depends on the freelist’s exact LIFO ordering, and a breakpoint reschedules and shifts that very ordering, while a printk in the allocation path is itself an allocation that moves the slot you’re chasing. The failure modes that matter most (a near-miss spray, a side-effect write landing one field off) are the ones a wrong nudge either hides or turns into an immediate reboot.

Record the run once and you stop guessing: you walk the recording from the store that reclaims the slot to the unlink that writes through it, and watch our forged bytes turn into a binder_node the kernel trusts and then into a value of our choosing in a kernel global.

One recording of the constrained write: a socket message’s copy_from_user (str x3,[x6],#8) writes our bytes into freed slot 0xffffff8137919880, the slot cast as a binder_node whose forged dead_node holds next=0x0 (WHAT) and pprev=0xffffffc012d5a668 (WHERE) with local_strong_refs=1 and the 0x4444… sentinel, then the teardown load ldp x8,x9,[x19,#0x20] pulls WHAT and WHERE out of the node, str x8,[x9] stores 0 into WHERE, cbz x8 skips the second forced write, and the global at 0xffffffc012d5a668 (randomize_va_space) read in Memory with an access history showing our store as its only writer, flips from 2 to 0.

One forged node, one unlink: the kernel reads our bytes as a binder_node and writes our zero into randomize_va_space, flipping userspace ASLR off, 2 → 0.

The recording opens on the reclaim: a socket message’s copy_from_user runs str x3, [x6], #8, and [x6] is 0xffffff8137919880, the freed kmalloc-128 slot, now taking our bytes one 8-byte store at a time.

Open that slot in the Memory view and cast the cell as a binder_node (the frontend decodes the raw bytes against the struct in place), and the fields come up named. The forged dead_node, the hlist_node overlay at +0x20, holds next = 0x0, the value to write (WHAT), and pprev = 0xffffffc012d5a668, the address to write to (WHERE). The same decode shows local_strong_refs = 1, the count whose decrement to zero fires the free path, and ptr = 0x4444444444444444 at +0x58, our userspace sentinel.

Jump to the teardown and ldp x8, x9, [x19, #0x20] pulls both halves of the write out of the forged node in a single load: x8 = 0 (WHAT) and x9 = 0xffffffc012d5a668 (WHERE), with x19 holding the slot. The next instruction is the write itself, str x8, [x9], storing x8 (0) into [x9]: *WHERE = WHAT. Then cbz x8: x8 is 0, so the branch is taken and the unlink’s second, forced store (*(WHAT + 8) = WHERE) never fires, the constraint from Why it is constrained, not free-form resolving in our favour.

Finally, right-click [x9], open it in Memory, and read the access history of that address: across the whole trace the global has exactly one write, ours, at #126,982,275. That global at 0xffffffc012d5a668 is randomize_va_space, the sysctl that controls userspace ASLR (the frontend shows it as a bare address, not by name). Before our store it reads 2; after, 0. One unlink write flipped it 2 → 0, turning address-space randomization off.

Nothing in that chain is asserted. The slot the kernel writes through is the slot our copy_from_user filled, the same address 0xffffff8137919880, decoded into the very fields we planned. The write’s value and target are the WHAT and WHERE sitting in our forged dead_node, loaded at ldp and retired at str. And we know the write landed where we aimed because the global’s access history names our store as its only writer: identity, not coincidence. The forged node, the unlink, and the 2 → 0 flip all sit at fixed points in one recording, each replayable on demand, readable yourself or over Epoch’s MCP.

The read primitive that turns this write into a full kernel traversal comes next, and the complete chain (spray, leak, write, read, and the walk to root) ships as a hosted Epoch trace in the finale.

‍

When the spray doesn’t land

Everything above assumes a well-formed fake node in the right slot. Get the payload wrong and the kernel does not politely return an error; it walks off our bad bytes and panics. Two small mistakes are instructive because both crash deep inside the kernel, far from the userspace line that caused them, and both are the kind of bug time-travel debugging turns from an afternoon into a few minutes. The steps below show how each investigation goes.

A self-loop that points one field short. The work.entry self-loop must point at work.entry itself, which is node address plus 0x08. Point it at the node’s base instead (a plausible-looking off-by-8) and the kernel’s emptiness check, list_empty(&node->work.entry), no longer sees a list whose head points at itself. The BUG_ON(!list_empty(...)) that guards the free path fires, and the kernel takes a deliberate trap just before it would reach hlist_del. From a live crash you’d see a BUG splat in binder_dec_node_nilocked and a reboot, with the root cause anyone’s guess. On a recording you land on the trap instruction, and the comparison that failed is right there: the list head and the pointer it holds differ by exactly 8. The backward step from “these two should be equal” to “our self-loop was computed from the wrong base” is immediate.

A value written to the wrong field. The 1 that belongs in local_strong_refs at +0x50 is, in this variant, written at +0x38, the proc field, instead. Now the fake node’s proc is 0x1, a non-NULL junk pointer. The teardown believes the node has an owning process and tries to take that process’s lock, dereferencing an address a fixed offset past 0x1. Data abort, near-NULL faulting address, somewhere in binder_dec_node. The live symptom is a bad-pointer oops with no obvious origin. The faulting address doesn’t appear anywhere in our source. Time-travel makes it tractable: from the fault, follow the bad pointer backward to the field it was loaded from (proc, +0x38), then ask the one question a live kernel can’t answer after the fact, who wrote that byte? Epoch keeps every address’s write history in the trace, so it answers directly: the copy_from_user inside our sendmsg spray. The payload put a 1 where a 0 belonged. The crash is a data abort in the kernel; the cause is one misplaced constant in userspace, and the backward data-flow trace connects them without a single re-run.

‍

To go further

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

The heap, in more detail

Freelist hardening still doesn’t touch us. Post 1 covered the target kernel’s SLUB defenses (CONFIG_SLAB_FREELIST_HARDENED and CONFIG_SLAB_FREELIST_RANDOM); they’re irrelevant here for the same reason as there: we never read, forge, or corrupt a freelist pointer; we just reclaim freed slots through legitimate kmalloc(128) calls, whose LIFO order neither feature disturbs. (Page reclamation and the min_partial threshold matter too, but they come into their own for the cross-cache reuse of the next post; same-cache reuse here doesn’t need whole pages returned to the page allocator.)

‍

The strange life of one slot

Follow the single 128-byte slot our node lived in and its history stops making tidy sense: binder allocates it (a kzalloc for the real binder_node), binder frees it (the BC_RELEASE that springs the UaF), the socket layer allocates it (our sendmsg copy), and then binder frees it again (the hlist_del teardown). The allocation and the matching free no longer live in the same subsystem, let alone the same function, and that mismatch is both what makes the write possible and what makes it delicate. It’s a lineage you can read straight off the slot in Epoch: query one address and its memory-access history comes back in order, every read and write to it, with a backtrace at each one naming the subsystem that made it.

Three fates of the freed slot: not reused (stale node), reused by an unrelated allocation, or reused by our sendmsg spray: each an alloc/free story that crosses subsystems

Three fates of the freed slot: not reused (stale node), reused by an unrelated allocation, or reused by our sendmsg spray: each an alloc/free story that crosses subsystems

The freed slot has three possible fates, and which one it gets decides everything. If nothing reclaims it, it keeps stale binder_node bytes, and freeing the buffer runs the teardown over them and can free the slot a second time (the double free). If an unrelated allocation reclaims it, the teardown reads that object’s bytes as node fields: garbage pointers, a crash, or corruption of whatever it was. Only the third fate is ours: our sendmsg copy wins the slot, the teardown runs on fields we chose, and the write lands, provided that copy is freed exactly once.

Two properties make the third fate work. First, the allocation persists, but only while the sending thread stays blocked inside the kernel. A normal sendmsg copies the control buffer in and returns, freeing that copy on the way out; ours never returns. We pre-fill each socket so the spraying sendmsg blocks mid-send, buffer full, its thread parked in the kernel with the copy already made, and that copy stays resident for exactly as long as the thread stays blocked there, which is as long as we leave the socket undrained. A stable window to read the sentinel, decide we won, and only then trigger the unlink.

Second, we free it exactly once. The winning slot is one chunk wearing two hats: the in-kernel copy held by a blocked sendmsg and the live fake binder_node. The teardown ends in kfree, freeing that chunk. If we then drained the winner’s socket, the sender would unblock and free the same chunk again, corrupting the freelist. So we single that thread out and never drain it: the chunk dies once, by the teardown, on the path we chose. Every other spray thread’s socket is drained normally, which frees its allocation and readies the thread for the next write.

‍

The choreography behind one write

The main line said “free the node, spray, verify, trigger” as if one process did it all. In reality the same kernel rule from Post 2 (one reference per process per node) forces the work across a small tree of cooperating processes, all ours, coordinated by pipe-based signals so the steps stay strictly ordered. The writer service plays Post 2’s orchestrator, still performing the leak read, and also hosts the spray-thread pool. The node owner keeps its Post 2 job and adds two: it reads the sentinel back after the spray, and it releases the buffer that fires the write.

Write primitive choreography: leak the node’s address, free it, spray the fake node into the known slot, verify the sentinel, then release the buffer to fire hlist_del

Write primitive choreography: leak the node’s address, free it, spray the fake node into the known slot, verify the sentinel, then release the buffer to fire hlist_del

One write, end to end: the writer service leaks a live node’s address, then the node is freed (leaving a queued transaction dangling at a known slot); the spray threads fire sendmsg to drop the fake node into that slot; the owner reads the sentinel to confirm the landing and names the winning thread; and only then does releasing the transaction buffer drive binder_dec_node → hlist_del, the write. A miss at the verify step tears down and retries rather than firing blind.

The writer service is long-lived: it spawns its spray-thread pool once and reuses it across every write request, rebuilding only the smaller node-owner subtree per write. That reuse is why the pool shrinks by one thread each time (the evicted winner is never reused), and over a full exploit that calls the write primitive a handful of times, the pool has ample headroom.

‍

Living inside the constraint

When a later step needs a non-zero WHAT (to plant a pointer the kernel will follow), the side-effect store *(WHAT + 8) = WHERE comes back, and the trick is to aim it somewhere harmless: pick a WHAT whose +8 neighbour is padding, a field about to be rewritten anyway, or a location whose corruption is inert for what the kernel does next. The primitive is usable for non-zero writes; it just isn’t free, because each one is a pair of writes you place together. The read primitive of the next post is built with this in mind.

It’s all build-specific

Field Offset
work.entry +0x08
dead_node +0x20 / +0x28
proc +0x38
local_strong_refs +0x50
binder_node object size 128 bytes (kmalloc-128)

Every offset here is specific to Android 12 on a Pixel 6, kernel 5.10, arm64. The ideas generalize: a same-cache spray to reclaim a freed slot, a fake object whose fields steer a teardown into an unlink, and the recognition that hlist_del is a constrained two-write gadget rather than a clean one. Port it to another build and every one of those constants shifts.

‍

Next

We can now write a chosen value to a chosen kernel address, at the cost of the unlink’s side effect. A write alone isn’t root, though; to know what to overwrite we first need to read kernel memory at will. The next post builds an arbitrary read primitive, and it does so with a nastier trick than anything so far: cross-cache reuse, steering a freed binder_node’s whole page into a different allocator cache, then reading back through an epoll/ioctl gadget. Same method, a new recording: record once, walk to the exact reuse, and watch a page change what it is.

‍

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