When JIT memory went unprotected

A two-line fix that Arm shipped in its Mali GPU driver r40p0 release in October 2022 was intended to harden the kernel driver against a whole class of memory manipulation bugs. Yet when that release's security patches were backported to the Pixel 6's Android kernel in January 2023, and later in the March feature drop, those two lines were conspicuously absent from one function.

@@ -2262,10 +2258,13 @@ int kbase_mem_commit(struct kbase_context *kctx, u64 gpu_addr, u64 new_pages)

        if (atomic_read(&reg->cpu_alloc->kernel_mappings) > 0)
                goto out_unlock;
        if (reg->flags & KBASE_REG_DONT_NEED)
                goto out_unlock;

+       if (reg->flags & KBASE_REG_NO_USER_FREE)
+               goto out_unlock;

Without them, the Pixel 6 kernel shipped with a high-severity vulnerability tracked as GHSL-2023-005. The bug is a use-after-free and can be exploited from a malicious app to gain arbitrary kernel code execution and root privileges.

A patch that slipped through

Arm's r40p0 release included several security fixes for the Mali GPU driver. On the Pixel 6 (which runs driver version r36p0), the January 2023 update pulled in the relevant security backports. But the specific change added to the kbase_mem_commit path—an evict_list membership check that guards JIT memory—did not make it.

The check was designed as a hardening measure for scenarios similar to CVE-2022-38181, a JIT memory use-after-free reported earlier in the driver. After spotting the omission, a proof-of-concept exploit for the Pixel 6 was reported to the Android security team in January 2023. The issue was rated high severity, then duplicated against an internal report, and fixed silently in the March feature drop update (released March 20, 2023 for Pixel 6), when the driver source moved to the Android 13 qpr2 branch. That branch uses a newer Mali driver where the fix is expressed via the kbase_va_region_is_no_user_free function, which first appeared upstream in r41.

@@ -2270,8 +2237,11 @@

    if (atomic_read(&reg->cpu_alloc->kernel_mappings) > 0)
        goto out_unlock;
-   /* can't grow regions which are ephemeral */
-   if (reg->flags & KBASE_REG_DONT_NEED)
+
+   if (kbase_is_region_shrinkable(reg))
+       goto out_unlock;
+
+   if (kbase_va_region_is_no_user_free(kctx, reg))
        goto out_unlock;

Arm later acknowledged that the backporting process to r36p0 likely failed to carry the change over. The fix only landed on Pixel phones because the qpr2 branch upgraded the driver wholesale, not because a targeted patch was applied to the older code.

The Mali GPU and JIT memory life cycle

The Arm Mali GPU is ubiquitously deployed in Android devices, smart TVs, and other SoC-based hardware. On Android phones it has been a frequent target for both researcher and in-the-wild exploits. This vulnerability continues a pattern around JIT memory handling in the Mali driver.

JIT memory differs from other GPU memory types in that its lifetime is managed by the kernel driver. A user app submits a BASE_JD_REQ_SOFT_JIT_ALLOC job through the KBASE_IOCTL_JOB_SUBMIT ioctl to allocate such memory, and a BASE_JD_REQ_SOFT_JIT_FREE job to free it. But "freeing" is not immediate. Rather than release the kbase_va_region that represents the memory on the spot, the driver's kbase_jit_free function shrinks the region to a minimal size, orchestrates cleanup that drops references from user space, and moves it to the kbase_context's evict_list where it may eventually be reclaimed by the kernel's shrinker.

