A write command that goes sideways

CVE-2022-25664 is an information disclosure bug in the Qualcomm Adreno GPU that I reported in November 2021. The weakness lets a malicious Android app repeatedly leak large amounts of memory—both user-space and kernel-space pages—without disturbing the device's running state. With the kernel leak, I was able to build a KASLR bypass. The bug was publicly disclosed in the October 2022 Qualcomm security bulletin and patched in the corresponding Android Security Patch level.

Adreno GPUs appear in many Qualcomm-powered phones, including U.S. Samsung Galaxy and Google Pixel models. Applications normally talk to the GPU through OpenGL ES or Vulkan, but those shader languages are ultimately compiled down to proprietary GPU instructions that the kernel sends to the hardware. That path goes through the kernel graphics surface layer (KGSL) driver, which user applications may call directly for low-latency rendering—meaning an app can construct and submit raw GPU command buffers itself.

Reading between the instructions

Even though the GPU instruction set is proprietary, the kernel driver itself uses a small set of these commands, and the open-source adreno_pm4types.h header reveals how to build them. For instance, a memory write command laid out like this writes num_words 32-bit words to a GPU-mapped destination:

 uint32_t* write_cmds;
  *write_cmds++ = cp_type7_packet(CP_MEM_WRITE, 2 + num_words);
  write_cmds += cp_gpuaddr(write_cmds, write_to_gpuaddr);

Here cp_type7_packet builds the packet header with the CP_MEM_WRITE opcode and the payload length, while cp_gpuaddr supplies the destination address in two words. Ben Hawkes' earlier Project Zero work on the Adreno GPU contains similar examples. Many instructions follow the same pattern—a cp_packet with an opcode and size, then one or more operand words:

static inline u32 cp_protected_mode(struct adreno_device *adreno_dev,
        u32 *cmds, int on)
{
    cmds[0] = cp_packet(adreno_dev, CP_SET_PROTECTED_MODE, 1);   //<-------- 1.
    cmds[1] = on;                                                //<-------- 2.

    return 2;
}

To get the GPU to execute such commands, application memory must first be mapped into the GPU's address space. The IOCTL_KGSL_MAP_USER_MEM ioctl does that and returns the GPU-side address:

   struct kgsl_map_user_mem req = {
            .len = len,
            .offset = 0,
            .hostptr = addr,    //<--------- user space address
            .memtype = KGSL_USER_MEM_TYPE_ADDR,
    };
    if (readonly) {
      req.flags |= KGSL_MEMFLAGS_GPUREADONLY;
    }
    int ret;

    ret = ioctl(fd, IOCTL_KGSL_MAP_USER_MEM, &req);
    if (ret)
        return ret;

    *gpuaddr = req.gpuaddr;    //<--------- address that the GPU can use for accessing the user memory

Once mapped, the app writes instructions into the shared buffer and submits them with IOCTL_KGSL_GPU_COMMAND. A trivial write test looks like this:

 uint32_t* write_cmds = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
  uint32_t* write_to = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
  //Map command buffer to GPU
  struct kgsl_map_user_mem_req cmd_req = {.hostptr = (uint64_t)write_cmds,...};
  ioctl(kgsl_fd, IOCTL_KGSL_MAP_USER_MEM, &cmd_req);
  uint64_t write_cmd_gpuaddr = cmd_req.gpuaddr;
  //Map destination buffer to GPU
  struct kgsl_map_user_mem_req write_req = {.hostptr = (uint64_t)write_to,...};
  ioctl(kgsl_fd, IOCTL_KGSL_MAP_USER_MEM, &write_req);
  uint64_t write_to_gpuaddr = write_req.gpuaddr;

  //Construct write command
  *write_cmds++ = cp_type7_packet(CP_MEM_WRITE, 2 + 1);
  write_cmds += cp_gpuaddr(write_cmds, write_to_gpuaddr);
  write_cmds++ = 0x41;

  struct kgsl_command_object cmd_obj = {.gpuaddr = write_cmd_gpuaddr,...};

  struct kgsl_gpu_command cmd = {.cmdlist = (uint64_t)(&cmd_obj),...};
  ...
  ioctl(kgsl_fd, IOCTL_KGSL_GPU_COMMAND, &cmd);

