MTE in Context

Memory Tagging Extension (MTE) is an Arm64 feature designed to detect memory corruption at the moment it occurs, rather than at a later exploitation stage. It works by repurposing unused high bits in 64-bit pointers to store a small tag. Each memory allocation is assigned its own tag, which is also stored alongside the memory block. When a pointer is dereferenced, hardware compares the pointer's tag against the memory block's tag. A mismatch indicates an invalid access.

This approach catches two common bug classes. A linear overflow into an adjacent object will likely hit a block with a different tag, triggering a fault. Similarly, a use-after-free is detected because the freed block's tag is changed on reallocation, so the stale pointer's tag no longer matches.

What distinguishes MTE from earlier mitigations like Kernel Control Flow Integrity (kCFI) is timing. kCFI and similar defenses attempt to disrupt the later stages of an exploit chain. MTE, by contrast, halts corruption at the first faulty memory access. An attacker never gains a primitive from a corrupted pointer, making MTE exceptionally difficult to bypass in practice.

While MTE could theoretically be implemented in software, the overhead of tagging on every allocation and checking on every dereference would be prohibitive. Hardware support, introduced in the Arm v8.5a architecture, makes the scheme practical. Most Android devices with MTE-capable silicon use Arm v9 processors. Even with hardware assist, MTE is probabilistic: with only 4 tag bits, an out-of-bounds access has roughly a 1/16 chance of hitting an object with the same tag. Side-channel attacks, such as Spectre, could leak enough information to align tags deliberately, but such attacks are predominantly viable only for local attackers. Google Project Zero's "MTE As Implemented" series covers these limitations in depth.

Beyond silicon, MTE needs OS support. The Pixel 8 is currently the only handset with a user-facing MTE toggle, and it is off by default. Enabling MTE in the kernel requires additional manual steps.

Mali GPU and JIT Memory

The Arm Mali GPU is a common component in Android devices and has been a frequent target for in-the-wild exploits. This particular issue, CVE-2023-6241, is a logic flaw in the Mali GPU's memory management unit, specifically in how it handles JIT memory. It affects GPUs using the Command Stream Frontend (CSF) feature, which includes Google's Pixel 7 and Pixel 8. The bug was fixed in Arm Mali driver r47p0 and shipped to Android in the March 2024 security update.

An application exploiting this vulnerability can gain arbitrary kernel code execution and root on an MTE-enabled Pixel 8. The exploit path runs from an untrusted Android app through the Mali GPU driver, and the author has verified it works with kernel MTE enabled.

JIT memory is a GPU-specific allocation type that allows the kernel driver to manage memory reserved for just-in-time compilation. The handling of JIT memory contains a logic error that can be leveraged to corrupt kernel memory. This bug is closely related to a previously reported issue in the same code path. The interaction between the driver's JIT memory tracking and the GPU's memory management unit creates an opportunity to achieve write access to arbitrary kernel addresses, bypassing the protection MTE would normally provide.

How JIT memory is managed in the Mali driver

Mali GPU memory that a user app wants the GPU to access must be mapped into the GPU's address space. A kbase_context object, created when the driver file is opened, manages these GPU memory resources. Each open file descriptor gets its own context. Within a context, a kbase_va_region represents a memory region. Its nr_pages field gives the region's virtual size, which is fixed, while gpu_alloc->nents tracks the actual number of backing physical pages, which can change when the region is resized.

JIT memory is a special type of native GPU memory whose allocation and freeing are controlled by commands sent from the user app. On CSF-based GPUs, these are software commands executed on the CPU, not on the GPU hardware. Software commands go through a kbase_kcpu_command_queue, created via the KBASE_IOCTL_KCPU_QUEUE_CREATE ioctl. To allocate or free JIT memory, the app enqueues BASE_KCPU_COMMAND_TYPE_JIT_ALLOC or BASE_KCPU_COMMAND_TYPE_JIT_FREE commands using KBASE_IOCTL_KCPU_QUEUE_ENQUEUE.

Freed JIT memory is not immediately returned; it goes into a pool managed by the kbase_context. When a new allocation request arrives, the driver first looks for a suitable region in that pool. If one exists with a matching virtual size but insufficient physical backing, kbase_jit_allocate tries to grow it via kbase_jit_grow:


