Three bugs, one Android kernel chain

This series walks through three vulnerabilities reported last year that together yield remote kernel code execution on Android when chained through a malicious website in beta Chrome: a use-after-free (UAF) in the Chromium renderer, a Chromium sandbox escape that was caught and fixed while still in beta, and a UAF in the Qualcomm msm kernel's kgsl driver. Although the full chain only works against beta Chrome, the renderer RCE and the kernel bug affected stable versions of their respective software independently. All three have since been patched — the last one on January 1st.

The vulnerabilities in this series

Three flaws drive the chain:

  • CVE-2020-11239 — a UAF in the kgsl driver of the Qualcomm msm kernel, used here for arbitrary kernel code execution from a compromised beta Chrome. Reported in July 2020 to the Android security team as A-161544755 (GHSL-2020-375), it landed in the January bulletin. The bulletin mistakenly linked it to A-168722551, though the Android security team has since acknowledged the original report—even if the public acknowledgement page has not yet caught up.
  • CVE-2020-15972 — a UAF in web audio giving a renderer RCE. The bug was a duplicate; an anonymous researcher filed theirs about three weeks before the report as 1125635 (GHSL-2020-167).
  • CVE-2020-16045 — the sandbox escape that gains control of the Chrome browser process, reported as 1125614 (GHSL-2020-165). The exploit relies on a component enabled only in beta at the time; had it not been reported, the bug would likely have shipped to stable and been exploitable there.

There is a curious timing detail: the renderer bug was fixed in Chrome 86.0.4240.75, and the sandbox-escape bug would have entered that same stable build had it not been reported. The two flaws missed each other by exactly one day, leaving stable Chrome without a full chain.

Making the IOCTL fail on demand

The bug surfaces during the ioctl calls IOCTL_KGSL_GPUOBJ_IMPORT and IOCTL_KGSL_MAP_USER_MEM, which third-party apps use to create shared memory regions between userspace and the kgsl driver. The vulnerability results from a mismatch between the two memory types these calls can create: KGSL_USER_MEM_TYPE_ADDR and KGSL_USER_MEM_TYPE_ION (the latter is synonymous with KGSL_USER_MEM_TYPE_DMABUF).

When an ION object is created, the caller passes a file descriptor to a DMA buffer obtained from the ion allocator. The kgsl driver obtains the buffer via dma_buf_get, attaches to it with dma_buf_attach, and accesses its backing memory through the sg_table returned by dma_buf_map_attachment. This sg_table is stored in the kgsl_mem_entry and later referenced from the DMA buffer's dma_buf_attachment after the ION ABI change in kernel version 4.12 puts the attachment on the buffer itself.

For KGSL_USER_MEM_TYPE_ADDR objects, kgsl performs direct I/O and creates an sg_table owned by the kgsl_mem_entry. Destroying either object type calls kgsl_sharedmem_free to free entry->memdesc.sgt; for ION objects, the code first nulls memdesc.sgt before freeing, so the correct reference is left in the DMA buffer attachment.

The problem appears when the former code path checks whether a user-supplied address is an ion allocation. If it is, kgsl silently treats the request as an ION object import. If the ioctl then fails, cleanup logic path mismatches: the code frees entry->memdesc.sgt without first detaching the DMA buffer via kgsl_destroy_ion. The DMA buffer keeps a dangling attachment whose sg_table pointer is now free'd. Later, any use of DMA_BUF_IOCTL_SYNC on that buffer dereferences the stale freed object.

The natural trigger point is the failure of the ioctl in kgsl_mem_entry_attach_process, specifically where kgsl_mem_entry_track_gpuaddr fails to reserve a GPU address for the new mapping. This can be caused by an out-of-memory condition in the kernel's GPU address space, but exhausting real memory is unreliable and risks crashing the device.

A more controlled way to force this failure is with the alignment property of the mapped address. The ioctl accepts a flags parameter that selects the align value used during address reservation, and the flag comes directly from the caller's ioctl parameters. During mapping, kgsl picks the next aligned free GPU address; if no free address is aligned at the requested size, it returns an error.