The expected outcome is that 0x41 lands in write_to. It doesn't.

Phantom opcodes and stale pages

My first attempts often failed, and the kernel log (on a rooted test device) pointed to a strange cause. The error reported a fault at opcode 0x7e, a value that was nowhere in my command buffer:

[ 4775.765921] c3    289 kgsl kgsl-3d0: |a6xx_cp_hw_err_callback| CP opcode error interrupt | opcode=0x0000007e
[ 4776.189015] c3      0 kgsl kgsl-3d0: |adreno_hang_int_callback| MISC: GPU hang detected
[ 4776.197549] c2    289 kgsl kgsl-3d0: adreno_ion[4602]: gpu fault ctx 13 ctx_type ANY ts 1 status 00800005 rb 00f3/00f3 ib1 0000000040000FEC/036c ib2 0000000000000000/0000

The offending opcode changed between runs, and none of the reported values existed in the buffer I had written. Puzzled, I noticed that Ben Hawkes' PoC inserted a delay between filling the command buffer and sending it to the GPU:

   ...
    *payload_cmds++ = cp_type7_packet(CP_MEM_WRITE, 3);
    payload_cmds += cp_gpuaddr(payload_cmds, 0x40403000+20);
    *payload_cmds++ = 0x13371337;

    payload_cmds_size = (payload_cmds - payload_buf) * 4;

    usleep(50000);
    ...

I added a similar pause and the commands began succeeding. That alone would have been the end of it, but the mystery opcode convinced me something deeper was wrong: the GPU was seeing data I hadn't put there. To find out what it actually read, I had to work around a chicken-and-egg problem: a failed write gives no output, and a successful write means the GPU read the buffer correctly.

The trick is to split the command across two adjacent command buffers. Place the write instruction's header and destination address at the end of one page-length buffer, and let the data words spill into the next buffer. If the first buffer is read correctly but the second is not, the write still executes—but the destination buffer receives whatever the GPU thought it saw in the second buffer, stale contents included.

The first command buffer consists of the opcode, size and destination gpu addresss. It lies right before a page boundary. The second command buffer contains the values to be written lies on the next page.

I could force the first buffer to be coherent by writing it and then waiting, while leaving the second buffer to be mapped and submitted without any delay. When that worked, the destination buffer frequently contained data I never wrote—values that looked like user-space pointers:

dest_buf[0] 0x7e52c58b16
dest_buf[1] 0x7e52c5a731
dest_buf[2] 0x7e52c5b68c
dest_buf[3] 0x7e52c5c78d
dest_buf[4] 0x7e52c60e81

That pointed to stale data left in the page from a prior use. To confirm, I mapped a page, filled it with a recognizable magic value (0x41414141), then unmapped it immediately before mapping the second buffer. If the GPU was reading old page contents, the magic should reappear. It did:

dest_buf[0] 0x4141414141414141
dest_buf[1] 0x4141414141414141
dest_buf[2] 0x4141414141414141
dest_buf[3] 0x4141414141414141
dest_buf[4] 0x4141414141414141

That confirmed an information leak—the GPU was consuming stale memory contents in place of command data.

Cache coherency on the GPU path

The suspicion was a cache coherency issue. When the CPU writes to memory, data goes through the CPU cache first and is only synchronized with physical memory on a cache flush. CPU cores have coherent caches between each other, but devices that access physical memory directly—like a GPU—do not participate in that coherency protocol. They can read stale data that differs from what is in the CPU cache. The kernel provides synchronization functions for exactly this case, and the theory was that memory is not synchronized before a user page gets mapped to the GPU.