struct kbase_va_region *kbase_jit_allocate(struct kbase_context *kctx,
    const struct base_jit_alloc_info *info,
    bool ignore_pressure_limit)
{
  ...
  kbase_gpu_vm_lock(kctx);
  mutex_lock(&kctx->jit_evict_lock);
  /*
   * Scan the pool for an existing allocation which meets our
   * requirements and remove it.
   */
  if (info->usage_id != 0)
    /* First scan for an allocation with the same usage ID */
    reg = find_reasonable_region(info, &kctx->jit_pool_head, false);
  ...
}

The grow path can release the kctx->reg_lock to allocate more physical pages:


static int kbase_jit_grow(struct kbase_context *kctx,
 const struct base_jit_alloc_info *info,
 struct kbase_va_region *reg,
 struct kbase_sub_alloc **prealloc_sas,
 enum kbase_caller_mmu_sync_info mmu_sync_info)
{
    ...
  if (!kbase_mem_evictable_unmake(reg->gpu_alloc))
    goto update_failed;
    ...
  old_size = reg->gpu_alloc->nents;                      //commit_pages - reg->gpu_alloc->nents;    //<---------2.
  pages_required = delta;
    ...
  while (kbase_mem_pool_size(pool) mem_partials_lock);
    kbase_gpu_vm_unlock(kctx);                        //<---------- lock dropped.
    ret = kbase_mem_pool_grow(pool, pool_delta);
    kbase_gpu_vm_lock(kctx);
        ...
}

If no pooled region is suitable, the driver creates a fresh JIT region from scratch:


struct kbase_va_region *kbase_jit_allocate(struct kbase_context *kctx,
    const struct base_jit_alloc_info *info,
    bool ignore_pressure_limit)
{
    ...
  } else {
    /* No suitable JIT allocation was found so create a new one */
    u64 flags = BASE_MEM_PROT_CPU_RD | BASE_MEM_PROT_GPU_RD |
        BASE_MEM_PROT_GPU_WR | BASE_MEM_GROW_ON_GPF |
        BASE_MEM_COHERENT_LOCAL |
        BASEP_MEM_NO_USER_FREE;
    u64 gpu_addr;
        ...
    mutex_unlock(&kctx->jit_evict_lock);
    kbase_gpu_vm_unlock(kctx);
    reg = kbase_mem_alloc(kctx, info->va_pages, info->commit_pages, info->extension,
              &flags, &gpu_addr, mmu_sync_info);
   ...
}

The kctx->reg_lock protects concurrent access to memory regions, ensuring that another thread cannot change a region's physical size while it is being manipulated. This lock is what previously prevented a race identified as GHSL-2023-005. That vulnerability involved shrinking a JIT region from another thread via the KBASE_IOCTL_MEM_COMMIT ioctl while kbase_mem_pool_grow was running. The race changed reg->gpu_alloc->nents after kbase_mem_pool_grow, leaving the cached old_size and delta values stale. Those stale values were later used to map the region, creating an inconsistency in the GPU memory map. After the fix, KBASE_IOCTL_MEM_COMMIT can no longer resize JIT regions, closing that hole.


static int kbase_jit_grow(struct kbase_context *kctx,
 const struct base_jit_alloc_info *info,
 struct kbase_va_region *reg,
 struct kbase_sub_alloc **prealloc_sas,
 enum kbase_caller_mmu_sync_info mmu_sync_info)
{
    ...
   //grow memory pool
    ...
    //delta use for allocating pages
    gpu_pages = kbase_alloc_phy_pages_helper_locked(reg->gpu_alloc, pool,
            delta, &prealloc_sas[0]);
    ...
    //old_size used for growing gpu mapping
    ret = kbase_mem_grow_gpu_mapping(kctx, reg, info->commit_pages,
            old_size);
    ...
}

A race in the JIT grow path

