GPU memory sharing and the race in CVE-2022-46395

The Arm Mali GPU driver is a frequent target on Android. In September 2022, Project Zero's Jann Horn disclosed CVE-2022-36449, a set of Mali driver bugs. One issue, identified as Project Zero issue 2327, is directly relevant to CVE-2022-46395, a variant that allows arbitrary kernel code execution from the untrusted app domain. Testing was done on a Pixel 6 device. The bug was reported to Arm on November 17, 2022, fixed in Mali driver r42p0 (released January 27, 2023), and patched in the Android May 2023 security update.

User applications interact with the Mali driver through a kbase_context object, created after opening the driver file and issuing a series of ioctl calls. This per-file-descriptor object manages GPU-shared memory resources. The KBASE_IOCTL_MEM_IMPORT ioctl allows sharing user memory with the GPU via direct I/O. Here, the user space application owns the memory; the kernel uses get_user_pages to raise page refcounts so pages aren't freed while in GPU use.

Imported direct-I/O memory maps to a kbase_va_region with a KBASE_MEM_TYPE_IMPORTED_USER_BUF type. This kbase_va_region tracks GPU address ranges and sizes, along with two kbase_mem_phy_alloc pointers: cpu_alloc and gpu_alloc. In this setup both point to the same object. For user-buffer imports, the gpu_alloc->pages array holds the pages obtained via get_user_pages, which also increments their refcounts. Pages are added when imported if the KBASE_REG_SHARED_BOTH flag is set, or on demand later. With the flag clear, pages are only populated when the GPU actually needs them, making the memory lifecycle more intricate.

Root cause of Project Zero issue 2327

The Project Zero bug stems from a subtle interaction in the on-demand page population path. The driver handles the KBASE_IOCTL_MEM_IMPORT ioctl by creating a kbase_va_region and, depending on the flags, the pages are fetched either immediately or deferred. When deferred, the driver relies on a later GPU operation to populate the pages array. A race develops between the user space process unmapping the memory region (freeing the kbase_va_region) and the on-demand population code still using that region. If the race is won, the driver accesses freed memory, leading to use-after-free.

The fix in r42p0 prevents the race by ensuring the page population routine checks region validity under proper locking, avoiding the window where the region can be freed concurrently.

Exploiting the tight race

The CVE-2022-46395 variant exploits a much narrower race condition than in the original issue. Rather than a straightforward unmap during population, this variant requires precise timing between unmapping the GPU address region and the driver's mmap handling. The exploit strategy focuses on the kbase_va_region's cpu_alloc/gpu_alloc pointer fields, attempting to overwrite them with controlled data while the region is obtained and freed in the race.

Successful exploitation yields arbitrary kernel code execution and root from an untrusted app on a Pixel 6. The full technical details, including the specific steps to trigger and exploit the race, are covered in the source advisory. The fix is part of the r42p0 driver release and the Android May 2023 security patch, and a complete timeline is available from the GHSL advisory link GHSL-2022-127.

The Aliasing Between External-Resource Mapping and Backing-Store Lifetime

A KBASE_MEM_TYPE_IMPORTED_USER_BUF region differs from the usual Mali GPU memory model: the backing pages are owned by user space, but they can be pinned and unpinned while the gpu_alloc stays alive. When a softjob requiring BASE_JD_REQ_EXTERNAL_RESOURCES is submitted, kbase_jd_user_buf_map calls kbase_jd_user_buf_pin_pages to populate the pages array of the region’s gpu_alloc with the physical addresses of the user pages and bump their refcounts via get_user_pages. After the job completes, kbase_jd_user_buf_unmap removes those pages and drops the refcounts.