Tracing how the GPU obtains user pages confirmed the mechanism. The KGSL driver handles IOCTL_KGSL_MAP_USER_MEM by calling get_user_pages to get references to the pages backing the user's memory.

At first glance, that call looks like it should handle coherency. get_user_pages invokes both flush_anon_page and flush_dcache_page, which seems to indicate the cache is flushed before the page reaches the caller. That would mean the driver has nothing left to synchronize. Function names, however, can mislead.

On Arm64, flush_anon_page is defined in include/linux/highmem.h:

#ifndef ARCH_HAS_FLUSH_ANON_PAGE
static inline void flush_anon_page(struct vm_area_struct *vma, struct page *page, unsigned long vmaddr)
{
}
#endif

Because ARCH_HAS_FLUSH_ANON_PAGE is not defined on Arm64, this function compiles to a no-op. Meanwhile flush_dcache_page, implemented in arch/arm64/mm/flush.c, does not actually flush the CPU cache either. It only marks the cache line as dirty so the flush happens later:

/*
 * This function is called when a page has been modified by the kernel. Mark
 * it as dirty for later flushing when mapped in user space (if executable,
 * see __sync_icache_dcache).
 */
void flush_dcache_page(struct page *page)
{
    if (test_bit(PG_dcache_clean, &page->flags))
        clear_bit(PG_dcache_clean, &page->flags);
}

The net effect is that neither call performs the synchronization that the driver needs. The KGSL driver does not issue an explicit cache flush—the developers relied on get_user_pages to do that for them—so the stale data problem is real.

Exploitation without adverse effects

This bug causes no crash or other side effect, and the CP_MEM_WRITE GPU command places no limit on how many values can be written per command. That makes leaking userspace memory straightforward: repeatedly trigger the bug and use it to copy whatever stale page contents land in the destination buffer. The volume of data leaked in a single attempt is bounded by how long the stale data survives before a cache flush catches up, but a few pages at a time is realistic. This gives a failure-free way to read memory that other user processes have freed. The contents are luck of the draw, though—there is no way to predict or steer which process's freed page will be recycled as the second command buffer.

Reusing Kernel Pages for a KASLR Leak

Turning the GPU memory disclosure into a KASLR bypass requires leaking kernel memory, which means getting a kernel-allocated page remapped into user space. The kernel page allocator normally prevents this because pages are segregated by zone and migrate type. Kernel pages used by the SLUB allocator live in ZONE_NORMAL with MIGRATE_UNMOVABLE type. Pages handed to user space via anonymous mmap are allocated with the GFP_HIGHUSER_MOVABLE mask, landing them in MIGRATE_MOVABLE. On Android, without CONFIG_HIGHMEM, user pages still end up in ZONE_NORMAL, but they will never share a page with kernel objects because of the migrate type mismatch.

The fix is to find a public interface that maps MIGRATE_UNMOVABLE pages to user space without setting the VM_IO or VM_PFNMAP flags. Those flags matter because the GPU driver's __get_user_pages path rejects regions that carry them. The ion allocator, for example, creates pages with GFP_HIGHUSER, but its mmap uses remap_pfn_range, which marks the VMA with the forbidden flags.

The asynchronous I/O subsystem fits the requirements. The io_setup syscall allocates its ring buffer pages with GFP_HIGHUSER and maps them into user space directly, leaving the VMA free of VM_IO and VM_PFNMAP. These pages are therefore valid targets for IOCTL_KGSL_MAP_USER_MEM.

One obstacle remains in the GPU mapping path. The kgsl_setup_useraddr function attempts to treat the incoming range as a DMA buffer and checks whether the first VMA is backed by a file associated with a dma_buf. Since an AIO mapping is file-backed, that check fails. The test, however, only inspects the first vm_area_struct. By constructing a single GPU mapping that begins with an anonymous VMA and is immediately followed by the AIO VMA, the DMA check passes on the anonymous region and the whole range gets mapped to the GPU.

Constructing the Buffer Layout