When the GPU accesses an address in a memory region that isn’t backed by a physical page, the resulting fault is handled by kbase_mmu_page_fault_worker. In some cases, the handler can allocate and map a physical page on the fly to back the faulting address. The handler performs several checks to verify that the region is allowed to grow; the relevant ones for JIT memory are the GROWABLE_FLAGS_REQUIRED and KBASE_REG_DONT_NEED flag checks:


void kbase_mmu_page_fault_worker(struct work_struct *data)
{
    ...
    kbase_gpu_vm_lock(kctx);
    ...
  if ((region->flags & GROWABLE_FLAGS_REQUIRED)
      != GROWABLE_FLAGS_REQUIRED) {
    kbase_gpu_vm_unlock(kctx);
    kbase_mmu_report_fault_and_kill(kctx, faulting_as,
        "Memory is not growable", fault);
    goto fault_done;
  }

  if ((region->flags & KBASE_REG_DONT_NEED)) {
    kbase_gpu_vm_unlock(kctx);
    kbase_mmu_report_fault_and_kill(kctx, faulting_as,
        "Don't need memory can't be grown", fault);
    goto fault_done;
  }

    ...
  spin_lock(&kctx->mem_partials_lock);
  grown = page_fault_try_alloc(kctx, region, new_pages, &pages_to_grow,
      &grow_2mb_pool, prealloc_sas);
  spin_unlock(&kctx->mem_partials_lock);
    ...
}

GROWABLE_FLAGS_REQUIRED is set on a JIT region at creation and never changes:

#define GROWABLE_FLAGS_REQUIRED (KBASE_REG_PF_GROW | KBASE_REG_GPU_WR)

These flags are applied by kbase_jit_allocate when a JIT region is first created:


struct kbase_va_region *kbase_jit_allocate(struct kbase_context *kctx,
    const struct base_jit_alloc_info *info,
    bool ignore_pressure_limit)
{
    ...
  } else {
    /* No suitable JIT allocation was found so create a new one */
    u64 flags = BASE_MEM_PROT_CPU_RD | BASE_MEM_PROT_GPU_RD |
        BASE_MEM_PROT_GPU_WR | BASE_MEM_GROW_ON_GPF |      //jit_evict_lock);
    kbase_gpu_vm_unlock(kctx);
    reg = kbase_mem_alloc(kctx, info->va_pages, info->commit_pages, info->extension,
              &flags, &gpu_addr, mmu_sync_info);
   ...
}

The KBASE_REG_DONT_NEED flag is added when the region is freed, but it is cleared in kbase_jit_grow before the kctx->reg_lock and kctx->mem_partials_lock are dropped and before kbase_mem_pool_grow runs:


static int kbase_jit_grow(struct kbase_context *kctx,
 const struct base_jit_alloc_info *info,
 struct kbase_va_region *reg,
 struct kbase_sub_alloc **prealloc_sas,
 enum kbase_caller_mmu_sync_info mmu_sync_info)
{
  ...
  if (!kbase_mem_evictable_unmake(reg->gpu_alloc))    //<----- Remove KBASE_REG_DONT_NEED
  goto update_failed;
    ...
  while (kbase_mem_pool_size(pool) mem_partials_lock);
    kbase_gpu_vm_unlock(kctx);
    ret = kbase_mem_pool_grow(pool, pool_delta);      //<----- race window: fault handler grows region
    kbase_gpu_vm_lock(kctx);
        ...
}

During the race window in the snippet above, a JIT region is still allowed to grow on a page fault. By forcing a fault on unmapped memory in another thread while kbase_mem_pool_grow executes, an attacker can grow the JIT region concurrently. This changes reg->gpu_alloc->nents and invalidates the cached old_size and delta values used later in the function:


static int kbase_jit_grow(struct kbase_context *kctx,
 const struct base_jit_alloc_info *info,
 struct kbase_va_region *reg,
 struct kbase_sub_alloc **prealloc_sas,
 enum kbase_caller_mmu_sync_info mmu_sync_info)
{
    ...
  if (!kbase_mem_evictable_unmake(reg->gpu_alloc))
    goto update_failed;
    ...
  old_size = reg->gpu_alloc->nents;                      //commit_pages - reg->gpu_alloc->nents;    //<---------2.
  pages_required = delta;
    ...
  while (kbase_mem_pool_size(pool) mem_partials_lock);
    kbase_gpu_vm_unlock(kctx);
    ret = kbase_mem_pool_grow(pool, pool_delta);  //gpu_alloc->nents changed by fault handler
    kbase_gpu_vm_lock(kctx);
        ...
   //delta use for allocating pages
    gpu_pages = kbase_alloc_phy_pages_helper_locked(reg->gpu_alloc, pool,   //commit_pages,         //<----- 4.
            old_size);
    ...
}