The cleanup in the unmap path is not merely about the pages array. The driver can have created both GPU and CPU mappings to those pages. For example, kbase_unmap_external_resource, which calls kbase_jd_user_buf_unmap, does tear down the GPU mappings through kbase_mmu_teardown_pages. CPU mappings via mmap on the Mali device file, however, were left dangling. Because kbase_mem_shrink_cpu_mapping was never invoked for the KBASE_MEM_TYPE_IMPORTED_USER_BUF case, those CPU mappings survived after the refcount drop, using freed pages and producing a straightforward use-after-free.

This is not just a case of missing cleanup logic; it is a semantic break. For most Mali memory types, backing pages live and die with the region and its gpu_alloc. For KBASE_MEM_TYPE_NATIVE, kbase_mem_shrink can remove backing pages, but that path deliberately first removes the GPU and CPU mappings with kbase_mem_shrink_gpu_mapping and kbase_mem_shrink_cpu_mapping, and then frees the pages via kbase_free_phy_pages_helper. Since the gpu_alloc holds pages array entries and other refcounted structures (e.g., kbase_cpu_mapping’s refs on the region and alloc) are expected to keep everything alive, the fact that an IMPORTED_USER_BUF can shrink to zero pages without going through kbase_mem_shrink or region teardown violates assumptions elsewhere in the driver.

CVE-2022-46395: A Narrow Race On a Mapped Backing Store

The second bug is in the same area of missing assumptions. The anchor is kbase_vmap_prot, which maps a region’s pages array into the kernel address space to allow the driver to write to them. It builds a kbase_vmap_struct that holds refs on both the cpu_alloc and gpu_alloc so the region itself cannot vanish while the mapping is alive. To prevent shrinking while the vmap is outstanding, kbase_vmap_phy_pages also increments kernel_mappings in the alloc via kbase_mem_phy_alloc_kernel_mapped. The kbase_mem_commit routine checks that counter, and other uses of kbase_mem_shrink are serialized by the context’s jctx.lock — the same lock that guards the lifetime of kbase_vmap_prot mappings.

The hole is that a KBASE_MEM_TYPE_IMPORTED_USER_BUF region can drop its backing pages without those protections. The KBASE_IOCTL_STICKY_RESOURCE_UNMAP path routes through kbase_unmap_external_resource and removes pages without acquiring jctx.lock. So between a kbase_vmap_prot and the corresponding kbase_vunmap, a concurrent unmap of a sticky resource can free the very pages being written. One affected call site is KBASE_IOCTL_SOFT_EVENT_UPDATE, which reaches kbase_write_soft_event_status over kbase_vmap_prot; other uses in the driver follow the same pattern but have deeper call chains. The race window is extremely small.

Widening a tight race with a timerfd

The race window between kbase_vmap_prot and kbase_vunmap spans only a few instructions. Hitting it is difficult, and freeing and replacing the backing pages inside that narrow window is even harder. A technique from Jann Horn's work on Exploiting race conditions on [ancient] Linux can widen the window by manipulating task priorities, but it lacks the precise timing control needed here. A complementary approach, described in Racing against the clock—hitting a tiny kernel race window, offers the required granularity.

The idea is to interrupt the task executing the race window so it pauses. By scheduling these interrupts at precise times and controlling the pause duration, other tasks can execute within the window. The kernel exposes APIs for this, and one such mechanism is the timerfd. A timerfd is a file descriptor whose readiness you schedule through the hardware timer. Using the timerfd_settime syscall, you create a descriptor and schedule it for a future time. If epoll instances monitor that descriptor, then once it becomes ready, those instances are iterated and their watchers are woken.


  migrate_to_cpu(0);   //<------- pin this task to a cpu

  int tfd = timerfd_create(CLOCK_MONOTONIC, 0);   //<----- creates timerfd
  //Adds epoll watchers
  int epfds[NR_EPFDS];
  for (int i=0; i<NR_EPFDS; i++)
    epfds[i] = epoll_create1(0);

  for (int i=0; i<NR_EPFDS; i++) {
    struct epoll_event ev = { .events = EPOLLIN };
    epoll_ctl(epfd[i], EPOLL_CTL_ADD, fd, &ev);
  }  
  
  timerfd_settime(tfd, TFD_TIMER_ABSTIME, ...);  //<----- schedule tfd to be available at a later time

  ioctl(mali_fd, KBASE_IOCTL_SOFT_EVENT_UPDATE,...); //<---- tfd becomes available and interrupts this ioctl  

