One chip that didn’t get the memo

When the Pixel 6 launched in 2021, it was positioned as Google’s first fully in-house phone. In practice, that claim held for most of the silicon — except for one notable holdout: the Arm Mali GPU. For security researchers, the fortified driver code for the Midgard, Bifrost, and Valhall architectures remains a distinct and difficult target.

One such researcher learned this the hard way while poking at the Mali driver stack. The result was CVE-2022-38181, a flaw that allowed a malicious Android app to escalate to arbitrary kernel code execution and root on a Pixel 6.

Screenshot of an email from the Android security team that the reported bug has been labeled "won't fix."

Reporting and disclosure mess

The vulnerability was reported to the Android security team on 2022-07-12, complete with a proof-of-concept exploit demonstrating root-level kernel code execution from an unprivileged app. It was initially filed under bug ID 238770628 and rated High severity. That assessment didn’t last.

After review, the Android security team reclassified the issue as “Won’t fix” and redirected the report to Arm’s security team. Coordination with Arm proved far smoother — they issued a public patch in driver version r40p0 on 2022-10-07 and agreed to a coordinated disclosure date around mid-November to give users time to update. Arm’s handling was notably faster than past Android-related disclosures.

The Android side was less transparent. Contact with the Android security team remained elusive, and the fix quietly landed in the January Pixel update under a new bug ID, 259695958. Neither the original report ID nor the CVE ID appeared in the official security bulletin. The full advisory, including the disclosure timeline, is available in the GHSL-2022-054 advisory.

Anatomy of the Arm Mali GPU attack surface

The Arm Mali GPU is a device-specific component integrated into a wide range of products, from Android phones to smart TV boxes. International versions of Samsung’s S series phones up to the S21, along with the Pixel 6 series, rely on this GPU. Android GPU drivers remain a prime target for attackers because they can be reached directly from the untrusted app domain, and the market is dominated by just two vendors: Qualcomm’s Adreno and Arm’s Mali. A handful of bugs can therefore cover a huge install base.

This concentration is reflected in real-world exploitation data. Of the seven Android 0-days detected as exploited in the wild in 2021, five targeted GPU drivers. Another exploited bug—CVE-2021-39793, disclosed in March 2022—also hit a GPU driver. Of those six exploited Android GPU driver bugs, three targeted Qualcomm’s GPU and three targeted Arm’s Mali GPU.

Many Mali GPU vulnerabilities lie in memory management code, and the current issue follows that pattern. It involves a special category of GPU memory called JIT memory. Despite the name, JIT memory has nothing to do with JIT-compiled code—it is allocated as non-executable memory. It appears to serve as cache memory, managed by the GPU kernel driver, shared with user applications and returned to the kernel when memory pressure demands it. Unlike other GPU memory types, which are usually created via direct ioctl calls like KBASE_IOCTL_MEM_ALLOC or KBASE_IOCTL_MEM_IMPORT, JIT regions are created by submitting a special GPU instruction through the KBASE_IOCTL_JOB_SUBMIT ioctl.

That ioctl dispatches “job chains” to the GPU—lists of opaque job structures consisting of headers and payload instructions. Most jobs run on the GPU itself, but some, called “softjobs,” are executed by the kernel on the host CPU. Among these softjobs are instructions to allocate and free JIT memory (BASE_JD_REQ_SOFT_JIT_ALLOC and BASE_JD_REQ_SOFT_JIT_FREE).

How JIT memory lives and dies

A user application first creates a kbase_context kernel object by opening the driver file and issuing a series of ioctl calls. Each kbase_context manages resources for one file handle and contains three list heads governing JIT memory: jit_active_head, jit_pool_head, and jit_destroy_head. These track, respectively, regions still in use by userland, unused regions held for reuse, and regions pending return to the kernel. Both jit_pool_head and jit_destroy_head handle freed JIT regions, but the former acts as a pool for quick reallocation while the latter are destined for the kernel’s page allocator.