An attacker can set a large align value to consume all valid aligned slots quickly. For instance, with align set to 1 << 31, only two GPU addresses (0 and 1 << 31) are available. After a single successful mapping of any small object (e.g., 4096 bytes), a second ioctl on the same kind will exhaust the aligned space and fail, yielding a free'd sg_table that remains reachable through the DMA buffer's attachment. This stale object can then be replaced by a controlled allocation of similar size in the kernel, giving the attacker complete influence over the fields of the manipulated sg_table.

Turning the Use-After-Free Into Read/Write Primitives

The freed sg_table can be reached through the DMA_BUF_IOCTL_SYNC ioctl in several ways:

static long dma_buf_ioctl(struct file *file,
              unsigned int cmd, unsigned long arg)
{
    ...
    switch (cmd) {
    case DMA_BUF_IOCTL_SYNC:
        ...
        if (sync.flags & DMA_BUF_SYNC_END)
            if (sync.flags & DMA_BUF_SYNC_USER_MAPPED)
                ret = dma_buf_end_cpu_access_umapped(dmabuf,
                                     dir);
            else
                ret = dma_buf_end_cpu_access(dmabuf, dir);
        else
            if (sync.flags & DMA_BUF_SYNC_USER_MAPPED)
                ret = dma_buf_begin_cpu_access_umapped(dmabuf,
                                       dir);
            else
                ret = dma_buf_begin_cpu_access(dmabuf, dir);

        return ret;

These entry points resolve to __ion_dma_buf_begin_cpu_access or __ion_dma_buf_end_cpu_access, both of which provide the actual implementation. The whole point of the sync operation is to reconcile the CPU's view of the buffer with the device (GPU) view. For the kgsl device, this synchronization is handled in lib/swiotlb.c. Different sync variants all converge on a shared code path:

  1. The scatterlist entries in the freed sg_table are walked one at a time.
  2. For each entry, the dma_address and dma_length fields locate the memory region to synchronize.
  3. That region is passed to swiotlb_sync_single, which performs the actual sync.

swiotlb_sync_single checks if the address (via dma_to_phys, which for kgsl is the identity) belongs to a swiotlb_buffer. If yes, it calls swiotlb_tlb_sync_single; otherwise it goes directly to dma_mark_clean.

static void
swiotlb_sync_single(struct device *hwdev, dma_addr_t dev_addr,
            size_t size, enum dma_data_direction dir,
            enum dma_sync_target target)
{
    phys_addr_t paddr = dma_to_phys(hwdev, dev_addr);

    BUG_ON(dir == DMA_NONE);

    if (is_swiotlb_buffer(paddr)) {
        swiotlb_tbl_sync_single(hwdev, paddr, size, dir, target);
        return;
    }

    if (dir != DMA_FROM_DEVICE)
        return;

    dma_mark_clean(phys_to_virt(paddr), size);
}

The dma_mark_clean path only flushes the CPU cache for the given address, keeping it coherent with memory. It didn't yield an exploit, so the useful path is the one through swiotlb_tbl_sync_single.

void swiotlb_tbl_sync_single(struct device *hwdev, phys_addr_t tlb_addr,
                 size_t size, enum dma_data_direction dir,
                 enum dma_sync_target target)
{
    int index = (tlb_addr - io_tlb_start) >> IO_TLB_SHIFT;
    phys_addr_t orig_addr = io_tlb_orig_addr[index];

    if (orig_addr == INVALID_PHYS_ADDR)                            //<--------- a. checks address valid
        return;
    orig_addr += (unsigned long)tlb_addr & ((1 << IO_TLB_SHIFT) - 1);

    switch (target) {
    case SYNC_FOR_CPU:
        if (likely(dir == DMA_FROM_DEVICE || dir == DMA_BIDIRECTIONAL))
            swiotlb_bounce(orig_addr, tlb_addr,
                       size, DMA_FROM_DEVICE);
    ...
}

That function validates tlb_addr against the io_tlb_orig_addr array before handing off to swiotlb_bounce.

static void swiotlb_bounce(phys_addr_t orig_addr, phys_addr_t tlb_addr,
               size_t size, enum dma_data_direction dir)
{
    ...
    unsigned char *vaddr = phys_to_virt(tlb_addr);
    if (PageHighMem(pfn_to_page(pfn))) {
        ...

        while (size) {
            sz = min_t(size_t, PAGE_SIZE - offset, size);

            local_irq_save(flags);
            buffer = kmap_atomic(pfn_to_page(pfn));
            if (dir == DMA_TO_DEVICE)
                memcpy(vaddr, buffer + offset, sz);
            else
                memcpy(buffer + offset, vaddr, sz);
            ...
        }
    } else if (dir == DMA_TO_DEVICE) {
        memcpy(vaddr, phys_to_virt(orig_addr), size);
    } else {
        memcpy(phys_to_virt(orig_addr), vaddr, size);
    }
}

Because both tlb_addr and size originate from a scatterlist in the freed sg_table, the attack surface becomes apparent: a memcpy whose source/destination is only partially controlled (tlb_addr must pass the checks), while size is completely unvalidated. That is a potentially strong relative read/write primitive, bounded by three open questions:

  1. Can is_swiotlb_buffer be satisfied without a separate info leak, given how swiotlb_buffer is defined?
  2. What exactly does the io_tlb_orig_addr check enforce, and how do we satisfy it?
  3. To what extent can the orig_addr read from io_tlb_orig_addr be controlled?

The SWIOTLB as an Exploitation Primitive

The Software Input Output Translation Lookaside Buffer (SWIOTLB), also called the bounce buffer, is a memory region with a physical address below 32 bits. On modern Android devices it is rarely used, primarily serving two purposes:

  1. As a proxy for DMA buffers with physical addresses above 32 bits when they are attached to devices that can only address 32 bits. This incurs an extra copy on each synchronization and is a last resort.
  2. As a protection layer preventing untrusted USB devices from direct DMA access.

The second case requires physical access, so this discussion focuses on the first, which also resolves the open questions about the synchronization path. The check is_swiotlb_buffer determines membership using the global io_tlb_start and io_tlb_end variables:

int is_swiotlb_buffer(phys_addr_t paddr)
{
    return paddr >= io_tlb_start && paddr < io_tlb_end;
}

The kernel guarantees the SWIOTLB address stays below 32 bits by allocating it extremely early in boot. On a Pixel 4, kernel logs show io_tlb_start at 0xf3800000 and io_tlb_end at 0xf3c00000:

...
[    0.000000] c0      0 software IO TLB: swiotlb init: 00000000f3800000
[    0.000000] c0      0 software IO TLB: mapped [mem 0xf3800000-0xf3c00000] (4MB)
...

Early allocation also makes the location predictable. The address effectively depends only on the amount of memory reserved via the swiotlb boot parameter. Pixel 4 uses swiotlb=2048, allocating 4 MB (allocation size = swiotlb * 2048), the same value as Galaxy S10 and S20. The Samsung Galaxy A71 sets swiotlb=1, which allocates the minimum of 0x40000 bytes.

[    0.000000] software IO TLB: mapped [mem 0xfffbf000-0xfffff000] (0MB)

Using swiotlb=1 on a Pixel 4 places the SWIOTLB at the same address, giving a predictable location to pass the is_swiotlb_buffer test.

The io_tlb_orig_addr array stores the addresses of DMA buffers attached to devices that cannot access them directly:

int
swiotlb_map_sg_attrs(struct device *hwdev, struct scatterlist *sgl, int nelems,
             enum dma_data_direction dir, unsigned long attrs)
{
    ...
    for_each_sg(sgl, sg, nelems, i) {
        phys_addr_t paddr = sg_phys(sg);
        dma_addr_t dev_addr = phys_to_dma(hwdev, paddr);

        if (swiotlb_force == SWIOTLB_FORCE ||
            !dma_capable(hwdev, dev_addr, sg->length)) {
            //device cannot access dev_addr, so use SWIOTLB as a proxy
            phys_addr_t map = map_single(hwdev, sg_phys(sg),
                             sg->length, dir, attrs);
             ...
}

When a high-address DMA buffer is mapped to a device that is not dma_capable, map_single writes the buffer's dev_addr into io_tlb_orig_addr. Triggering such a mapping means the orig_addr used in swiotlb_bounce's memcpy points to a controlled DMA buffer, yielding full read/write control over its contents.

static void swiotlb_bounce(phys_addr_t orig_addr, phys_addr_t tlb_addr,
               size_t size, enum dma_data_direction dir)
{
    ...
    //orig_addr is the address of a DMA buffer uses the SWIOTLB mapping
    } else if (dir == DMA_TO_DEVICE) {
        memcpy(vaddr, phys_to_virt(orig_addr), size);
    } else {
        memcpy(phys_to_virt(orig_addr), vaddr, size);
    }
}

If a SWIOTLB can be allocated, arbitrary read and write of memory beyond the SWIOTLB region becomes possible, with fully controlled content for writes and arbitrary size. This is the foundation of the exploit.

Synchronization works as follows. When the device can access the DMA buffer address directly, cache flushes suffice:

Diagram of the DMA sync

When direct access fails, a SWIOTLB acts as an intermediate buffer. The io_tlb_orig_addr array serves as a lookup table to find the original DMA buffer:

When the device cannot access the DMA buffer directly, a SWIOTLB is created as an intermediate buffer to allow device access.

In the use-after-free scenario, controlling the memcpy size between the DMA buffer and SWIOTLB turns it into a read/write primitive:

DMA read-write

By controlling the scatterlist entries that describe the SWIOTLB's location and size, specifying a size larger than the original buffer causes an out-of-bounds access while still passing checks by pointing into the SWIOTLB. Reading back the overflowed data or controlling the written data is covered in the next section.

Allocating a SWIOTLB via adsprpc

The SWIOTLB is rarely used—most devices support 64-bit addressing or DMA synchronization goes through arm_smmu rather than swiotlb. Allocation was only achieved with the adsprpc driver, used to communicate with the DSP on Qualcomm Snapdragon chipsets. The DSP handles compute-intensive image, video, audio and ML tasks, but as a separate processor with its own OS, RPC is required to transfer data and instructions. adsprpc provides that kernel-level RPC mechanism.

Default SELinux policy denies third-party adsprpc access on Google Pixel devices, but many Snapdragon-based phones permit it. Samsung devices, for instance, allow direct access from third-party apps, enabling the exploit from an app or a compromised Chrome process. On Pixels, chaining another bug to compromise a privileged service like hal_neuralnetworks (a closed-source component in [email protected] that can reach adsprpc) would be necessary; this path was not investigated.

The obvious ioctl, FASTRPC_IOCTL_MMAP, invokes fastrpc_mmap_create to attach a supplied DMA buffer:

static int fastrpc_mmap_create(struct fastrpc_file *fl, int fd,
    unsigned int attr, uintptr_t va, size_t len, int mflags,
    struct fastrpc_mmap **ppmap)
{
    ...
    } else if (mflags == FASTRPC_DMAHANDLE_NOMAP) {
        VERIFY(err, !IS_ERR_OR_NULL(map->buf = dma_buf_get(fd)));
        if (err)
            goto bail;
        VERIFY(err, !dma_buf_get_flags(map->buf, &flags));
        ...
        map->attach->dma_map_attrs |= DMA_ATTR_SKIP_CPU_SYNC;
        ...

However, the call typically fails when fastrpc_mmap_on_dsp runs, detaching the buffer and freeing the freshly allocated SWIOTLB. Working with such a transient buffer would require racing multiple threads, so a permanent allocation is preferable.

The get_args function also calls fastrpc_mmap_create:

static int get_args(uint32_t kernel, struct smq_invoke_ctx *ctx)
{
    ...
    for (i = bufs; i < bufs + handles; i++) {
        ...
        if (ctx->attrs && (ctx->attrs[i] & FASTRPC_ATTR_NOMAP))
            dmaflags = FASTRPC_DMAHANDLE_NOMAP;
        VERIFY(err, !fastrpc_mmap_create(ctx->fl, ctx->fds[i],
                FASTRPC_ATTR_NOVA, 0, 0, dmaflags,
                &ctx->maps[i]));
        ...
    }

get_args is invoked by the FASTRPC_IOCTL_INVOKE_* ioctls to pass arguments to DSP functions. Normally put_args later detaches the buffer, but if the remote invocation fails, put_args is skipped and cleanup is deferred until the adsprpc file is closed:

static int fastrpc_internal_invoke(struct fastrpc_file *fl, uint32_t mode,
                   uint32_t kernel,
                   struct fastrpc_ioctl_invoke_crc *inv)
{
    ...
    if (REMOTE_SCALARS_LENGTH(ctx->sc)) {
        PERF(fl->profile, GET_COUNTER(perf_counter, PERF_GETARGS),
        VERIFY(err, 0 == get_args(kernel, ctx));                  //<----- get_args
        PERF_END);
        if (err)
            goto bail;
    }
    ...
 wait:
    if (kernel) {
      ....
    } else {
        interrupted = wait_for_completion_interruptible(&ctx->work);
        VERIFY(err, 0 == (err = interrupted));
        if (err)
            goto bail;                                        //<----- invocation failed and jump to bail directly
    }
    ...
    VERIFY(err, 0 == put_args(kernel, ctx, invoke->pra));    //<------ detach the arguments
    PERF_END);
    ...
 bail:
    ...
    return err;
}

Calling FASTRPC_IOCTL_INVOKE_* with an invalid remote function therefore allocates and retains the SWIOTLB until the /dev/adsprpc-smd fd is closed. This is the only involvement of adsprpc needed.

With the ability to create SWIOTLB mappings to controlled DMA buffers, the out-of-bounds primitive is turned into a full exploit in four steps:

  1. Allocate multiple DMA buffers. By manipulating the ion heap (detailed later), place useful data beyond one of them, called DMA_1.
  2. Use adsprpc to allocate SWIOTLB buffers tied to these DMA buffers, arranging for DMA_1's mapping SWIOTLB_1 to be first so all other SWIOTLBs sit behind it—a straightforward outcome since SWIOTLBs allocate as a contiguous array.
  3. Trigger out-of-bounds read/write on DMA_1 so memory behind DMA_1 transfers to or from the region behind SWIOTLB_1.
  4. Because the SWIOTLBs behind SWIOTLB_1 map to other controlled DMA buffers, the DMA_BUF_IOCTL_SYNC ioctl on those buffers reads or writes the SWIOTLB data, translating the primitive into arbitrary read/write of memory behind DMA_1.

The two-buffer case is illustrated below:

DMA OOB sync

Making the freed sg_table useful

So far, the plan assumes control of the scatterlist sgl inside a freed sg_table. Replacing that freed object with controlled data is the hard part. Classic heap spraying primitives such as sendmsg or setxattr don’t work here because the sgl field must be a valid pointer to a region we control, and there is no way to leak a heap address from this bug alone. Searching for other kmalloc-allocated objects in the same 128-byte bucket with a pointer as the first field turns up few candidates; filename from getname_flags is close but unusable due to the null-byte restriction it imposes:

struct filename *
getname_flags(const char __user *filename, int flags, int *empty)
{
    struct filename *result;
    ...
    if (unlikely(len == EMBEDDED_NAME_MAX)) {
        ...
        result = kzalloc(size, GFP_KERNEL);
        if (unlikely(!result)) {
            __putname(kname);
            return ERR_PTR(-ENOMEM);
        }
        result->name = kname;
        len = strncpy_from_user(kname, filename, PATH_MAX);
        ...

Winning a narrow race with scheduler tricks

The key observation is that in the DMA-buffer access path, the sgl value is read once and cached in a register; after that the original sg_table state is irrelevant. Inlining, as with ion_sgl_sync_mapped, makes the exact point harder to pin down from source, but the principle holds: once sgl is cached, the code only touches the pointed-to scatterlist.

So the plan is to swap the freed sg_table with a fresh one that can be freed again, then free that second table after sgl has been cached. Freeing the second table via sg_free_table clears the sgl pointer to NULL, which doesn’t matter because the register still holds the old address. The result is a direct use-after-free on sgl inside ion_sgl_sync_mapped, which sendmsg spraying can then overwrite.

The problem is timing: the window between caching sgl and using it is far too short for a full replace-and-free sequence, even when racing a slow core against a fast one. The fix comes from a technique Jann Horn described for racing on older Linux kernels—one that still works on current Android.

The scheduler can preempt a running task to give another task CPU time. A task can also voluntarily yield, for example when blocked on I/O. Preemption happens inside ioctl calls too. By combining CPU affinity with task priorities, we can force a preemption at the exact point where sgl is cached in a register and keep the task suspended long enough to complete the replacement:

  1. A SCHED_NORMAL task blocks on a pipe read, waiting for data.
  2. A SCHED_IDLE task on the same CPU runs the DMA_BUF_IOCTL_SYNC ioctl against the buffer whose sg_table we control.
  3. Once the SCHED_IDLE task has cached sgl, the main thread writes to the pipe. That wakes the SCHED_NORMAL task, which preempts the lower-priority SCHED_IDLE task.
  4. The SCHED_NORMAL task now spins in a busy loop, keeping the SCHED_IDLE task from resuming.

Sequence of object replacement

Putting it together, the full replacement flow is:

  1. Get a freed sg_table in a DMA buffer via the fake out-of-memory error path.
  2. Call IOCTL_KGSL_GPUOBJ_IMPORT again so a new sg_table lands on the freed slot. Freeing this one later is just a IOCTL_KGSL_GPUMEM_FREE_ID call away. (Some extra heap grooming is needed because the import path allocates another similar-size object first.)
  3. Pin a SCHED_NORMAL task to a CPU and have it block on an empty pipe.
  4. Pin a SCHED_IDLE task to the same CPU, waiting for a signal to run DMA_BUF_IOCTL_SYNC on the DMA buffer holding the new sg_table.
  5. Signal the SCHED_IDLE task to start the ioctl.
  6. Wait just long enough for sgl to be cached, then wake the SCHED_NORMAL task by writing to the pipe.
  7. The SCHED_NORMAL task enters a busy loop, freezing the SCHED_IDLE task mid-ioctl.
  8. Free the sg_table with IOCTL_KGSL_GPUMEM_FREE_ID, which also frees the cached scatterlist. Then spray sendmsg data over that freed object. That gives control of the dma_address and dma_length fields used by memcpy.
  9. Stop the SCHED_NORMAL task so the ioctl resumes against the forged scatterlist.
ideal scenario

Reality is messier, as this diagram shows:

real-world scenario

Surprisingly, this race wins almost every time. The same timing parameters work on both a Galaxy A71 and a Pixel 4. Failed attempts don’t crash. There is a crash risk if the SCHED_IDLE task resumes too quickly, because the suspension only holds for roughly 10-20 ms—sometimes not enough for the full replacement.

Choosing a target heap

To convert the read/write primitives into useful work, the data placed behind the DMA buffers matters. Allocating DMA buffers requires the ion allocator, which pulls from ion heaps. Most ion heaps are created at fixed, low (sub-32-bit) addresses, as shown in the kernel boot log on a Galaxy A71.

[    0.626370] ION heap system created
[    0.626497] ION heap qsecom created at 0x000000009e400000 with size 2400000
[    0.626515] ION heap qsecom_ta created at 0x00000000fac00000 with size 2000000
[    0.626524] ION heap spss created at 0x00000000f4800000 with size 800000
[    0.626531] ION heap secure_display created at 0x00000000f5000000 with size 5c00000
[    0.631648] platform soc:qcom,ion:qcom,ion-heap@14: ion_secure_carveout: creating heap@0xa4000000, size 0xc00000
[    0.631655] ION heap secure_carveout created
[    0.631669] ION heap secure_heap created
[    0.634265] cleancache enabled for rbin cleancache
[    0.634512] ION heap camera_preview created at 0x00000000c2000000 with size 25800000

The system heap is different: it has no fixed address and allocations land above the 32-bit boundary, making it the right choice here. Allocations on the system heap go through ion_system_heap_allocate. It first tries a preallocated memory pool and falls back to alloc_pages when that pool is full, recycling pages back into the pool on free.

static void *ion_page_pool_alloc_pages(struct ion_page_pool *pool)
{
    struct page *page = alloc_pages(pool->gfp_mask, pool->order);
    ...
    return page;
}

Allocations from the preallocated pool are less interesting, because out-of-bounds accesses would only touch other ion buffers (user-space data). The alloc_pages path is where kernel objects can be reached. alloc_pages uses the buddy allocator and returns 2^order contiguous pages. To exploit an overflow from the buddy allocator, the approach follows the technique described in Andrey Konovalov's work on packet sockets. The key insight is that kmalloc and friends use the slab allocator, which itself requests slabs from the buddy allocator. /proc/slabinfo shows slab sizes in pages.

kmalloc-8192        1036   1036   8192    4    8 : tunables    0    0    0 : slabdata    262    262      0
...
kmalloc-128       378675 384000    128   32    1 : tunables    0    0    0 : slabdata  12000  12000      0

For example, when the 8192-byte bucket is exhausted, the slab allocator requests an order-3 block (8 pages) from the buddy allocator as a new slab. By exhausting slabs this way, a new slab can be placed into the same region as an ion buffer, enabling reads and writes across kernel objects allocated by kmalloc.

Arranging the buddy allocator

For each order, the buddy allocator keeps a freelist. When an order runs low, it splits a block from the next order up and adds halves to the appropriate freelist. Allocating pages of the same order repeatedly eventually forces those splits, so consecutive allocations end up adjacent. Experiments on Pixel 4 show a predictable pattern after enough ion system heap allocations:

  1. Buffers are grouped into 4MB blocks (order 10, the largest on Android).
  2. Within a block, each new allocation is adjacent to and at a higher address than the previous one.
  3. When a 4MB block is full, allocations move to the start of the next block, located just below the filled one.
ion heap pattern

After spraying many DMA buffers, the final allocated buffer sits in front of a hole of free memory. A subsequent allocation from the buddy allocator will likely land in that hole if the requested size fits, and where it lands can be steered by the spray pattern. The strategy becomes straightforward: first exhaust the larger order blocks with ion allocations, then allocate many kmalloc objects to force creation of a new slab, which should fall into the hole next to a controlled DMA buffer. The use-after-free then transfers into arbitrary read/write across that fresh slab.

Breaking KASLR and mapping the heap layout

Initial experiments used binder_open via open("/dev/binder"), which allocates a persistent binder_proc struct measuring 560 bytes. Filling the kmalloc-1024 slab with these is straightforward, and dumping out-of-bounds reads revealed a recurring memory pattern.

00011020: 68b2 8e68 c1ff ffff 08af 5109 80ff ffff  h..h......Q.....
00011030: 0000 0000 0000 0000 0100 0000 0000 0000  ................
00011040: 0000 0200 1d00 0000 0000 0000 0000 0000  ................
...

The value 08af 5109 80ff ffff decodes to a kernel code address: it is the binder_fops pointer. What the overflow is surfacing are file structs from the opened binder files — the f_ops field points at binder_fops. The 32 bytes following f_ops are identical across all file structs of the same type, giving a reliable signature. Scanning the page contents behind the DMA buffer for that signature locates the file structs for the binder devices.

Each file struct also holds f_pos_lock, a mutex containing a wait_list. This is a standard doubly linked list. When initialized, the head points to itself, so reading the next or prev pointer exposes the exact address of that file struct. Since the offset from the file struct to the controlled DMA buffer is fixed in the dumped data (0x11020 in the example), that yields the DMA buffer's address as well.

# echo 0 > /proc/sys/kernel/kptr_restrict                                                                                                                                                
# cat /proc/kallsyms | grep ffffff800951af08                                                                                                                                                 
ffffff800951af08 r binder_fops

From the binder_fops address the KASLR slide follows directly. With a known DMA buffer address, a fake file_operations structure can be stored there and the f_ops pointer of an owned file struct can be repointed to it, giving a clear route to arbitrary code execution:

  1. Use the out-of-bounds read from the use-after-free to dump memory behind a controlled DMA buffer.
  2. Match the binder file struct signature in that data and record its offset.
  3. Derive the binder_fops address for the KASLR slide, and the file struct address from wait_list.
  4. Combine the file struct address with the observed offset to find the DMA buffer address.
  5. Overwrite the f_ops pointer of the owned file to point to a fake file_operations placed in the DMA buffer; subsequent file operations then dispatch to attacker-controlled functions.

Nothing about the binder files is essential here — the actual exploit swaps /dev/binder for /dev/null — but the mechanics are identical. The final step of turning the redirected f_ops into full kernel code execution is the subject of what follows.

Closing the Chain: Turning the Bug Into Kernel Code Execution

For the final stage, I use the “ultimate ROP gadget” from Brandon Azad’s earlier work on Android. The function __bpf_prog_run32 executes eBPF bytecode supplied as its second argument, which gives us a powerful primitive: arbitrary memory load/store and the ability to call kernel functions with up to five arguments and a 64-bit return value.

unsigned int __bpf_prog_run32(const void *ctx, const bpf_insn *insn)

Since I already control the contents of a DMA buffer and know its kernel address, storing the bytecode there and passing that address as the second argument is straightforward. However, Samsung’s Realtime Kernel Protection (RKP) introduces two constraints:

  • It enforces a form of control-flow integrity (CFI) that only permits jumps to the start of legitimate functions, not arbitrary offsets. This blocks JOPP-style attacks.
  • It treats critical data structures, such as process credentials, as read-only

The CFI restriction might seem limiting, but we do not actually need to jump anywhere arbitrary. The file_operations struct we overwrite contains a full set of syscall wrapper functions. We just need one whose second argument is a 64-bit value we control. The llseek operation fits perfectly:

struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ...

It accepts a 64-bit loff_t offset, and we can reach it through the lseek64 syscall:

off_t lseek64(int fd, off_t offset, int whence);

By setting the llseek field of our crafted file_operations to point to __bpf_prog_run32, every subsequent lseek64 call executes our eBPF program — no need to re-trigger the original UAF vulnerability.

RKP’s protection of credentials rules out the classic root-credential overwrite. But as Jann Horn’s work demonstrates, once we have arbitrary kernel read/write, we effectively control all userspace data and processes. There are multiple established techniques to achieve privilege escalation from there — for example, overwriting the kernel stack of a privileged process, as described in an earlier SVE-2020-18610 exploit write-up. This means a full chain would continue with one such post-exploitation step, but this exploit ends at the point of verified arbitrary kernel code execution.

What This Bug Teaches Us

The root cause is a use-after-free in the Qualcomm kgsl driver, triggered by a mismatch between the user-supplied memory type and what the kernel actually creates. When an error occurs, the kernel applies incorrect cleanup logic to the wrong kind of object, leaving a dangling reference. Two distinct software flaws conspired here: an overloaded type that became ambiguous, and error handling that failed to match the operation with the real object state. Combined, they allowed a third-party application to reach arbitrary code execution in the kernel. Userspace sandboxing on Android has improved significantly, but vendor kernel drivers remain a large and dangerous attack surface. A single memory-corruption bug in a driver can grant full kernel control, which is why such bugs continue to shorten the overall exploit chain. The full exploit is available in GitHub’s Security Lab repository, and the next part of this series moves up the stack to a Chrome sandbox escape via issue 1125614.