Here, a timerfd named tfd is created with timerfd_create, and epoll watchers are attached via epoll_ctl. The tfd is scheduled to become available at a specific future time, and then the KBASE_IOCTL_SOFT_EVENT_UPDATE ioctl is run. If the tfd becomes ready while that ioctl executes, it is interrupted and the epoll watchers are processed instead. By maintaining a large epoll watch list and scheduling the tfd to fire inside the race window, you can widen it enough to free and replace the backing stores of the KBASE_MEM_TYPE_IMPORTED_USER_BUF region. Even so, most attempts will miss the window, so you need a way to detect a successful trigger before proceeding.

The race window sits between the calls to kbase_vmap_prot and kbase_vunmap:

static int kbasep_write_soft_event_status(
        struct kbase_context *kctx, u64 evt, unsigned char new_status)
{
    ...
    mapped_evt = kbase_vmap_prot(kctx, evt, sizeof(*mapped_evt),
                     KBASE_REG_CPU_WR, &map);
    //Race window start
    if (!mapped_evt)                            
        return -EFAULT;
    *mapped_evt = new_status;
    //Race window end
    kbase_vunmap(kctx, &map);
    return 0;
}

Notably, kbase_vmap_prot holds the kctx->reg_lock for nearly its entire execution:

void *kbase_vmap_prot(struct kbase_context *kctx, u64 gpu_addr, size_t size,
              unsigned long prot_request, struct kbase_vmap_struct *map)
{
    struct kbase_va_region *reg;
    void *addr = NULL;
    u64 offset_bytes;
    struct kbase_mem_phy_alloc *cpu_alloc;
    struct kbase_mem_phy_alloc *gpu_alloc;
    int err;
    kbase_gpu_vm_lock(kctx);         //reg_lock
    ...
out_unlock:
    kbase_gpu_vm_unlock(kctx);      //reg_lock
    return addr;
fail_vmap_phy_pages:
    kbase_gpu_vm_unlock(kctx);
    kbase_mem_phy_alloc_put(cpu_alloc);
    kbase_mem_phy_alloc_put(gpu_alloc);
    return NULL;
}

A common failure mode is an interrupt arriving while kbase_vmap_prot is running, with the kctx->reg_lock mutex held. To detect whether the mutex is held at the moment of interruption, you can issue an ioctl that requires that same lock from another thread during the interrupt. KBASE_IOCTL_MEM_FREE is a good candidate: it holds kctx->reg_lock for most of its execution and can return early on an invalid argument. If the lock is already held by KBASE_IOCTL_SOFT_EVENT_UPDATE (via kbase_vmap_prot), a concurrent KBASE_IOCTL_MEM_FREE will block and only complete afterward. Otherwise, the KBASE_IOCTL_MEM_FREE returns first. Comparing the return times of the two ioctls tells you whether the interrupt occurred while the lock was held. Additionally, if the interrupt lands inside kbase_vmap_prot, the address evt supplied to kbasep_write_soft_event_status will not have been written yet.

Thus, if both of these hold, you know the interrupt occurred before the end of the race window but not inside kbase_vmap_prot:

  1. A KBASE_IOCTL_MEM_FREE started during the interrupt in another thread returns before the KBASE_IOCTL_SOFT_EVENT_UPDATE.
  2. The address evt has not been written to.

However, those same conditions can occur if the interrupt fires before kbase_vmap_prot begins. In that scenario, freeing the backing pages of the KBASE_MEM_TYPE_IMPORTED_USER_BUF region makes the kbase_vmap_prot call fail, since the target address evt is no longer valid. That failure propagates as an error from KBASE_IOCTL_SOFT_EVENT_UPDATE, which helps disambiguate the timing.