The exploit needs three separate user space regions with a specific arrangement:

  1. Command buffer: A region holding the valid opcode with the write instruction near the end. Its CPU cache and physical memory must be in sync when the GPU executes.
  2. Anonymous buffer: Mapped anonymously to satisfy the DMA check. In the GPU address space, it must sit directly behind the command buffer (at a higher address). Cache coherency is not required here.
  3. Source buffer: Mapped via AIO so it can reclaim a kernel page. In user space, it must immediately follow the anonymous buffer. It should be mapped as late as possible so its CPU cache and physical memory are out of sync when the GPU command runs.

The GPU side of the layout is straightforward. The driver places new mappings at the lowest available gap in the GPU address space. Mapping the command buffer first, then the combined anonymous-plus-source region, naturally yields the required adjacency.

User space addressing works in the opposite direction. mmap assigns addresses top-down, so a naive sequence would place the source buffer ahead of the anonymous buffer. The solution is to map a placeholder region below the intended anonymous buffer spot, unmap it after the anonymous region is in place, and let the later source buffer mmap claim the freed hole.

Freeing a Kernel Slab for Reuse

With the memory layout in place, the next step is to influence which kernel page gets reused as the source buffer's backing store. Objects allocated via kmalloc come from SLUB slabs backed by one or more pages. Normally, freed objects return to the slab's freelist. But when a slab becomes completely empty, its pages can be returned to the page allocator — provided the per-CPU partial list gets flushed.

To force that flush and get a desired object's contents onto a reusable page:

  1. Pin the process to one CPU so all slab and page-allocator state stays local.
  2. Fill any partially populated slabs with other allocations, so fresh slabs are created for the target object.
  3. Allocate objects_per_slab * (cpu_partial + 1) target objects. This fills at least cpu_partial complete pages.
  4. Free the last 2 * objects_per_slab objects to empty at least one slab, which goes onto the per-CPU partial list.
  5. Free at least one object from each of the other pages. This overflows the partial list, triggering a flush that returns pages — including the page of interest — to the allocator's per-CPU cache.

A subsequent page allocation on that same CPU is likely to reuse one of those freshly freed pages. If the victim object is still referenced elsewhere after being freed, its contents remain in memory and can be read through the source buffer.

For a useful KASLR leak, the target object should carry pointers to both global kernel symbols and other slab objects. The kgsl_syncsource_fence object fits well. Its dma_fence_ops field points to the global kgsl_syncsource_fence_ops table, its cb_list points to itself, and its parent points to a controllable kgsl_syncsource. Allocation is easy through IOCTL_KGSL_SYNCSOURCE_CREATE_FENCE; freeing is done by closing the returned file descriptor without touching other objects on the same slab. Reading the stale fence data yields the kernel base address (via the ops pointer) and heap addresses (via the self and parent pointers).

The full user space leak and KASLR bypass code is available here, with setup notes. It works across all Qualcomm devices tested during research.

A Cache Coherency Leak in the Wild

The bug detailed here, CVE-2022-25664, is an information disclosure that stems from a discrepancy between the CPU cache and physical memory. By exploiting this inconsistency, an attacker can recover stale data that was erased from the cache but still lingered in RAM. Such coherency issues are notoriously hard to spot during development, and the APIs meant to flush caches can be deceptive, behaving differently across architectures.

The discovery itself was accidental, surfaced while debugging a failed GPU command. Follow-up analysis was further muddied by the unexpected behavior of cache-flushing functions on Arm64. Yet, it is precisely this obscurity that makes these flaws dangerous. They can sit dormant in a codebase for extended periods, evading both automated scans and manual review, only to be triggered by very specific hardware or runtime conditions.

While the specifics of this exploit path are tied to the Arm64 memory model, the broader lesson applies to any system where DMA, co-processors, or heterogeneous compute share memory with the CPU. Developers must treat cache maintenance as a correctness issue, not a performance detail, because the failure mode here is not a crash, but a silent leak of privileged information.