A use-after-free path in CSF queue binding

Arm's Memory Tagging Extension (MTE) is designed to make memory corruption bugs all but unexploitable. But mitigations only hold if the underlying code respects the abstractions they depend on. CVE-2025-0072, a vulnerability in ARM's Mali GPU driver, breaks that assumption: a malicious Android app can turn a page lifetime bug into a use-after-free and, from there, bypass MTE for arbitrary kernel code execution.

The bug was reported to Arm on December 12, 2024, fixed in Mali driver r54p0 (released May 2, 2025), and shipped in Android's May 2025 security update. Affected devices use the Command Stream Frontend (CSF) architecture found in newer Mali GPUs, including Google's Pixel 7, 8, and 9 series. The exploit was developed and tested on a Pixel 8 with kernel MTE enabled; with minor changes it should also work on the 7 and 9.

Queue objects and the binding dance

On CSF-based Mali GPUs, userland talks to the kernel through command queues. Each queue is a kbase_queue object created via the KBASE_IOCTL_CS_QUEUE_REGISTER ioctl. To become usable, the queue must be bound to a kbase_queue_group (created with KBASE_IOCTL_CS_QUEUE_GROUP_CREATE). The KBASE_IOCTL_CS_QUEUE_BIND ioctl performs that binding and, in the process, calls get_user_pages_mmap_handle to produce a handle for the user application.

static int kbase_csf_queue_bind(struct kbase_context *kctx,
    union kbase_ioctl_cs_queue_bind *bind)
{
    struct kbase_queue *queue;
    ...
    queue->bind_state = KBASE_CSF_QUEUE_BIND_IN_PROGRESS;
    ...
}

The binding is deliberately two-phase. After the ioctl returns, queue->bind_state is still KBASE_CSF_QUEUE_BIND_IN_PROGRESS; the queue is not yet live. The user application must call mmap with the returned handle as the file offset to finish the job. That call lands in kbase_csf_cpu_mmap_user_io_pages, which allocates the backing GPU memory via kbase_csf_alloc_command_stream_user_pages and maps it into user space.

static int kbase_csf_alloc_command_stream_user_pages(struct kbase_context *kctx,
    struct kbase_queue *queue)
{
    ...
    queue->phys = kcalloc(num_pages, sizeof(*queue->phys), GFP_KERNEL);
    ...
    ret = kbase_mem_pool_alloc_pages(&kctx->mem_pool, num_pages,
        queue->phys, true);
    ...
}

Those pages are stored in the queue->phys array. Once the mmap completes, bind_state flips to KBASE_CSF_QUEUE_BOUND, and the pages stay alive exactly as long as the user mapping exists. Unmapping triggers kbase_csf_free_command_stream_user_pages, which walks queue->phys and frees each page via kbase_mem_pool_free_pages. Because freeing is tied to the unmap, a correctly used queue never exposes freed pages to user space.

Freeing the wrong pages

The invariant breaks if an attacker can change what queue->phys points to after the initial mmap. Consider triggering kbase_csf_alloc_command_stream_user_pages a second time, replacing the original page list with freshly allocated pages and mapping those to a new user region. Now unmap the older region. The unmap handler calls kbase_csf_free_command_stream_user_pages, which frees whatever is currently in queue->phys — in this case, the newly allocated pages, not the ones the old mapping actually references.

That produces a telling artifact: the new pages are freed from the kernel's perspective, but user space still holds a live mapping to them through the new region. The pages have been turned into a use-after-free primitive — still accessible from user space, yet released back into the kernel's page pools for reallocation.

User space mappings        queue->phys           State
--------------------      -------------         -----
old mmap region            old pages            alive, then unmapped
new mmap region            new pages            freed, but still mapped

This is the core of CVE-2025-0072: a page use-after-free in the Mali CSF queue path. The follow-on steps to convert that into arbitrary kernel code execution and to defeat MTE follow established techniques for Mali driver exploits, but the page lifetime bug itself is the foundation.

How the bug is triggered

Initial attempts to bind a kbase_queue multiple times fail because the queue->group field is checked before binding:

int kbase_csf_queue_bind(struct kbase_context *kctx, union kbase_ioctl_cs_queue_bind *bind)
{
  ...
	if (queue->group || group->bound_queues[bind->in.csi_index])
		goto out;
  ...
}

Once bound, a kbase_queue cannot be unbound via any ioctl, and KBASE_IOCTL_CS_QUEUE_TERMINATE deletes it entirely. The alternative is to terminate the kbase_queue_group. When the group terminates, it calls kbase_csf_term_descheduled_queue_group to unbind its queues:

void kbase_csf_term_descheduled_queue_group(struct kbase_queue_group *group)
{
  ...
	for (i = 0; i < max_streams; i++) {
		struct kbase_queue *queue = group->bound_queues[i];

		/* The group is already being evicted from the scheduler */
		if (queue)
			unbind_stopped_queue(kctx, queue);
	}
  ...
}

The unbind process resets the queue->group field:

static void unbind_stopped_queue(struct kbase_context *kctx, struct kbase_queue *queue)
{
  ...
	if (queue->bind_state != KBASE_CSF_QUEUE_UNBOUND) {
    ...
		queue->group->bound_queues[queue->csi_index] = NULL;
		queue->group = NULL;
    ...
		queue->bind_state = KBASE_CSF_QUEUE_UNBOUND;
	}
}

This reset opens up the possibility of binding the queue to another group, enabling a page use-after-free with this sequence:

  1. Create a kbase_queue and a kbase_queue_group, then bind them.
  2. Create GPU memory pages for the user io pages in the kbase_queue, map them to user space with mmap, and store them in queue->phys.
  3. Terminate the group, which also unbinds the queue.
  4. Create a new group and bind the same queue to it.
  5. Create new user io pages, which overwrite the existing entries in queue->phys, and map them to user space.
  6. Unmap the memory from step 2. This frees the pages in queue->phys—but those are now the pages created in step 5, which remain mapped to user space.

The pages freed in step 6 remain accessible from the application. Using a previously documented technique, these freed pages can be reused as page table global directories (PGD) for the Mali GPU.

Page allocation flow

Backing pages for a kbase_va_region are allocated by kbase_mem_pool_alloc_pages:

int kbase_mem_pool_alloc_pages(struct kbase_mem_pool *pool, size_t nr_4k_pages,
    struct tagged_addr *pages, bool partial_allowed)
{
    ...
  /* Get pages from this pool */
  while (nr_from_pool--) {
    p = kbase_mem_pool_remove_locked(pool);     //<------- 1.
        ...
  }
    ...
  if (i != nr_4k_pages && pool->next_pool) {
    /* Allocate via next pool */
    err = kbase_mem_pool_alloc_pages(pool->next_pool,      //<----- 2.
        nr_4k_pages - i, pages + i, partial_allowed);
        ...
  } else {
    /* Get any remaining pages from kernel */
    while (i != nr_4k_pages) {
      p = kbase_mem_alloc_page(pool);     //<------- 3.
            ...
        }
        ...
  }
    ...
}

The allocation works in tiers: pages come first from the current kbase_mem_pool; if that is insufficient, from pool->next_pool; and if still short, directly from the kernel buddy allocator via kbase_mem_alloc_page. Freeing follows the reverse path with kbase_mem_pool_free_pages, which first tries to return pages to the context's pool, then to pool->next_pool, and finally back to the kernel.

The pool->next_pool is shared by all kbase_context objects and is also used for allocating PGDs for GPU contexts. By manipulating the pools carefully, a freed backing page from a kbase_va_region can be reallocated as a GPU context's PGD. The lingering user space mapping then permits rewriting that PGD, mapping arbitrary kernel memory—including code—into the GPU. This allows modification of kernel code for arbitrary execution, reading and writing kernel data, changing credentials to root, and disabling SELinux. A working exploit for the Pixel 8 is available with setup notes.

Why MTE fails here

Memory Tagging Extension (MTE) is designed to detect corruption by tagging memory blocks and checking those tags at dereference time, using unused high bits in 64-bit pointers. For use-after-free, tags are cleared on free and reassigned on allocation, so dereferencing a freed object should trigger a mismatch.

Hardware acceleration for tagging appears in ARMv8.5a, with software support in the kernel's SLUB and buddy allocators. This bug, however, achieves use-after-free through a user space mapping rather than direct kernel dereferencing. While one might suspect the custom kbase_mem_pool is at fault—since pages recycled through it are never freed to the buddy allocator—the author also tested returning the page properly to the buddy allocator, and MTE still did not trigger. The root cause appears to be in the mapping creation: when the page is accessed after being freed, the mapping goes through mgm_vmf_insert_pfn_prot and kbase_csf_user_io_pages_vm_fault, which ultimately uses insert_pfn to place page frames directly into the user space page table. This bypasses kernel-level dereferencing, so MTE checks are never performed.

Implications

CVE-2025-0072 demonstrates that MTE can be bypassed when freed memory pages are accessed via user space mappings inserted by a driver—not just when the freed memory is accessed from the GPU, as in previously reported vulnerabilities. This scenario is far more common and suggests MTE protections are incomplete against such driver-inserted mappings.