Combining these signals yields a clear procedure for determining whether the race was won during the interrupt:

  1. Check if evt has been written to. If so, the interrupt arrived too late and the race was lost.
  2. If evt is still unwritten, launch a KBASE_IOCTL_MEM_FREE from another thread. If that ioctl returns before the interrupted KBASE_IOCTL_SOFT_EVENT_UPDATE does, continue. Otherwise, the interrupt came too early — inside kbase_vmap_prot — and the race was lost.
  3. Proceed to free the backing pages of the KBASE_MEM_TYPE_IMPORTED_USER_BUF region containing evt. If KBASE_IOCTL_SOFT_EVENT_UPDATE returns an error, the interrupt happened before the kbase_vmap_prot invocation and the race was lost. If it succeeds, the race is likely won and the exploit can move to its next stage.

The following diagram maps these conditions onto the race window's layout.

Choosing the replacement target

Once the race is won and the KBASE_MEM_TYPE_IMPORTED_USER_BUF backing pages are freed, the interrupt return path has KBASE_IOCTL_SOFT_EVENT_UPDATE write new_status through the stale vmap address into whatever now occupies those pages. The ideal replacement is kernel memory, but that is not straightforward: pages are allocated according to zone and migrate type, and the import's backing pages came from userspace, typically with GFP_HIGHUSER or GFP_HIGHUSER_MOVABLE. On Android, without ZONE_HIGHMEM, that lands in ZONE_NORMAL with MIGRATE_UNMOVABLE or MIGRATE_MOVABLE. Kernel objects from the SLUB allocator sit in ZONE_NORMAL/MIGRATE_UNMOVABLE, so most userspace mappings (which use GFP_HIGHUSER_MOVABLE) cannot be reused for this purpose.

There is a known way around this, used in the earlier “The code that wasn’t there” bug: the asynchronous I/O filesystem allocates userspace memory with the GFP_HIGHUSER flag. Importing that memory into Mali gives a KBASE_MEM_TYPE_IMPORTED_USER_BUF region whose backing pages are ZONE_NORMAL/MIGRATE_UNMOVABLE and therefore compatible with kernel page reuse.

Working with a narrow write primitive

All uses of kbase_vmap_prot put tight limits on the written value. For KBASE_IOCTL_SOFT_EVENT_UPDATE, only zero or one can be written:

static int kbasep_write_soft_event_status(
        struct kbase_context *kctx, u64 evt, unsigned char new_status)
{
    ...
    if ((new_status != BASE_JD_SOFT_EVENT_SET) &&
        (new_status != BASE_JD_SOFT_EVENT_RESET))
        return -EINVAL;
    mapped_evt = kbase_vmap_prot(kctx, evt, sizeof(*mapped_evt),
                     KBASE_REG_CPU_WR, &map);
    ...
    *mapped_evt = new_status;
    kbase_vunmap(kctx, &map);
    return 0;
}

Here new_status must be either BASE_JD_SOFT_EVENT_SET (one) or BASE_JD_SOFT_EVENT_RESET (zero). Despite that restriction, replacing the backing page with kernel page table directories or SLUB pages gives a useful primitive—but because the race is hard to trigger, it is preferable to replace the page reliably and exploit the bug with a single trigger.

Kernel objects larger than a page are often allocated with vmalloc rather than kmalloc. Unlike the SLUB allocator, vmalloc takes pages directly from the page allocator at page granularity, and the per-CPU page cache makes a fresh vmalloc allocation reuse the most recently freed page on the same CPU. Freeing the KBASE_MEM_TYPE_IMPORTED_USER_BUF backing pages and immediately calling vzalloc (the zeroing variant of vmalloc) yields an object that overlaps those pages, and the zero-or-one write can target any offset in it.