void kbase_jit_free(struct kbase_context *kctx, struct kbase_va_region *reg)
{
    ...
    //First reduce the size of the backing region and unmap the freed pages
    old_pages = kbase_reg_current_backed_size(reg);
    if (reg->initial_commit < old_pages) {
        u64 new_size = MAX(reg->initial_commit,
            div_u64(old_pages * (100 - kctx->trim_level), 100));
        u64 delta = old_pages - new_size;
        //Free delta pages in the region and reduces its size to old_pages - delta
        if (delta) {
            mutex_lock(&kctx->reg_lock);
            kbase_mem_shrink(kctx, reg, old_pages - delta);
            mutex_unlock(&kctx->reg_lock);
        }
    }
    ...
void kbase_jit_free(struct kbase_context *kctx, struct kbase_va_region *reg)
{
    ...
    mutex_lock(&kctx->jit_evict_lock);
    /* This allocation can't already be on a list. */
    WARN_ON(!list_empty(&reg->gpu_alloc->evict_node));
    //Add reg to evict_list
    list_add(&reg->gpu_alloc->evict_node, &kctx->evict_list);
    atomic_add(reg->gpu_alloc->nents, &kctx->evict_nents);
    //Move reg to jit_pool_head
    list_move(&reg->jit_node, &kctx->jit_pool_head);
    ...
}

An alternate path to the evict_list

A separate ioctl, KBASE_IOCTL_MEM_FLAGS_CHANGE, can also transfer a memory region to the evict_list via the kbase_mem_flags_change function. When invoked with the BASE_MEM_DONT_NEED flag, the region is queued for reclaim outside of the normal JIT free routine.

int kbase_mem_flags_change(struct kbase_context *kctx, u64 gpu_addr, unsigned int flags, unsigned int mask)
{
    ...
    prev_needed = (KBASE_REG_DONT_NEED & reg->flags) == KBASE_REG_DONT_NEED;
    new_needed = (BASE_MEM_DONT_NEED & flags) == BASE_MEM_DONT_NEED;
    if (prev_needed != new_needed) {
        ...
        if (new_needed) {
            ...
            ret = kbase_mem_evictable_make(reg->gpu_alloc);  //<------ Add to `evict_list`
            if (ret)
                goto out_unlock;
        } else {
            kbase_mem_evictable_unmake(reg->gpu_alloc);     //<------- Remove from `evict_list`
        }
    }

That second path was the root cause of CVE-2022-38181: a JIT region could be added directly to the evict_list without the cleanup steps enforced by kbase_jit_free, enabling a use-after-free. Arm's r40p0 fix closed that hole by blocking memory regions with the KBASE_REG_NO_USER_FREE flag from being added to evict_list in kbase_mem_flags_change. JIT regions carry KBASE_REG_NO_USER_FREE from creation until release, so the check prevents JIT memory from being made evictable anywhere except via kbase_jit_free.

@@ -951,6 +951,15 @@
    if (kbase_is_region_invalid_or_free(reg))
        goto out_unlock;

+   /* There is no use case to support MEM_FLAGS_CHANGE ioctl for allocations
+    * that have NO_USER_FREE flag set, to mark them as evictable/reclaimable.
+    * This would usually include JIT allocations, Tiler heap related allocations
+    * & GPU queue ringbuffer and none of them needs to be explicitly marked
+    * as evictable by Userspace.
+    */
+   if (reg->flags & KBASE_REG_NO_USER_FREE)
+       goto out_unlock;
+
    /* Is the region being transitioning between not needed and needed? */
    prev_needed = (KBASE_REG_DONT_NEED & reg->flags) == KBASE_REG_DONT_NEED;
    new_needed = (BASE_MEM_DONT_NEED & flags) == BASE_MEM_DONT_NEED;

The completion that never reached the Pixel

But memory lifetime isn't limited to the kbase_va_region container. The backing pages inside a region can also be trimmed independently through the KBASE_IOCTL_MEM_COMMIT ioctl, which adjusts the page count. Because JIT regions also support this operation, Arm added the same KBASE_REG_NO_USER_FREE check in kbase_mem_commit to stop JIT memory being shrunk or otherwise manipulated outside the dedicated BASE_JD_SOFT_JIT_FREE flow.

@@ -2262,10 +2258,13 @@ int kbase_mem_commit(struct kbase_context *kctx, u64 gpu_addr, u64 new_pages)

        if (atomic_read(&reg->cpu_alloc->kernel_mappings) > 0)
                goto out_unlock;
        if (reg->flags & KBASE_REG_DONT_NEED)
                goto out_unlock;

+       if (reg->flags & KBASE_REG_NO_USER_FREE)
+               goto out_unlock;

When that two-line check is absent, kbase_mem_commit can release the backing pages of a JIT region without the bookkeeping that keeps the region's lifetime consistent. The result is the same class of use-after-free as the earlier vulnerability, but triggered through a different API. The fix that should have arrived as part of a routine backport effort only became effective when the whole driver moved a version forward.

A small race window in JIT reuse

The upstream fix to kbase_mem_commit blocks the obvious route into manipulating JIT backing pages through the KBASE_IOCTL_MEM_COMMIT ioctl. But the patch is less comprehensive than it first appears: even prior to the change, kbase_mem_commit already refused regions carrying either the KBASE_REG_ACTIVE_JIT_ALLOC or KBASE_REG_DONT_NEED flag, and JIT regions are effectively always tagged with one or the other throughout their lifetime.

Almost always, that is. One window remains, and it sits in the JIT region recycling path. When a BASE_JD_REQ_SOFT_JIT_FREE job frees a region, the region goes onto the jit_pool_head list with KBASE_REG_DONT_NEED set. A later BASE_JD_REQ_SOFT_JIT_ALLOC job can reuse it: kbase_jit_allocate finds the pooled region, then calls kbase_jit_grow. Before resizing, kbase_jit_grow invokes kbase_mem_evictable_unmake, which drops the KBASE_REG_DONT_NEED flag and pulls the region off the evict_list.

From that point until the resize completes, the region holds neither KBASE_REG_ACTIVE_JIT_ALLOC nor KBASE_REG_DONT_NEED. The kctx->reg_lock normally protects it, but not continuously. If the grow operation needs more pages than the context's memory pool holds, the pool itself must grow — and that path releases the lock:

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)
{
    ...
    while (kbase_mem_pool_size(pool) < pages_required) {
      int pool_delta = pages_required - kbase_mem_pool_size(pool);
      int ret;
      kbase_mem_pool_unlock(pool);
      spin_unlock(&kctx->mem_partials_lock);
      kbase_gpu_vm_unlock(kctx);                   
      ret = kbase_mem_pool_grow(pool, pool_delta);  //<------- race window 
      kbase_gpu_vm_lock(kctx);
      ...
   }
}

During that gap between kbase_gpu_vm_unlock and kbase_gpu_vm_lock, another thread can call kbase_mem_commit on the region and resize its backing store. Since kbase_mem_pool_grow performs large kernel allocations, the window is wide enough to hit reliably.

What a race buys

The consequences depend on how the driver tracks backing pages and resizes them. Each kbase_va_region has a kbase_mem_phy_alloc, held in the gpu_alloc and cpu_alloc fields (identical in this configuration). The relevant fields inside are nents, the backing page count, and pages, an array of physical page addresses.

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

Growing a region appends new page addresses to pages and bumps nents, then maps the new pages into the GPU page table. Shrinking removes entries and mappings. Crucially, when kbase_jit_grow starts, it stores two values: old_size, the region's size before growth, and delta, the number of pages the region still needs.

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)
{
    ...
    /* Grow the backing */
    old_size = reg->gpu_alloc->nents;

    /* Allocate some more pages */
    delta = info->commit_pages - reg->gpu_alloc->nents;
    ...
    //grow memory pool
    ...

Both values are read again after the pool-growth operation — by which point the concurrent kbase_mem_commit may have already changed reg->gpu_alloc->nents. The subsequent calls use the stale values:

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)
{
    ...
    //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);

kbase_alloc_phy_pages_helper_locked appends delta new pages to the pages array starting at position nents. kbase_mem_grow_gpu_mapping then maps the range between pages + old_size and pages + info->commit_pages into the GPU address space starting at region_start_address + old_size * 0x1000.

If the concurrent shrink made nents smaller than old_size, the two operations work from inconsistent starting points. The palette allocator adds delta pages immediately after the current nents entries, while the GPU mapping covers addresses based on the old region geometry:

The result is two disjoint gap sets: entries in pages with no corresponding GPU mapping, and GPU mappings backed by NULL addresses. Neither state is directly useful. Unmapped backing pages simply fault when accessed from the GPU; mapped-but-empty addresses will almost certainly crash the device.

A teardown shortcut with hidden assumptions

Another code path deserves scrutiny: kbase_mmu_teardown_pages, which invalidates GPU page table entries before backing pages are released. The function walks the target GPU address range and, where a high-level page table entry is already invalid, skips a fixed number of addresses instead of descending into the lower-level tables:

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

The skipped amount corresponds to the entry's span — 512 pages for a level 2 entry. When the invalid entry aligns perfectly with that span, skipping the lower levels is fine, because those addresses can only be reached through the invalid entry anyway.

Alignment, however, is not guaranteed. Suppose a level 2 entry is invalid while the following entry is valid, and kbase_mmu_teardown_pages is called at an address 256 pages into the invalid entry. The check fails, and the cleanup skips all 512 pages from that start point. The first 256 pages lie under the invalid entry, so skipping them is correct. The next 256 pages, though, fall under the valid neighboring entry and remain reachable — skipping their teardown leaves stale mappings behind:

In practice, callers always pass a valid start address vpfn, which keeps the invalid-entry condition confined to aligned cases where the skip is safe. The optimization is therefore correct under the driver's actual usage — but it depends on that invariant holding in every call path.

Turning the stale mapping into privilege escalation

As described, the race leaves a JIT region with a kbase_va_region whose GPU address space start is unmapped, despite the region still being tracked by the Mali driver. The key to exploitation is combining this with the way kbase_mmu_teardown_pages handles its teardown iteration.

When the corrupted JIT region is eventually freed, its start address is passed to kbase_mmu_teardown_pages to remove its GPU mappings. If three conditions are met, the teardown skips a chunk of still-valid mappings:

  1. The start address is not aligned to the containing level 2 page table entry boundary.
  2. The preceding addresses within that level 2 entry are unmapped.
  3. The unmapped “gap” inside the region extends to the end of that level 2 entry.

When those hold, the level 2 entry becomes invalid. The validity check at the region start then returns false for that entry, and the cleanup code skips past it, leaving GPU mappings in place for the addresses just beyond the gap. Those mappings still reference backing pages that are now freed, so the GPU can keep writing to memory pages the kernel believes are released.

JIT regions are freed only after being placed on the evict_list via a BASE_JD_SOFT_JIT_FREE job, and then only under memory pressure. Applying mmap pressure to trigger the shrinker is unreliable, but the driver offers a way to detect eviction. The KBASE_IOCTL_MEM_QUERY ioctl returns an error for a GPU address whose region has been evicted, so you can poll with it after each allocation attempt. That solves the first practical problem: knowing when the backing pages are actually released.

The freed pages go back to the kernel allocator because the region is marked evicted. In kbase_free_phy_pages_helper, the reclaimed argument is true for an evicted region, so pages bypass the Mali memory pools entirely and return to the kernel, where they can be reused as any other page.

Reclaiming a freed page as a GPU page table

Reusing a kernel page as the backing store for a new kbase_va_region is possible, but only after the kbase_context memory pool and the Mali driver's shared pool (pool->next_pool) are drained. Allocating enough GPU regions will exhaust both pools, forcing new allocations to come directly from the kernel via the buddy allocator. After substantial allocation, one of the new regions will reclaim the freed JIT page.

Because the stale GPU mapping still lets you write to the JIT page's former address, you can mark it with unique values and then scan the new GPU regions for those values. That identifies which region now owns the physical page. By making the owning context's pool full before freeing that region, its page gets pushed up to pool->next_pool, the shared Mali pool. As prior work on this driver has shown, that same shared pool is the source for GPU page table global directories (PGDs). With the page parked in that pool, you can force its allocation as a PGD for a new GPU context.

At that point, writing through the stale JIT mapping goes straight into the PGD of the target context. The bottom level of the GPU page table holds physical addresses for GPU virtual mappings, and because these are not randomized for kernel code and static data on this platform, you can point a GPU virtual address at kernel physical memory. Issuing GPU commands then reads and writes kernel code directly. From there, overwriting the current process's credentials and disabling SELinux yields root.

The exploit in sequence

The full chain, as demonstrated on a Pixel 6:

  1. Allocate a JIT region and free it so it lands on the jit_pool_head list.
  2. Allocate a second JIT region with a large backing store, which reuses the freed region. The large request forces the Mali driver to grow its memory pool.
  3. From another thread, call KBASE_IOCTL_MEM_COMMIT to shrink that region's backing store to zero during the pool grow, creating the unmapped gap.
  4. Free the JIT region with a BASE_JD_SOFT_JIT_FREE job to place it on the evict_list, then raise memory pressure with mmap.
  5. Poll with KBASE_IOCTL_MEM_QUERY until the region is evicted and its backing pages are freed.
  6. From the GPU, write identifying markers through the stale mapping to the reclaimed page.
  7. Drain the Mali memory pools by allocating more GPU regions; find which new region contains your marker.
  8. Free that region so its page moves into the shared pool, then allocate a new GPU context to reuse the page as its PGD.
  9. Modify the PGD entries through the stale JIT mapping so a GPU virtual address points at kernel code, then use GPU commands to overwrite kernel code, elevate privileges, and disable SELinux.

A working exploit for this bug and setup notes are available in the GitHub Security Lab repository.

What the missed patch tells us about backporting

The vulnerability on the Pixel 6 came down to a single change in the Arm Mali driver, version r40p0, that never made it into the device's security updates. The patch was part of a larger set of fixes, but it got left behind when the security-relevant updates were cherry-picked for the Pixel 6.

That gap exposes a broader problem with how security patches are handled. Vendors routinely separate security fixes from other updates so customers can apply a minimal set of patches without taking on backward-incompatible changes. The result is that researchers and attackers only ever see the full set of upstream changes, while the backported subset is what actually reaches devices. The labeling of a fix as security-related or not has no bearing on whether an attacker will exploit it. But that label can change how a researcher prioritizes it.

In this case, the fix was spotted but never investigated further, precisely because it was assumed to already be in the security pipeline. That assumption cost several months. Android users were left exposed to an N-day vulnerability well after the bug was identified and patched upstream. The delay was not caused by a technical failure but by the lack of clear communication around which patches were security-sensitive and which were slated for backporting.

Greater transparency in the backporting process, including flagging security patches in public releases, could shorten that window significantly. It would let researchers focus on the fixes that are actually missing from deployed builds rather than guessing which changes matter.

Notes

  1. Observant readers who learn their European history from a certain comic series may recognise the similarities between this and the beginning of the very first book in the Asterix series, "Asterix the Gaul."