When delta and old_size are later used to allocate backing pages and map them into GPU address space, they are stale. This is structurally similar to GHSL-2023-005, and since kbase_mem_pool_grow performs large allocations, the race is easy to win. There is one critical difference: GHSL-2023-005 let us shrink the JIT region, while CVE-2023-6241 only allows growing it. To see why that matters, we need to recall how the earlier exploit worked.

The number of backing pages for a kbase_va_region is stored in reg->gpu_alloc->nents. Each region has two kbase_mem_phy_alloc objects, cpu_alloc and gpu_alloc, which manage the physical pages; on Android these point to the same object. The pages array inside kbase_mem_phy_alloc holds the physical addresses of the backing pages, and nents tracks the length of that array:


struct kbase_mem_phy_alloc {
    ...
  size_t                nents;
  struct tagged_addr    *pages;
    ...
}

When kbase_alloc_phy_pages_helper_locked allocates new pages, it appends them to pages starting at index nents, then updates nents. In kbase_jit_grow, delta is the number of pages added:


static int kbase_jit_grow(struct kbase_context *kctx,
 const struct base_jit_alloc_info *info,
 struct kbase_va_region *reg,
 struct kbase_sub_alloc **prealloc_sas,
 enum kbase_caller_mmu_sync_info mmu_sync_info)
{
    ...
   //delta use for allocating pages
    gpu_pages = kbase_alloc_phy_pages_helper_locked(reg->gpu_alloc, pool,
            delta, &prealloc_sas[0]);
    ...
}

So delta pages are inserted at index nents in the allocation’s pages array:

After the backing pages are allocated and stored, kbase_mem_grow_gpu_mapping maps them into the GPU address space. The virtual layout of the region is described by start_pfn (the first address as a page frame) and nr_pages (the region’s size); both are set once and never change:


struct kbase_va_region {
    ...
  u64 start_pfn;
    ...
  size_t nr_pages;
    ...
}

Only the first nents pages of the region’s virtual address space are backed by physical memory, and the backed range must be contiguous, starting from the region’s beginning. For example, a layout like this is valid:

But one where the backing doesn’t start at the region’s start is invalid:

So is one with gaps in the backed range:

During kbase_jit_grow, kbase_mem_grow_gpu_mapping maps GPU addresses from (start_pfn + old_size) * 0x1000 to (start_pfn + info->commit_pages) * 0x1000, using pages from index old_size to info->commit_pages in the pages array (since delta = info->commit_pages - old_size):


static int kbase_jit_grow(struct kbase_context *kctx,
 const struct base_jit_alloc_info *info,
 struct kbase_va_region *reg,
 struct kbase_sub_alloc **prealloc_sas,
 enum kbase_caller_mmu_sync_info mmu_sync_info)
{
    ...
    old_size = reg->gpu_alloc->nents;
    delta = info->commit_pages - reg->gpu_alloc->nents;
    ...
    //old_size used for growing gpu mapping
    ret = kbase_mem_grow_gpu_mapping(kctx, reg, info->commit_pages,
            old_size);
    ...
}

The same old_size value serves double duty: it selects both the GPU address at which new mappings begin and the offset into the pages array where backing pages are taken from.

If nents changes after old_size and delta are cached, those offsets are wrong. In GHSL-2023-005, shrinking the region made nents smaller, so the new pages were inserted at pages + nents, before the stale offset old_size:

And the mapping began at (start_pfn + old_size) * 0x1000, covering pages from pages + old_size to pages + nents + delta. The pages between pages + nents and pages + old_size were never mapped, while some GPU addresses ended up with no backing page:

From a stale offset to a dangling mapping