When a user submits a BASE_JD_REQ_SOFT_JIT_ALLOC job, the kernel ultimately calls kbase_jit_allocate, which first searches jit_pool_head for a reusable region:

    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);
        ...
    if (reg) {
        ...
        list_move(&reg->jit_node, &kctx->jit_active_head);

If a suitable region is found, it is moved to jit_active_head, marking it in use. Otherwise a new region is created and added there. The allocated region—new or recycled—is then stored in the jit_alloc array of the kbase_context by kbase_jit_allocate_process.

When the user no longer needs the JIT memory, it sends a BASE_JD_REQ_SOFT_JIT_FREE job, which invokes kbase_jit_free. Rather than releasing the backing pages immediately, this function shrinks the region to minimal size and removes CPU-side mappings so the pages become unreachable from the user process address space:

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);
        }
    }
    ...
    //Remove the pages from address space of user process
    kbase_mem_shrink_cpu_mapping(kctx, reg, 0, reg->gpu_alloc->nents);    

The backing pages are not fully freed at this stage, nor is the region object itself freed. Instead, reg moves to jit_pool_head and, more interestingly, onto the evict_list of the kbase_context:

    kbase_mem_shrink_cpu_mapping(kctx, reg, 0, reg->gpu_alloc->nents);
    ...
    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);

After kbase_jit_free returns, its caller kbase_jit_free_finish clears the reference stored in jit_alloc—even though reg is still valid:

static void kbase_jit_free_finish(struct kbase_jd_atom *katom)
{
    ...
    for (j = 0; j != katom->nr_extres; ++j) {
        if ((ids[j] != 0) && (kctx->jit_alloc[ids[j]] != NULL)) {
            ...
            if (kctx->jit_alloc[ids[j]] !=
                    KBASE_RESERVED_REG_JIT_ALLOC) {
                ...
                kbase_jit_free(kctx, kctx->jit_alloc[ids[j]]);
            }
            kctx->jit_alloc[ids[j]] = NULL;    //<--------- clean up reference
        }
    }
    ...
}

JIT regions parked in jit_pool_head may later be reused for a new allocation, explaining the pool’s purpose. The jit_destroy_head list comes into play through eviction. When a region is put on the evict_list, it becomes eligible for reclaim under memory pressure. This allows the Mali driver to retain unused JIT memory for fast reuse while still surrendering it to the kernel when resources dwindle.

The Linux kernel’s memory reclamation mechanism relies on shrinkers. Drivers can define a shrinker object with count_objects and scan_objects callbacks:

struct shrinker {
    unsigned long (*count_objects)(struct shrinker *,
                       struct shrink_control *sc);
    unsigned long (*scan_objects)(struct shrinker *,
                      struct shrink_control *sc);
    ...
};

The shrinker is registered via register_shrinker. Under memory pressure, the kernel walks registered shrinkers, uses count_objects to estimate reclaimable memory, then calls scan_objects to actually free it. In the Mali driver, this shrinker is defined and registered in kbase_mem_evictable_init:

int kbase_mem_evictable_init(struct kbase_context *kctx)
{
    ...
    //kctx->reclaim is a shrinker
    kctx->reclaim.count_objects = kbase_mem_evictable_reclaim_count_objects;
    kctx->reclaim.scan_objects = kbase_mem_evictable_reclaim_scan_objects;
    ...
    register_shrinker(&kctx->reclaim);
    return 0;
}

The critical work happens in kbase_mem_evictable_reclaim_scan_objects, which frees the memory the kernel requests:

static
unsigned long kbase_mem_evictable_reclaim_scan_objects(struct shrinker *s,
        struct shrink_control *sc)
{
    ...
    list_for_each_entry_safe(alloc, tmp, &kctx->evict_list, evict_node) {
        int err;

        err = kbase_mem_shrink_gpu_mapping(kctx, alloc->reg,
                0, alloc->nents);
        ...
        kbase_free_phy_pages_helper(alloc, alloc->evicted);
        ...
        list_del_init(&alloc->evict_node);
        ...
        kbase_jit_backing_lost(alloc->reg);   //<------- moves `reg` to `jit_destroy_pool`
    }
    ...
}