A particularly suitable vzalloc object is the kbase_mem_phy_alloc itself. It is allocated via kbase_alloc_create, reachable through many ioctls including KBASE_IOCTL_MEM_ALLOC. When the region being created is large enough that alloc_size exceeds KBASE_MEM_PHY_ALLOC_LARGE_THRESHOLD, the allocator uses vzalloc:

static inline struct kbase_mem_phy_alloc *kbase_alloc_create(
        struct kbase_context *kctx, size_t nr_pages,
        enum kbase_memory_type type, int group_id)
{
    ...
    size_t alloc_size = sizeof(*alloc) + sizeof(*alloc->pages) * nr_pages;
    ...
    /* Allocate based on the size to reduce internal fragmentation of vmem */
    if (alloc_size > KBASE_MEM_PHY_ALLOC_LARGE_THRESHOLD)
        alloc = vzalloc(alloc_size);
    else
        alloc = kzalloc(alloc_size, GFP_KERNEL);
    ...
}

So a KBASE_IOCTL_STICKY_RESOURCE_UNMAP that frees the imported region's backing pages, immediately followed by a KBASE_IOCTL_MEM_ALLOC for a sufficiently large region, deterministically places a kbase_mem_phy_alloc object on the freed pages:


ioctl(mali_fd, KBASE_IOCTL_STICKY_RESOURCE_UNMAP, ...);  //<------ frees backing page
ioctl(mali_fd, KBASE_IOCTL_MEM_ALLOC, ...);              //<------ reclaim backing page as kbase_mem_phy_alloc

One field is enough

Many fields in this object are tempting targets. Rewriting kref could turn the bug into a use-after-free of kbase_mem_phy_alloc, which is straightforward to exploit. Simpler still is to zero the gpu_mappings field:

struct kbase_mem_phy_alloc {
    struct kref           kref;
    atomic_t              gpu_mappings;
    atomic_t              kernel_mappings;
    size_t                nents;
    struct tagged_addr    *pages;
    ...
}

The Mali driver lets separate memory regions share backing pages through the KBASE_IOCTL_MEM_ALIAS ioctl. A region from KBASE_IOCTL_MEM_ALLOC is passed into the alias call, and after mapping both results to userspace they point at the same physical pages:


  union kbase_ioctl_mem_alloc alloc = ...;
  ...
  ioctl(mali_fd, KBASE_IOCTL_MEM_ALLOC, &alloc);
  void* region = mmap(NULL, ..., mali_fd, alloc.out.gpu_va);
  union kbase_ioctl_mem_alias alias = ...;
  ...
  struct base_mem_aliasing_info ai = ...;
  ai.handle.basep.handle = (uint64_t)region;
  ...
  alias.in.aliasing_info = (uint64_t)(&ai);
  ioctl(mali_fd, KBASE_IOCTL_MEM_ALIAS, &alias);
  void* alias_region = mmap(NULL, ..., mali_fd,  alias.out.gpu_va);

Because both regions share backing pages, the original region must not be resized via KBASE_IOCTL_MEM_COMMIT or its pages could be freed while alias_region still maps them:


  union kbase_ioctl_mem_alloc alloc = ...;
  ...
  ioctl(mali_fd, KBASE_IOCTL_MEM_ALLOC, &alloc);
  void* region = mmap(NULL, ..., mali_fd, alloc.out.gpu_va);
  union kbase_ioctl_mem_alias alias = ...;
  ...
  struct base_mem_aliasing_info ai = ...;
  ai.handle.basep.handle = (uint64_t)region;
  ...
  alias.in.aliasing_info = (uint64_t)(&ai);
  ioctl(mali_fd, KBASE_IOCTL_MEM_ALIAS, &alias);
  void* alias_region = mmap(NULL, ..., mali_fd,  alias.out.gpu_va);

  struct kbase_ioctl_mem_commit commit = ...;
  commit.gpu_addr = (uint64_t)region;
  ioctl(mali_fd, KBASE_IOCTL_MEM_COMMIT, &commit);  //<---- ioctl fail as region cannot be resized