GPU mappings are removed by kbase_mmu_teardown_pgd_pages, which walks a GPU address range and marks entries invalid in the page table. If it hits a high-level entry (for instance, a level 2 PTE covering 512 pages) that is already invalid, it skips the whole covered range, assuming those addresses are already unmapped:


static int kbase_mmu_teardown_pgd_pages(struct kbase_device *kbdev, struct kbase_mmu_table *mmut,
          u64 vpfn, size_t nr, u64 *dirty_pgds,
          struct list_head *free_pgds_list,
          enum kbase_mmu_op_type flush_op)
{
        ...
        for (level = MIDGARD_MMU_TOPLEVEL;
                level ate_is_valid(page[index], level))
                break; /* keep the mapping */
            else if (!mmu_mode->pte_is_valid(page[index], level)) {  //<------ 1.
                /* nothing here, advance */
                switch (level) {
                ...
                case MIDGARD_MMU_LEVEL(2):
                    count = 512;            // nr)
                    count = nr;
                goto next;
            }
        ...
next:
        kunmap(phys_to_page(pgd));
        vpfn += count;
        nr -= count;

This function is called whenever a kbase_va_region is shrunk or deleted. Under normal conditions, its assumption is safe: because mappings are contiguous from the start of the region, if any address in the region is mapped, then the first one must be, so a high-level entry covering the start will be valid. If it isn’t, then nothing is mapped and the skip is correct:

Likewise, when region shrinks begin inside a region, an invalid high-level entry means the shrink point is in an unmapped area, so everything past it is unmapped too:

Mappings must therefore only ever exist contiguously from the region start for kbase_mmu_teardown_pgd_pages to act correctly. GHSL-2023-005 broke that assumption by shrinking a region to zero size during the race, leaving a region whose start was unmapped but which still had valid mappings further in:

When such a region is deleted, kbase_mmu_teardown_pgd_pages starts at the first address, finds an invalid level 2 PTE, and skips 512 pages—some of which may still have live mappings:

Addresses in that skipped range stay mapped to entries in the pages array that are no longer owned, giving an attacker GPU-side access to freed physical pages. CVE-2023-6241 does not directly provide the shrink primitive, so reaching the same state requires a different path—one that will be the focus of the next part.

Mapping a path to arbitrary code execution

The race condition becomes exploitable when a region grows during the race window. In that case, nents ends up larger than old_size when kbase_alloc_phy_pages_helper_locked and kbase_mem_grow_gpu_mapping run, and the delta pages are inserted at index nents of the pages array:

The pages array now holds enough pages to back both the JIT growth and the fault access, which is exactly the state expected when kbase_jit_grow runs after the page fault handler. When kbase_mem_grow_gpu_mapping maps the delta pages starting at (start_pfn + old_size) * 0x1000, the total backing page count has grown by fh + delta (where fh is the number of pages the fault handler added), leaving the last fh pages in the array unmapped:

On its own this doesn't create an obvious problem: the region's start addresses are still mapped, there is no gap, and the unmapped pages are simply freed on region deletion, so it's not even a leak. However, triggering another GPU fault in the affected JIT region changes the picture. A fault handler that finds an unmapped address adds backing pages and maps them starting from (start_pfn + reg->gpu_alloc->nents) * 0x1000, preserving the invariant that only addresses at the start of a region are mapped. With a fault at some fault_addr, it appends new_pages = fault_addr/0x1000 - reg->gpu_alloc->nents pages (plus any padding). This maps new pages after the unmapped region, producing a gap in the GPU mappings:

Since delta must be non-zero and the first delta + old_size pages remain mapped, the start of the region can't be left unmapped as in GHSL-2023-005. The alternative is to shrink the region so the resulting size lands inside the unmapped gap.

A JIT region can only be shrunk via the BASE_KCPU_COMMAND_TYPE_JIT_FREE GPU command. This doesn't destroy the kbase_va_region but places it in a pool for reuse, and before that, kbase_jit_free trims the region based on its initial_commit size and the context's trim_level:


void kbase_jit_free(struct kbase_context *kctx, struct kbase_va_region *reg)
{
    ...
  old_pages = kbase_reg_current_backed_size(reg);
  if (reg->initial_commit initial_commit,
      div_u64(old_pages * (100 - kctx->trim_level), 100));
    u64 delta = old_pages - new_size;
    if (delta) {
      mutex_lock(&kctx->reg_lock);
      kbase_mem_shrink(kctx, reg, old_pages - delta);
      mutex_unlock(&kctx->reg_lock);
    }
  }
  ...
}

With control over the shrink size, the exploitation sequence is:

  1. Create a JIT region and trigger the bug, arranging the GPU fault so the handler adds fault_size pages — enough to cover at least one level 2 PTE. After the bug, only the first old_size + delta pages are GPU-mapped, while the backing store holds old_size + delta + fault_size pages.

  1. Trigger a second fault at an offset beyond the backing page count, appending and mapping pages after the unmapped area from step 1.

  1. Free the JIT region with BASE_KCPU_COMMAND_TYPE_JIT_FREE, shrinking it via kbase_jit_free so the final size (final_size) falls within the unmapped span covered by the first level 2 PTE.

When the region shrinks, kbase_mmu_teardown_pgd_pages unmaps GPU addresses from region_start + final_size to the region's end. Because the entire range covered by the first level 2 PTE is already unmapped, the teardown hits !mmu_mode->pte_is_valid at a level 2 PTE and skips the next 512 pages from region_start + final_size. Addresses belonging to the following level 2 PTE are still mapped and get skipped incorrectly, leaving them mapped to pages that are about to be freed:

After the shrink completes, those backing pages are freed while the orange region in the figure retains GPU access to them. Reusing a freed backing page as any kernel page opens several exploitation routes. One is the previously described technique of replacing the backing page with a page-table global directory (PGD) for the GPU kbase_context.

Making a freed backing page become a PGD

Backing store pages for a kbase_va_region are allocated through 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);     //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.
            ...
        }
        ...
  }
    ...
}

Allocation is tiered: pages come first from the kbase_mem_pool associated with the kbase_context (kbase_mem_pool_remove_locked, step 1), then from pool->next_pool (step 2), and finally from the kernel buddy allocator via kbase_mem_alloc_page. Freeing follows the reverse path with kbase_mem_pool_free_pages, returning pages to the context pool first, then pool->next_pool, and finally to the buddy allocator.

Critically, pool->next_pool is shared by all kbase_context instances and is also used to allocate PGDs for GPU contexts. By arranging the memory pools carefully, a freed backing page from a kbase_va_region can be reused as a GPU context's PGD. Once that happens, the GPU addresses still referencing the freed page allow rewriting the PGD from the GPU. This maps arbitrary kernel memory — including kernel code — into the GPU address space, enabling writes to kernel code for arbitrary code execution, reads and writes of arbitrary kernel data, and easy escalation to root or disabling SELinux by rewriting process credentials.

Why MTE doesn't stop this

None of the exploit steps specifically target MTE, and MTE doesn't interfere with any of them. MTE guards against dereferences of pointers to inconsistent memory, but this bug never dereferences anything invalid. The race creates an inconsistency between the pages array and GPU mappings; viewed separately, neither contains invalid entries. When the bug causes kbase_mmu_teardown_pgd_pages to skip removing mappings, the effect is that physical addresses of freed pages remain in the GPU page table. The GPU then accesses those freed pages directly by physical address, with no pointer dereference involved — and it's unclear whether MTE applies to GPU memory accesses at all. Using the GPU to touch physical addresses directly sidesteps MTE entirely. Memory-safe code can't help here: at some layer, physical addresses must be used directly to reach memory.

Conclusion

CVE-2023-6241 yields arbitrary kernel code execution on a Pixel 8 with kernel MTE enabled. MTE is a major step forward and will make many memory-corruption bugs unexploitable, but it is not a complete solution: a single bug like this one still achieves full kernel compromise. The bypass works by using a coprocessor — the GPU — to access physical memory directly, as described in case 4 of Google Project Zero's "MTE As Implemented, Part 3: The Kernel." As CPU-side mitigations grow stronger, coprocessors and their kernel drivers will remain an attractive attack surface.