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:
- The
scatterlistentries in the freedsg_tableare walked one at a time. - For each entry, the
dma_addressanddma_lengthfields locate the memory region to synchronize. - 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:
- Can
is_swiotlb_bufferbe satisfied without a separate info leak, given howswiotlb_bufferis defined? - What exactly does the
io_tlb_orig_addrcheck enforce, and how do we satisfy it? - To what extent can the
orig_addrread fromio_tlb_orig_addrbe 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:
- 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.
- 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:

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:

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

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:
- Allocate multiple DMA buffers. By manipulating the ion heap (detailed later), place useful data beyond one of them, called
DMA_1. - Use
adsprpcto allocate SWIOTLB buffers tied to these DMA buffers, arranging forDMA_1's mappingSWIOTLB_1to be first so all other SWIOTLBs sit behind it—a straightforward outcome since SWIOTLBs allocate as a contiguous array. - Trigger out-of-bounds read/write on
DMA_1so memory behindDMA_1transfers to or from the region behindSWIOTLB_1. - Because the SWIOTLBs behind
SWIOTLB_1map to other controlled DMA buffers, theDMA_BUF_IOCTL_SYNCioctl on those buffers reads or writes the SWIOTLB data, translating the primitive into arbitrary read/write of memory behindDMA_1.
The two-buffer case is illustrated below:

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:
- A
SCHED_NORMALtask blocks on a pipe read, waiting for data. - A
SCHED_IDLEtask on the same CPU runs theDMA_BUF_IOCTL_SYNCioctl against the buffer whosesg_tablewe control. - Once the
SCHED_IDLEtask has cachedsgl, the main thread writes to the pipe. That wakes theSCHED_NORMALtask, which preempts the lower-prioritySCHED_IDLEtask. - The
SCHED_NORMALtask now spins in a busy loop, keeping theSCHED_IDLEtask from resuming.
Sequence of object replacement
Putting it together, the full replacement flow is:
- Get a freed
sg_tablein a DMA buffer via the fake out-of-memory error path. - Call
IOCTL_KGSL_GPUOBJ_IMPORTagain so a newsg_tablelands on the freed slot. Freeing this one later is just aIOCTL_KGSL_GPUMEM_FREE_IDcall away. (Some extra heap grooming is needed because the import path allocates another similar-size object first.) - Pin a
SCHED_NORMALtask to a CPU and have it block on an empty pipe. - Pin a
SCHED_IDLEtask to the same CPU, waiting for a signal to runDMA_BUF_IOCTL_SYNCon the DMA buffer holding the newsg_table. - Signal the
SCHED_IDLEtask to start the ioctl. - Wait just long enough for
sglto be cached, then wake theSCHED_NORMALtask by writing to the pipe. - The
SCHED_NORMALtask enters a busy loop, freezing theSCHED_IDLEtask mid-ioctl. - Free the
sg_tablewithIOCTL_KGSL_GPUMEM_FREE_ID, which also frees the cachedscatterlist. Then spraysendmsgdata over that freed object. That gives control of thedma_addressanddma_lengthfields used bymemcpy. - Stop the
SCHED_NORMALtask so the ioctl resumes against the forged scatterlist.
Reality is messier, as this diagram shows:
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:
- Buffers are grouped into 4MB blocks (order 10, the largest on Android).
- Within a block, each new allocation is adjacent to and at a higher address than the previous one.
- When a 4MB block is full, allocations move to the start of the next block, located just below the filled one.
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:
- Use the out-of-bounds read from the use-after-free to dump memory behind a controlled DMA buffer.
- Match the binder
filestruct signature in that data and record its offset. - Derive the
binder_fopsaddress for the KASLR slide, and thefilestruct address fromwait_list. - Combine the
filestruct address with the observed offset to find the DMA buffer address. - Overwrite the
f_opspointer of the owned file to point to a fakefile_operationsplaced 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.