This scan walks the evict_list, unmaps backing pages from the GPU (CPU mappings were already dropped in kbase_jit_free), frees those pages, and then calls kbase_jit_backing_lost to move reg from jit_pool_head to jit_destroy_head:

void kbase_jit_backing_lost(struct kbase_va_region *reg)
{
    ...
    list_move(&reg->jit_node, &kctx->jit_destroy_head);

    schedule_work(&kctx->jit_work);
}

Regions sitting in jit_destroy_head are later handled by kbase_jit_destroy_worker, which frees the kbase_va_region and removes all references to it. Or nearly all: one small pointer survives cleanup, and lifetime management in the Arm Mali driver is not forgiving.

Specifically, the cleanup logic in kbase_mem_evictable_reclaim_scan_objects does not remove the jit_alloc reference created during allocation. Normally this is fine, because that reference is cleared when kbase_jit_free_finish runs—an event that, in the ordinary flow, always precedes a region entering the evict_list. But ordinary flows are not what attackers use.

Turning an eviction path into a dangling pointer

Although eviction semantics are heavily intertwined with JIT memory—most functions even carry “JIT” names—evictable memory is more general. Other GPU memory types can be made evictable via kbase_mem_evictable_make and kbase_mem_evictable_unmake, reachable from userland through the KBASE_IOCTL_MEM_FLAGS_CHANGE ioctl. Passing the KBASE_REG_DONT_NEED flag toggles a region’s membership in the evict_list:

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`
        }
    }

The vulnerability arises from combining this capability with JIT memory. If a user first marks a JIT region as KBASE_REG_DONT_NEED, placing it directly on the evict_list, and then induces memory pressure, kbase_mem_evictable_reclaim_scan_objects will free that region while a pointer to it still resides in jit_alloc. A subsequent BASE_JD_REQ_SOFT_JIT_FREE job then triggers kbase_jit_free_finish, which touches the dangling pointer:

static void kbase_jit_free_finish(struct kbase_jd_atom *katom)
{
    ...
    for (j = 0; j != katom->nr_extres; ++j) {
        if ((ids[j] != 0) && (kctx->jit_alloc[ids[j]] != NULL)) {
            ...
            if (kctx->jit_alloc[ids[j]] !=
                    KBASE_RESERVED_REG_JIT_ALLOC) {
                ...
                kbase_jit_free(kctx, kctx->jit_alloc[ids[j]]);  //<----- Use of the now freed jit_alloc[ids[j]]
            }
            kctx->jit_alloc[ids[j]] = NULL;
        }
    }

Among other side effects, kbase_jit_free proceeds to free backing pages belonging to the already-freed kctx->jit_alloc[ids[j]]:

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 < old_pages) {
        ...
        u64 delta = old_pages - new_size;
        if (delta) {
            mutex_lock(&kctx->reg_lock);
            kbase_mem_shrink(kctx, reg, old_pages - delta);  //<----- Free some pages in the region
            mutex_unlock(&kctx->reg_lock);
        }
    }

Because an attacker can replace the freed JIT region with a fake object at the same address, this yields a powerful primitive: the ability to free arbitrary pages at will.

Reliable trigger and replacement

The bug fires when the kernel's shrinker reclaims evictable Mali memory via kbase_mem_evictable_reclaim_scan_objects. Creating the required memory pressure from user space is as simple as mapping a large buffer with mmap, but the exact pressure needed to force a shrinker pass is unpredictable; overshooting risks an OOM kill and makes the subsequent object replacement unreliable.

A better approach is to poll for the side effect. The Mali driver exposes KBASE_IOCTL_MEM_QUERY to inspect properties of a GPU memory region at a given address. When the shrinker frees a JIT region it first unmaps the GPU mapping, so the address becomes invalid and the ioctl returns an error. Polling that ioctl between allocation steps tells us when the JIT region is actually gone. Since the ioctl performs no allocation, it cannot disturb the free-object state we are trying to observe.

Two details make exploitation practical. First, shrinker work runs in the context of the process that triggered the memory pressure. If that process is pinned to a CPU, the JIT region is freed on that same CPU — technically the actual free is deferred to a worker thread, but in practice that worker runs immediately on the same CPU. Second, the freed kbase_va_region object is large (allocated from the kmalloc-256 cache) rather than a smaller, heavily used cache. Freed objects go into the per-CPU cache of the freeing CPU, so a follow-up allocation of a similar size on that CPU will typically reuse the same slot. These factors together allow reliable reclamation of the freed JIT region.

Real object instead of a fake one

The obvious path is heap spraying with sendmsg to plant a fake kbase_va_region, a fake gpu_alloc, and fake pages pointers so that kbase_mem_shrink frees arbitrary pages. That requires knowing addresses of attacker-controlled kernel data, either through an information leak or using a fake-object-store technique. A simpler route exists: reuse a real kbase_va_region of the same type.

Normal memory allocated via KBASE_IOCTL_MEM_ALLOC is the same object type as a JIT region, but lacks the KBASE_REG_NO_USER_FREE flag that marks JIT allocations. That flag is what prevents kbase_mem_alias from creating extra mappings to a region (the KBASE_IOCTL_MEM_ALIAS ioctl is used to share backing pages across multiple region objects). The exploit therefore:

  1. Triggers the UAF, freeing the original JIT region.
  2. Allocates a normal memory region with KBASE_IOCTL_MEM_ALLOC, which lands in the freed slot. The dangling jit_alloc pointer now references this new, aliasable region.
  3. Uses KBASE_IOCTL_MEM_ALIAS to create an alias region sharing the backing pages.

Submitting a BASE_JD_REQ_SOFT_JIT_FREE job now invokes kbase_jit_free on the aliased region. The resulting kbase_mem_shrink frees backing pages but only tears down the mappings on the original region — the alias region still maps those now-freed pages. No function pointers or state fields need to be forged, and the aliasing check that blocks this on legitimate JIT memory never runs because the object no longer carries KBASE_REG_NO_USER_FREE.

From here the attacker holds an alias to pages that have been returned to the allocator. The remaining steps follow the technique used for an earlier Mali issue — the setup is identical once a freed-backing-page primitive exists.

From freed pages to arbitrary kernel code execution

Backing pages for a kbase_va_region are allocated through kbase_mem_pool_alloc_pages in three tiers: first from the per-context kbase_mem_pool (kbase_mem_pool_remove_locked), then from pool->next_pool, and finally via kbase_mem_alloc_page directly from the buddy allocator. Freeing follows the reverse order: pages are returned to the per-context pool, overflow to the next pool, and only then go back to the kernel.

The next_pool is a Mali-managed pool shared across all kbase_context objects. It is also the pool used to allocate page table global directories (PGDs) for GPU contexts. By draining the pools to the right level, a freed backing page from the aliased region can be reallocated as a bottom-level PGD. The PGD stores physical addresses of GPU virtual memory pages; writing to that page lets the attacker map arbitrary physical pages into the GPU address space, then read and write them with GPU commands. Physical addresses of kernel code and data are not randomized, so mapping those pages into the GPU space and overwriting kernel code yields arbitrary kernel code execution. With that, the last steps are to rewrite the cred of the current process to gain root and disable SELinux.

The complete exploit flow is:

  1. Create JIT memory.
  2. Mark the JIT memory as evictable.
  3. Force memory pressure via repeated user-space mmap allocations.
  4. Poll KBASE_IOCTL_MEM_QUERY until the JIT address becomes invalid.
  5. Allocate new GPU memory regions with KBASE_IOCTL_MEM_ALLOC to reclaim the freed slot.
  6. Create an alias region sharing the new region's backing pages.
  7. Submit BASE_JD_REQ_SOFT_JIT_FREE to free the backing pages while retaining the alias mapping.
  8. Force the freed page to be reused as a GPU-context PGD; use the alias to rewrite it and map arbitrary physical pages.
  9. Map kernel code into GPU space, overwrite it, patch cred, and disable SELinux.

The patch gap problem

The dismissal of this bug as “Won’t fix” needs to be viewed in a broader context than one isolated report. There is a long history of Android kernel N-day vulnerabilities that were fixed upstream but never made it into Android in a timely manner — or at all.

CVE-2019-2215, better known as Bad Binder, is the most infamous case. The syzkaller fuzzer found it in November 2017, and it was patched upstream in February 2018 — but the fix never appeared in an Android security bulletin until it was rediscovered being exploited in the wild in September 2019. CVE-2021-1048 follows a similar arc: introduced in December 2020, fixed upstream weeks later, and only included in the Android Security Bulletin in November 2021 after in-the-wild exploitation was confirmed. CVE-2021-0920 dates back to 2016, with details visible in a Linux kernel mailing list thread that kernel developers dismissed at the time; it resurfaced as an exploited in-the-wild bug and was patched in November 2021.

To be fair, some of these cases were patched or ignored upstream without ever being flagged as security issues — CVE-2021-0920 was ignored outright — so downstream vendors had little chance to react before exploitation was known. That is precisely why assigning CVE IDs and treating reports as security-relevant matters: downstream users need to know which patches are security fixes. Vendors sometimes perceive vulnerabilities as reputational damage and quietly patch or downplay them, and the consequences of that mentality speak for themselves.

Recent disclosures show a worrying trend

Android has improved kernel branch unification to prevent problems like CVE-2019-2215, where some branches were patched and others were not. But recent disclosures suggest the gap between upstream and Android can still be measured in months.

Dirty Pipe (CVE-2022-0847) was disclosed on March 7th, 2022, complete with a proof-of-concept exploit to overwrite read-only files. The upstream fix landed February 23rd and was merged into the Android kernel the next day — yet the patch did not appear in an Android Security Bulletin until May 2022. In the meantime, the public exploit still worked on a Pixel 6 running the April patch. Notably, the Android Security Team had been made aware of the bug on February 21st, a day after it went to the Linux kernel, so this was not a matter of discovery after the fact.

The Mali GPU driver has produced a string of similar cases. CVE-2021-39793 was fixed by Arm in driver version r36p0 as CVE-2022-22706, released February 11th, 2022; it was only included in the Android Security Bulletin in March, as an exploited in-the-wild bug. Another vulnerability I reported to the Android Security Team on January 15th, 2022, was patched by Arm in driver version r37p0, released April 21st, 2022. The patch appeared in the Android Security Bulletin in June, and a Pixel 6 running the May patch was still exposed.

Project Zero’s Jann Horn found five Mali GPU issues affecting Pixel phones between June and July 2022 (issues 2325, 2327, 2331, 2333, 2334). Arm fixed them promptly as CVE-2022-33917 in r39p0 on July 25th and CVE-2022-36449 in r38p1 on August 18th. Details and proof-of-concepts were disclosed September 18th, 2022, but at least some of those issues remained unfixed in December 2022. Issue 2327, for example, was only silently addressed in the January 2023 patch without a CVE mentioned — weeks after Project Zero published a blog post on November 22nd specifically highlighting the patching delays on these bugs.

Across all these cases, the pattern is the same: Android received patches a couple of months after they were publicly released upstream. Against that backdrop, the “Won’t fix” response to this report is less of an outlier than one might hope. In 2023, it is still entirely possible to pwn Android using nothing but N-days. Entirely.