A GPU driver bug that maps real memory into the wrong places
Arm's Mali GPU kernel driver is the gateway through which every Android app talks to the graphics hardware. Because it is reachable from the untrusted application domain, it is one of the most exposed pieces of kernel code on an Android device. And because so many phones ship with Mali silicon — including the Pixel 6 line — it is also a high-value target. Attacks against GPU drivers accounted for five of the seven Android 0-days detected as exploited in 2021, and a further in-the-wild exploit disclosed in March 2022 also targeted this driver family.
This article dissects CVE-2022-20186, a flaw in the Mali driver's memory management that was fixed in the June 2022 Pixel security update. The bug allows a malicious app to map arbitrary physical pages into GPU memory with read/write access, a primitive strong enough to yield kernel code execution and root on a Pixel 6.
Why GPU drivers are a favorite target
Three factors make Mali and its competitor Adreno so attractive to attackers:
- The GPU driver is accessible from any app process, so a compromised app can directly attack the kernel.
- Only two vendors' drivers cover the vast majority of Android devices, so a small number of flaws can give near-universal coverage.
- Much of the driver handles shared memory between the GPU and user space. This logic is intricate, and bugs in it often do not manifest as conventional memory corruption, making them invisible to standard mitigations.
CVE-2022-20186 fits that third category precisely: it lives in the driver's memory-management code, not in an obvious buffer overrun.
How the Mali driver allocates and maps memory
The Mali kernel driver is open source. Its role is to create and manage memory shared between the GPU and user space, along with the GPU page tables. A user-space process creates a kbase_context via a sequence of ioctl calls. Each context carries its own GPU address space and manages which pages are visible to the GPU.
Allocating memory with KBASE_IOCTL_MEM_ALLOC
To allocate GPU-visible memory, the user calls KBASE_IOCTL_MEM_ALLOC. The implementation in kbase_mem_alloc creates a kbase_va_region object to represent the allocation:
struct kbase_va_region *kbase_mem_alloc(struct kbase_context *kctx,
u64 va_pages, u64 commit_pages,
u64 extension, u64 *flags, u64 *gpu_va)
{
...
struct kbase_va_region *reg;
...
reg = kbase_alloc_free_region(rbtree, PFN_DOWN(*gpu_va),
va_pages, zone);
...
The region's backing pages come from per-context memory pools in the kbase_context. The pages are not mapped immediately. Instead, when the calling process is 64-bit, the region is placed on the context's pending_regions list:
if (*flags & BASE_MEM_SAME_VA) {
...
kctx->pending_regions[cookie_nr] = reg;
/* relocate to correct base */
cookie = cookie_nr + PFN_DOWN(BASE_MEM_COOKIE_BASE);
cookie <<= PAGE_SHIFT;
*gpu_va = (u64) cookie;
}...
The ioctl returns a cookie to the caller, which is later used as the offset in a call to mmap. That offsets selects the region from pending_regions.
User space mapping
The virtual address assigned to a region is chosen by kbase_context_get_unmapped_area. It does not support MAP_FIXED; instead it uses kbase_unmapped_area_topdown to find the highest available contiguous range:
unsigned long kbase_context_get_unmapped_area(struct kbase_context *const kctx,
const unsigned long addr, const unsigned long len,
const unsigned long pgoff, const unsigned long flags)
{
...
ret = kbase_unmapped_area_topdown(&info, is_shader_code,
is_same_4gb_page);
...
return ret;
}
The chosen address is stored in the region's start_pfn. Because the search is top-down, consecutive mappings sit at predictable offsets. If region1 and region2 are allocated one after the other, region2 will sit exactly 0x1000 bytes below region1 in the virtual address space:
int fd = open("/dev/mali0", O_RDWR);
union kbase_ioctl_mem_alloc alloc;
union kbase_ioctl_mem_alloc alloc2;
...
ioctl(fd, KBASE_IOCTL_MEM_ALLOC, alloc);
ioctl(fd, KBASE_IOCTL_MEM_ALLOC, alloc2);
void* region1 = mmap(NULL, 0x1000, prot, MAP_SHARED, fd, alloc.out.gpu_va);
void* region2 = mmap(NULL, 0x1000, prot, MAP_SHARED, fd, alloc2.out.gpu_va);
GPU page tables
While user space sees a normal virtual address, the GPU side is governed by a separate four-level page table maintained by the kbase_context. The top-level page directory is stored in mmut->pgd. Intermediate page directories (target_pgd) are allocated lazily from a global pool shared by all contexts on the device:
static int mmu_get_next_pgd(struct kbase_device *kbdev,
struct kbase_mmu_table *mmut,
phys_addr_t *pgd, u64 vpfn, int level)
{
...
p = pfn_to_page(PFN_DOWN(*pgd));
page = kmap(p);
...
target_pgd = kbdev->mmu_mode->pte_to_phy_addr(page[vpfn]); //<------- 1.
if (!target_pgd) {
target_pgd = kbase_mmu_alloc_pgd(kbdev, mmut); //<------- 2.
...
kbdev->mmu_mode->entry_set_pte(&page[vpfn], target_pgd); //<------- 3.
Intermediate directories and page tables are created only when an address within them is first touched. Entries in each directory are initialized with an invalid marker; when a lookup returns that marker, a new table is allocated and linked into the hierarchy.
GPU mapping and aliases
To map allocated pages into GPU space, kbase_gpu_mmap calls kbase_mmu_insert_pages:
int kbase_gpu_mmap(struct kbase_context *kctx, struct kbase_va_region *reg, u64 addr, size_t nr_pages, size_t align)
{
...
alloc = reg->gpu_alloc;
...
if (reg->gpu_alloc->type == KBASE_MEM_TYPE_ALIAS) {
...
} else {
err = kbase_mmu_insert_pages(kctx->kbdev,
&kctx->mmu,
reg->start_pfn, //<------ virtual address
kbase_get_gpu_phy_pages(reg), //<------ backing pages
kbase_reg_current_backed_size(reg),
reg->flags & gwt_mask,
kctx->as_nr,
group_id);
...
}
...
}
The insertions are done at the memory region's start_pfn address, so the GPU sees the same virtual layout as user space.
A second ioctl, KBASE_IOCTL_MEM_ALIAS, provides a more complex feature. It lets a new region be backed by pages that already belong to existing GPU maps, with a page-aligned stride separating each contributing region's portion:
union kbase_ioctl_mem_alias alias = {0};
alias.in.flags = BASE_MEM_PROT_CPU_RD | BASE_MEM_PROT_GPU_RD | BASE_MEM_PROT_CPU_WR | BASE_MEM_PROT_GPU_WR;
alias.in.stride = 4;
alias.in.nents = 2;
struct base_mem_aliasing_info ai[2];
ai[0].handle.basep.handle = region1;
ai[1].handle.basep.handle = region2;
ai[0].length = 0x3;
ai[1].length = 0x3;
ai[0].offset = 0;
ai[1].offset = 0;
alias.in.aliasing_info = (uint64_t)(&(ai[0]));
ioctl(mali_fd, KBASE_IOCTL_MEM_ALIAS, &alias);
The result is a larger region of length stride * nents: only the pages at offsets i * stride are actually backed. For example, aliasing two regions each of 3 pages with a stride of 2 produces a 4-page alias window in which only the first and third pages are mapped to real memory:
When the alias region is mapped to the GPU, the driver walks the list of backing regions and inserts their page tables at the computed offsets:
if (reg->gpu_alloc->type == KBASE_MEM_TYPE_ALIAS) {
u64 const stride = alloc->imported.alias.stride;
KBASE_DEBUG_ASSERT(alloc->imported.alias.aliased);
for (i = 0; i < alloc->imported.alias.nents; i++) {
if (alloc->imported.alias.aliased[i].alloc) {
err = kbase_mmu_insert_pages(kctx->kbdev,
&kctx->mmu,
reg->start_pfn + (i * stride), //<------ each region maps at reg->start_pfn + (i * stride)
alloc->imported.alias.aliased[i].alloc->pages + alloc->imported.alias.aliased[i].offset,
alloc->imported.alias.aliased[i].length,
reg->flags & gwt_mask,
kctx->as_nr,
group_id);
...
}
...
}
A Memory-Pool Loophole
To understand why the mis-sized alias region is exploitable, we need to look at how Mali’s kbase_mem_pool manages pages. A kbase_context has its own mem_pools, but those pools are linked to a next_pool — the global kbase_device memory pools shared across contexts. When allocating, a page is taken first from the current pool (1 in the code below), then, if it lacks capacity, from pool->next_pool (2), and finally, if that is also empty, from the kernel’s buddy allocator via kbase_mem_alloc_page:
int kbase_mem_pool_alloc_pages(struct kbase_mem_pool *pool, size_t nr_4k_pages,
struct tagged_addr *pages, bool partial_allowed)
{
...
/* Get pages from this pool */
while (nr_from_pool--) {
p = kbase_mem_pool_remove_locked(pool); //<------- 1.
...
}
...
if (i != nr_4k_pages && pool->next_pool) {
/* Allocate via next pool */
err = kbase_mem_pool_alloc_pages(pool->next_pool, //<----- 2.
nr_4k_pages - i, pages + i, partial_allowed);
...
} else {
/* Get any remaining pages from kernel */
while (i != nr_4k_pages) {
p = kbase_mem_alloc_page(pool); //<------- 3.
...
}
...
}
...
}
Freeing follows the reverse order: the page returns to the current pool, then to next_pool, and only goes back to the kernel buddy allocator if both pools are full:
void kbase_mem_pool_free_pages(struct kbase_mem_pool *pool, size_t nr_pages,
struct tagged_addr *pages, bool dirty, bool reclaimed)
{
struct kbase_mem_pool *next_pool = pool->next_pool;
...
if (!reclaimed) {
/* Add to this pool */
...
kbase_mem_pool_add_array(pool, nr_to_pool, pages, false, dirty); //<------- 1.
...
if (i != nr_pages && next_pool) {
/* Spill to next pool (may overspill) */
...
kbase_mem_pool_add_array(next_pool, nr_to_pool, //<------ 2.
pages + i, true, dirty);
...
}
}
/* Free any remaining pages to kernel */
for (; i < nr_pages; i++) {
...
kbase_mem_pool_free_page(pool, p); //<------ 3.
...
}
...
}
The per-context pools are initialized such that the next_pool points to the device-wide mem_pools:
int kbase_context_mem_pool_group_init(struct kbase_context *kctx)
{
return kbase_mem_pool_group_init(&kctx->mem_pools,
kctx->kbdev,
&kctx->kbdev->mem_pool_defaults,
&kctx->kbdev->mem_pools); //<----- becomes next_pool
}
This device pool is also what backs the GPU page table global directories (PGDs). Critically, a page that gets double-mapped due to the alias bug, freed while still GPU-accessible, and then placed into the device pool can later be reused as a PGD. From the GPU we could then rewrite that PGD and gain access to arbitrary physical pages.
Draining the Pools
To force a page into the device pool, we first need to know its capacity. On a Pixel 6, this is visible via the debugfs node:
oriole:/ # cat /sys/module/mali_kbase/drivers/platform\:mali/1c500000.mali/mempool/max_size
16384
On the test device the pool max size is 16384 pages. A freshly created context pool starts empty. If we free pages to it, it fills before anything goes to next_pool. The device pool, however, is shared and its contents are not entirely under our control — but it can be drained deterministically:
- From an empty context pool, allocate one page we want to later reside in the device pool. Since the context pool is empty, it is taken from the device pool (or the kernel if the device pool is full).
- Allocate 16384 pages from the context pool. They all come from the device pool — draining it completely.
- Free those 16384 pages. The context pool fills up and, being full, no page reaches the device pool.
- Free the single page from step 1. It lands in the now-empty device pool as the only free page.
After this, the device pool contains exactly the page we are still holding via the GPU. The next GPU PGD allocation will reuse that same physical page for the page table directory — and we can write to it from the GPU.
Feng Shui in the Page Table
The GPU page table is built lazily and addresses are allocated in a descending, contiguous order. Each PGD covers 512 entries, so mapping 512 pages guarantees we will trigger allocation of a new level 3 PGD (since addresses separated by 512 pages land in a different level 3 page table entry).
The context pool is already full at this point, so those 512 new pages won’t upset the device pool; the new PGD allocates the single page we still control. That allows us to rewrite the PGD entry to map GPU addresses onto arbitrary physical pages — granting read and write access to kernel memory.
In summary, the full exploit chain is:
- Allocate and map three three-page regions (
region1,region2,region3) plus an alias region withstride = 2 ** 63 + 1andnents = 2, backed byregion1andregion2. - Allocate 16384 pages to drain the device memory pool.
- Free those 16384 pages to fill the context memory pool.
- Unmap
region1and the alias region. Three pages are released to the already-full context pool, overflow to the device pool, and one of those pages is still back-fillingregion3— addressable from the GPU. - Map 1536 pages (or just 1024; the exploit uses two new PGDs) to force allocation of a new level 3 PGD that lands on the page we can still write through
region3.
From GPU memory writes to root
With control over the GPU page tables, the path to arbitrary physical memory access is short. Page tables reference backing pages via page frames, which are plain shifts of physical addresses. Writing a chosen page frame into the GPU PGD therefore grants write access to any physical address. On non-Samsung devices, kernel image physical addresses are fixed by firmware, so arbitrary physical writes can overwrite any kernel function with shellcode — enough to disable SELinux and overwrite credentials to become root. Samsung devices require the extra step of hijacking a kworker thread, following the procedure from the previous write-up.
The full exploit for the Pixel 6 is available here, with setup notes included.
Writing GPU memory via ioctl
The remaining practical question is how to reach GPU memory. Running a shader program is one option, but a lighter path exists through the kernel driver's KBASE_IOCTL_JOB_SUBMIT ioctl. This submits a “job chain” — a sequence of opaque data structures. Each job has a header and a payload; the header identifies the job type, and the payload layout differs accordingly. ARM does not document these structures, but the reverse-engineering work behind the open source Panfrost driver has filled the gap. Instruction sets for Bifrost and Midgard were documented by Connor Abbott, and Valhall by Alyssa Rosenzweig. The pandecode-standalone project likewise decodes job formats sent to the Mali GPU.
enum mali_job_type {
MALI_JOB_TYPE_NOT_STARTED = 0,
MALI_JOB_TYPE_NULL = 1,
MALI_JOB_TYPE_WRITE_VALUE = 2,
MALI_JOB_TYPE_CACHE_FLUSH = 3,
MALI_JOB_TYPE_COMPUTE = 4,
MALI_JOB_TYPE_VERTEX = 5,
MALI_JOB_TYPE_GEOMETRY = 6,
MALI_JOB_TYPE_TILER = 7,
MALI_JOB_TYPE_FUSED = 8,
MALI_JOB_TYPE_FRAGMENT = 9,
};
Most job types relate to specific shader stages, but MALI_JOB_TYPE_WRITE_VALUE offers a direct memory write without GPU assembly. Its payload is:
struct MALI_WRITE_VALUE_JOB_PAYLOAD {
uint64_t address;
enum mali_write_value_type type;
uint64_t immediate_value;
};
address holds the destination GPU address, immediate_value the data, and type the write width:
enum mali_write_value_type {
MALI_WRITE_VALUE_TYPE_CYCLE_COUNTER = 1,
MALI_WRITE_VALUE_TYPE_SYSTEM_TIMESTAMP = 2,
MALI_WRITE_VALUE_TYPE_ZERO = 3,
MALI_WRITE_VALUE_TYPE_IMMEDIATE_8 = 4,
MALI_WRITE_VALUE_TYPE_IMMEDIATE_16 = 5,
MALI_WRITE_VALUE_TYPE_IMMEDIATE_32 = 6,
MALI_WRITE_VALUE_TYPE_IMMEDIATE_64 = 7,
};
Note that the GPU's memory layout for these structures does not always match their C representation; pandecode-standalone also provides the packing/unpacking helpers for that conversion.
A memory safety bug without memory corruption
What makes CVE-2022-20186 stand out is that the attack never hijacks control flow. It abuses the GPU's memory management logic to reach arbitrary physical memory, so mitigations like kernel control flow integrity are irrelevant.
More unusually, the bug also sidesteps classic memory corruption. Two objects end up corrupted over the course of the exploit:
- A page table entry is overwritten by rewriting a different backing page through the alias region — an operation that uses only existing kernel functions, with no invalid access or type confusion involved.
- That backing page is freed and later reused as a page table entry. This qualifies as a use-after-free, yet no pointer is ever dereferenced; a stale physical address is fed to the GPU, which then accesses it.
Neither step would necessarily be prevented by a memory-safe language or by hardware mitigations such as Memory Tagging Extension (MTE). Code that directly manages physical memory leaves a very small margin for error: strong primitives can emerge without any memory-corruption bug, as has now been shown repeatedly in GPU drivers.
Disclosure timeline and patch gapping
The bug was reported to the Android security team on January 15, 2022 and patched in the Pixel June update released June 6, 2022 — past the 90-day disclosure standard set by Project Zero, though a similar delay was seen with the Qualcomm GPU issue discussed earlier. As with that bug, the fix was publicly visible in a kernel branch before the official release. The commit was spotted in android-gs-raviole-5.10-s-qpr3-beta-3 on May 24, 2022, leaving at least a two-week window between public visibility and the Pixel OTA. The widespread practice of patch gapping — where unpatched devices remain exposed after fixes land in public branches — remains a persistent risk in the Android kernel's complex branching structure.
August 1, 2022 Update: After publication, Vitaly Nikolenko and Jann Horne suggested that Arm's vulnerability list may use a different CVE, CVE-2022-28348. Based on affected versions, release date and patch analysis, the two CVE IDs likely describe the same bug. Arm may have published a fix as early as April 2022 while Pixel devices remained vulnerable until June; the May patch level on Pixel 6 was still affected. Since the ID communicated by the vendor was CVE-2022-20186, that remains the one used throughout this post and in the advisory.