Protection against this comes from the gpu_mappings counter in the region's gpu_alloc, which tracks how many regions share the backing pages. Aliasing increments it:

u64 kbase_mem_alias(struct kbase_context *kctx, u64 *flags, u64 stride,
            u64 nents, struct base_mem_aliasing_info *ai,
            u64 *num_pages)
{
    ...
    for (i = 0; i < nents; i++) {
        if (ai[i].handle.basep.handle > PAGE_SHIFT) <gpu_alloc;
            ...
            kbase_mem_phy_alloc_gpu_mapped(alloc);  //gpu_mappings
        }
        ...
    }
    ...
}

and KBASE_IOCTL_MEM_COMMIT refuses to resize a region whose gpu_mappings is nonzero:

int kbase_mem_commit(struct kbase_context *kctx, u64 gpu_addr, u64 new_pages)
{
    ...
    if (atomic_read(&reg->gpu_alloc->gpu_mappings) > 1)
        goto out_unlock;
    ...
}

Overwriting gpu_mappings to zero makes the aliased region pass that check. Its backing store can then be shrunk, freeing pages while alias mappings remain—and the alias region can reach those freed pages. This is the same situation reached in “Corrupting memory without memory corruption,” so the breakout technique from that work applies.

From freed pages to arbitrary physical memory

The state now is a kbase_va_region whose backing pages are freed but still accessible through an alias. The remaining question is how those pages are recycled. Backing pages for a region are allocated through kbase_mem_pool_alloc_pages, drawing first from the kbase_context-local pool, then from pool->next_pool, then directly from the buddy allocator:

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

Freeing follows the same path in reverse: pages go back to the context pool, overflow to pool->next_pool, and finally return to the kernel if both pools are full. As noted in the prior write-up, pool->next_pool is shared across all kbase_context instances and is also used to allocate GPU page table directories. By arranging the pools carefully, a freed backing page can be made to land in a GPU context's bottom-level PGD. Writing to that PGD maps arbitrary physical pages into GPU virtual address space, readable and writable through GPU commands. Kernel code and static data have unrandomized physical addresses that depend only on the kernel image, so this becomes arbitrary physical read/write and, ultimately, arbitrary kernel code execution.

The full Pixel 6 exploit for this bug is available with setup notes in the GitHub Security Lab repository.

Root Cause as a Roadmap

Analyzing CVE-2022-36449 to its root cause exposed an unusual memory management pattern in KBASE_MEM_TYPE_IMPORTED_USER_BUF regions. That deeper understanding didn’t just explain the original flaw—it directly enabled the discovery of a related vulnerability, CVE-2022-46395. The exercise underscores a practical lesson: root cause analysis isn't just about patching one bug, but about using the underlying mechanism to surface variants that share the same faulty foundation.

Why the Second Bug Is Harder to Hit

While the two issues share a common origin, CVE-2022-46395 is a considerably harder target. Exploitation requires winning an extremely tight race window, and the bug yields only a limited write primitive. These constraints make the vulnerability appear impractical at first glance. However, a closer look at prior work on racing kernel windows shows that even very narrow timing gaps can be won with the right approach. Techniques described in Racing against the clock—hitting a tiny kernel race window provide a concrete methodology for turning a seemingly impossible race into a reliable trigger.

Making the Most of a Minimal Primitive

The restricted write primitive is not a dead end either. The same research demonstrates that use-after-free conditions in memory pages can be exploited with consistency even when the write capability is extremely constrained. The key is to treat the primitive as a piece of a larger strategy rather than a standalone tool. By combining a successfully won race with a carefully shaped UAF on page memory, the constrained write becomes sufficient to achieve reliable exploitation.

In the end, the path from CVE-2022-36449 to CVE-2022-46395 is a case study in how systematic root cause analysis, paired with exploitation techniques for narrow race windows and limited primitives, can transform a difficult vulnerability into a demonstrable one.