# A Technical Deep Dive into CVE-2024-23380: Exploiting GPU Memory Corruption to Android Root
# Table of Contents
In our last blog, we talked about Binder exploit and fuzzing, and how they can be used to achieve Local Privilege Escalation (LPE) from a zero-permission application to root. In this blog, we will continue the journey of LPE, focusing on the KGSL GPU driver on the Qualcomm platform.
At BlackHat USA 2024, we published our research on the Qualcomm KGSL GPU. Over the past year, we have seen several great analyses of this issue by others [4] [5] [6]. Since then, we have received many questions from security researchers, regarding the specific issues they encountered while trying to reproduce the exploit. In this blog, we will outline in detail the process for exploiting, and answer some frequently asked questions from the security researcher community.
In Android, to protect the user data and the system, a user-installed application is usually constrained in a Sandbox, granting them very limited permissions (known as unprivileged or zero-permission status).
To execute unauthorized actions, an attacker must escalate to a higher privilege level, such as System permissions, with the ultimate goal of achieving Kernel-level access (Root). While it is possible to escalate privileges incrementally—starting with minor privilege gains, and finally achieving Root—this multi-stage approach significantly increases exploit complexity. Consequently, the most common attack vector is targeting the interfaces exposed by the Kernel directly.
The Kernel exposes a very limited set of interfaces to a zero-permission application. Although some vendors have special interfaces exposed (e.g., the NPU driver, or the fastrpc driver on Qualcomm devices), the most widely used attack vectors for exploiting Android devices are still Binder (which we described in previous blogs) and GPU [10] [11] [12] [13] (which we will discuss in this blog). We will focus specifically on Android devices powered by Qualcomm SoCs.
GPU is a well-known component for its ability to render graphics. For security researchers, GPU hardware is a complex, highly privileged, and proprietary coprocessor, which is separate from the Application Processor (AP) running the Android OS. To bridge the gap between userspace applications and this hardware, Qualcomm utilizes the KGSL driver. This driver exposes a device node at `/dev/kgsl-3d0`. Crucially, this node is accessible to all applications, allowing them to open the driver directly.
For example, the following code simply opens the device node and allocates a memory of size 0x1000 (using IOCTL_KGSL_GPUOBJ_ALLOC) which could be used by both the GPU and application.
“`
int fd = open(“/dev/kgsl-3d0”, O_RDWR | O_NONBLOCK | O_ASYNC); struct kgsl_gpuobj_alloc alloc_shared_mem = { .size = 0x1000, }; rc = ioctl(fd, IOCTL_KGSL_GPUOBJ_ALLOC, &alloc_shared_mem); close(fd);
“`
The GPU driver functions available for userspace applications differ by GPU vendors. However, there are some basic functions that are common across most GPU architectures:
– **GPU process lifecycle management -** The userspace can spawn one or more GPU processes in the GPU hardware for rendering content.
– **GPU Memory Management**- used to manage the shared memory between userspace applications and GPU hardware.
– **GPU command execution and synchronization**. Utilized by userspace to dispatch commands to the GPU and ensure they execute in the correct order (synchronization).
We will cover more details in the next section on Memory Management.
One of the key functions in the memory management of the GPU driver is to share memory between userspace applications and GPU hardware. A good explanation for this can be found in Project Zero’s blog, with the following diagram adapted from it:
To efficiently move data between the userspace application and GPU hardware, both should use the same physical memory directly. In the above diagram, the userspace application maps the physical memory into the userland virtual address space through the MMU, and the GPU hardware maps it to the GPU process’s virtual address space through the IOMMU, allowing them to share data through this physical memory directly. The KGSL driver manages these shared memories. From the KGSL driver’s perspective, based on who owns the backend physical pages, how the memory space looks like, there are three types of memory objects:
– **Basic Memory Object:** Physical pages owned and allocated by the KGSL driver
– **Userspace Memory Object:** Physical pages owned by the userspace application
– **Virtual Buffer Object:** A flexible object allows mapping with discontiguous physical memory as well as discontiguous virtual memory.
For all the objects described above, the KGSL driver will create exactly the same data structure, the `struct kgsl_mem_entry`, which is as follows:
“`
struct kgsl_mem_entry { struct kref refcount; struct kgsl_memdesc memdesc; void *priv_data; struct rb_node node; unsigned int id; struct kgsl_process_private *priv; … }
“`
The KGSL driver distinguishes these object types from the details of `kgsl_mem_entry.memdesc`, which has the following structure:
“`
struct kgsl_memdesc { struct kgsl_pagetable *pagetable; void *hostptr; unsigned int hostptr_count; uint64_t gpuaddr; phys_addr_t physaddr; uint64_t size; atomic_t priv; struct sg_table *sgt; const struct kgsl_memdesc_ops *ops; uint64_t flags; struct device *dev; unsigned long attrs; struct page **pages; unsigned int page_count; … }
“`
For different types of objects, `kgsl_mem_entry.memdesc.flags` and other fields (like `ops, priv`) will be different, so that the KGSL driver could distinguish them and manipulate them properly.
The core security mechanism to guarantee that the memory objects are correctly allocated and freed through the complicated multi-thread environment, is the reference counting system, as you can see in the field `kgsl_mem_entry.refcount`.
– When an object is created, the refcount is initialized to 1
– Every usage of the object should perform the following operation atomically: check that the refcount is not zero, and increase the refcount. This is usually achieved by `kref_get_unless_zero`
– Once finished using the object, use `kref_put` to decrease the refcount. When the refcount is not zero, this object should not be freed. When and only when the refcount is zero, free the object and its related resources (atomically; during this process, the object should not be accessed by anyone else).
This reference counting system is efficient and robust. Not only the memory objects, most of the other KGSL objects are also secured by this mechanism and prevent lots of security issues.
We refer to this object as the “Basic Memory Object” (BMO) because it illustrates the fundamental memory management method of the GPU driver. The following code shows how the userspace application creates a Basic Memory Object
“`
int fd = open(“/dev/kgsl-3d0”, O_RDWR | O_NONBLOCK | O_ASYNC); struct kgsl_gpuobj_alloc alloc_shared_mem = { .size = size, // Request memory size (will be aligned to PAGE_SIZE) .flags = flags, }; rc = ioctl(fd, IOCTL_KGSL_GPUOBJ_ALLOC, &alloc_shared_mem); struct kgsl_gpuobj_info info = {.id = alloc_shared_mem.entry_id}; rc = ioctl(fd, IOCTL_KGSL_GPUOBJ_INFO, &info); void *buf = mmap((void *)NULL, entry_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, info.gpuaddr);
“`
When IOCTL_KGSL_GPUOBJ_ALLOC is called, the underlying KGSL driver performs the following steps:
1. Create the `struct kgsl_mem_entry` object
2. Allocate the requested memory (the parameter `kgsl_gpuobj_alloc.size` is the memory size, will be aligned to PAGE_SIZE by the KGSL driver), which we refer to as the backend physical memory
3. Allocate the GPU IOMMU virtual address for this physical memory in GPU hardware
4. Setup the IOMMU physical-virtual mapping for the GPU hardware. So that after the mapping is created, the GPU hardware can access this physical memory through the GPU virtual address.
5. (Optional) When the `mmap` is called, then the kernel driver sets up the MMU mapping for the corresponding physical memory and virtual address for the userspace application, so that the userspace application can access the physical memory through the virtual address.
So we can see from the above process, with the Basic Memory Object, the backend memory is allocated by KGSL driver and shared to both GPU and userspace.
Userspace Memory Object is quite similar to Basic Memory Object. The difference is that, with the Userspace Memory Object, the userspace MMU mapping is already created (physical memory is already mapped to the virtual address of the userspace application), and then imported into KGSL driver and shared to GPU.
Assuming the userspace application already has memory allocated at `virtual address ptr`, then the Userspace Memory Object can be created by IOCTL_KGSL_GPUOBJ_IMPORT or IOCTL_KGSL_MAP_USER_MEM, and the underlying KGSL driver will then
1. Create the `struct kgsl_mem_entry` object
2. Retrieve the physical memory of `virtual address ptr`
3. Allocate the GPU IOMMU virtual address for this physical memory in GPU hardware
4. Setup the IOMMU physical-virtual mapping for the GPU hardware.
So now both the GPU hardware and userspace applications can access this physical memory.
After the Basic Memory Object and the Userspace Memory Object are created, the memory layout of the object is as follows:
In these two cases, the KGSL driver will take care of the GPU physical-virtual memory mapping and unmapping according to the memory status. The physical-virtual mapping is always a fixed contiguous mapping, which is relatively simple usage.
The basic idea of VBO is to add more flexibility to the memory management, by switching the fixed contiguous mapping to one that supports both physically and virtually non-contiguous memory.
To use the VBO, we can do the following
1. Call `IOCTL_KGSL_GPUOBJ_ALLOC` with the flag `KGSL_MEMFLAGS_VBO`, then an empty `kgsl_mem_entry` with type VBO will be created. The VBO will have a GPU hardware virtual memory. As an initial state, the virtual address has no physical memory, or has a physical memory of zero-page (a page with all zero content and writing to it has no effect). Nothing will happen if we read or write this virtual memory in the GPU hardware.
2. To make use of the VBO, we have to call `IOCTL_KGSL_GPUMEM_BIND_RANGES`, to bind one (or more) existing `kgsl_mem_entry`(Basic Memory Object or Userspace Memory Object) to the virtual address range of VBO. The binding process is as follows:
1. For the requested virtual address and length, KGSL driver will check whether there is already a binding in this address range of VBO. If one exists, it unbinds first to release the physical-virtual mapping in GPU hardware
2. KGSL driver retrieves the physical memory of the existing `kgsl_mem_entry`, and sets up the physical-virtual mapping to the requested virtual address. So that the GPU hardware can access the physical address through the virtual address in the VBO.
3. By repeating step 2 several times, we can create a memory layout similar to the following graph. In the graph, you can see there are two existing `kgsl_mem_entry`( `kgsl_mem_entry_A` and `kgsl_mem_entry_B`). The VBO `ksgl_mem_entry_VBO` is using the physical memory from both `kgsl_mem_entry_A` and `kgsl_mem_entry_B`.
1. Since there might be multiple objects bound to a VBO at the same time, the `kgsl_memdesc` of VBO `kgsl_mem_entry` has a special member called `ranges` to track the binding objects and addresses. This member is a Red-Black Tree (rbtree). When an object is bound to a VBO, both the object and the address range are inserted into the tree. When unbinding, the object is removed from the tree. To avoid concurrency issues when multiple threads manipulate the `ranges` at the same time, it’s critical to use the `ranges_lock` properly.
“`
struct kgsl_mem_entry { struct kref refcount; struct kgsl_memdesc memdesc; … } struct kgsl_memdesc { … /** @ranges: rbtree base for the interval list of vbo ranges */ struct rb_root_cached ranges; /** @ranges_lock: Mutex to protect the range database */ struct mutex ranges_lock; }
“`
So the memory of the VBO could be spliced, merged, split, removed… It’s really flexible, but also brings more challenges to memory management. We can see there are a series of issues occurring in VBO, CVE-2024-23380, CVE-2024-23384, CVE-2024-23381, CVE-2024-23372, CVE-2024-33034, which shows the complexity of this new memory management mechanism. CVE-2024-23380 is one of these issues we are going to discuss here.
As we know from the previous section, we can bind Basic Memory Object (BMO) to a VBO, so that the VBO could have one (or more) backend physical memory. During the binding, the backend memory will be mapped to the virtual memory of the GPU. So that GPU process could access this physical memory through the virtual memory mapping.
After all jobs accessing this memory are finished, the BMO could unbind from the VBO, which is then unmapping the virtual memory from physical memory.
While the VBO is maintaining a physical-virtual mapping, the BMO should not be released, since it holds the backend physical memory used by the VBO. This is protected by the reference counting system we mentioned previously. Every time when a BMO is binding to a VBO, the reference count of the BMO should increase.
The vulnerability here is, when two threads bind and unbind the same VBO at the same time, there is a race condition which could corrupt the reference count of the BMO, allowing it to be released while the physical memory is still binding to the VBO. So that in this case, after the backend memory of the BMO has been released, the VBO can still access the physical memory, leading to a physical page use-after-free.
The race condition of triggering this issue is described as follows:
– In the above process, we can see that when binding (Step 2 in Thread A) the BMO to the VBO, the BMO is placed into the rbtree `ranges`, and its reference count is increased simultaneously.
– When Thread A releases the `ranges` mutex, since the BMO is already in the rbtree `ranges`, other threads can access this rbtree `ranges` and unbind the BMO from the VBO, decreasing the reference count at the same time. After Thread B unbinds the BMO from the VBO (Step 2 in Thread B), the VBO no longer holds the reference to the BMO. As a result, the BMO could be freed by userspace at any time.
– However, in Thread A, the binding process is not finished yet. Thread A will continue mapping the physical memory of the BMO to the VBO address range (Step 4 in Thread A) — even if the BMO is no longer bound to the VBO.
– Now userspace can free the BMO (since nobody else is using the BMO from the driver’s perspective), as well as the physical memory of the BMO — although the physical memory is still used by the VBO. That’s the vulnerability.
After successfully triggering the issue, we can get the following wrong memory status, as illustrated below.
We can see, the “Freed pages” previously owned by the Basic Memory Object have already been freed, but it’s still mapping to the GPU virtual memory address space.
From the above vulnerability description, we know that by triggering the issue, we can control physical pages that have already been freed. If the physical pages are later used for important kernel data—for example, the `struct cred` which contains critical process credentials (e.g., user IDs, group IDs, and capabilities)—we can modify this important kernel data to gain privilege escalation (e.g., modify `cred.uid` to 0). So here is the Exploitation Sequence:
1. Trigger the vulnerability to control enough physical memory pages
2. Fill the pages with a specific, useful objects
3. Modify these objects to achieve arbitrary read/write access.
Let’s go through the exploit step-by-step.
As described above, we can trigger the issue by running two different threads, binding and unbinding the same Virtual Buffer Object(VBO) at the same time, then there is a chance that we will trigger the issue. A successful trigger leaves us in control of a physical page that the kernel considers “freed”. To confirm that we have triggered the issue, we can use the method described below in section Some Exploit Issues In Detail. Now, we have all the information about how to trigger the issue and control lots of freed pages. Let’s repeat this step until we get enough freed pages.
In most of the cases (depending on the configuration of `kgsl_pool_max_pages`), the freed memory will not go directly back to the kernel memory. Instead, it will be put back into a pool, so that the buffer can be reused for the next KGSL physical page allocation.
This pool is reserved exclusively for the GPU driver. If the freed page remains in this pool, it will simply be reused for graphics data. Since manipulating graphics data has no effect on the kernel, we must force the driver to release this page from its dedicated pool.
The method is to trigger a system-wide low memory condition. For example, if we allocate a large amount of memory in the userspace, this operation will consume too much memory and the system memory will be quite low. In this situation, the system will try to `reclaim` memory that is already owned by components but freeable, so that to avoid out-of-memory issues at the best efforts of the system.
The KGSL memory management system will react to the system memory reclaim request, and release as much as possible memory in the pool back to the system, so that the physical page controlled by us (through the vulnerability from Step 1) could be reused by the kernel.
Now, we need to fill the freed physical page with a specific object we can manipulate. We choose `kgsl_mem_entry` because it is powerful and easy to spray. The method to create the `kgsl_mem_entry` has already been described above in BASIC_MEMORY_OBJECT. Call `ioctl(fd, IOCTL_KGSL_GPUOBJ_ALLOC, …)` will allocate a memory entry in the KGSL driver, so that we will get a `kgsl_mem_entry.`
It’s important to note that if we request a Basic Memory Object, then backend physical memory will also be allocated, so that we might consume too many physical pages, which is not necessary and might reduce the success rate. To avoid this extra memory consumption, we’d better choose to allocate VBO, or Userspace Memory Object.
By repeating this step, we can spray a huge amount of `kgsl_mem_entry` into the kernel heap.
After the heap spray, we must identify some of the `kgsl_mem_entry` that will be luckily located in the physical pages controlled by us. Now we can scan the pages, to find out where these objects are.
In `struct kgsl_mem_entry`, there is a member named `metadata`, which is controlled by the userspace.
“`
struct kgsl_mem_entry { struct kref refcount; struct kgsl_memdesc memdesc; … char metadata[KGSL_GPUOBJ_ALLOC_METADATA_MAX + 1]; <–
“`
We can call `IOCTL_KGSL_GPUOBJ_SET_INFO` to modify the metadata to a special Sentinel as follows:
“`
struct kgsl_gpuobj_set_info set_info = {0}; set_info.flags = KGSL_GPUOBJ_SET_INFO_METADATA; set_info.metadata = (uint64_t)&metadata[0]; set_info.metadata_len = KGSL_GPUOBJ_ALLOC_METADATA_MAX; set_info.id = alloc_vbo.id; memcpy(&metadata[0], &SENTINEL,sizeof(SENTINEL)); ioctl(dev_fd_per_process, IOCTL_KGSL_GPUOBJ_SET_INFO, &set_info);
“`
So we can put a special Sentinel into this metadata, then we can find the `kgsl_mem_entry` in the controlled physical pages.
The method to scan the physical page is the same as what we used in Step 1 (to read content out from unbound VBO), that is using the GPU command to copy content out.
Along with `metadata`, in `struct kgsl_mem_entry`, there is another useful member `struct kgsl_memdesc memdesc`.
From the structure definition `struct kgsl_memdesc`, we can see there are lots of useful members.
The first one is `const struct kgsl_memdesc_ops *ops`, which usually point to `kgsl_page_ops`
“`
static const struct kgsl_memdesc_ops kgsl_page_ops = { .free = kgsl_free_pages, .vmflags = VM_DONTDUMP | VM_DONTEXPAND | VM_DONTCOPY | VM_MIXEDMAP, .vmfault = kgsl_paged_vmfault, .map_kernel = kgsl_paged_map_kernel, .unmap_kernel = kgsl_paged_unmap_kernel, .put_gpuaddr = kgsl_unmap_and_put_gpuaddr, };
“`
This `kgsl_page_ops` is useful. The member function `.vmfault` is related to page fault handling.
Recall how the Page Fault works: when accessing a virtual memory that the backend physical memory has not yet been prepared, a VM page fault event is raised and the kernel will handle it by preparing the memory. This also works when a KGSL Memory Object is mmapped to userspace. We can mmap the Basic Memory Object and map the underlying physical memory into the virtual memory of userspace applications. The MMU may not be set up until the userspace is trying to access this virtual memory – that’s what the `.vmfault` is doing, to set up the real physical-virtual memory mapping.
The current function `kgsl_page_ops.kgsl_paged_vmfault` is setting up the physical-virtual mapping from `kgsl_memdesc->pages`
“`
static vm_fault_t kgsl_paged_vmfault(struct kgsl_memdesc *memdesc, struct vm_area_struct *vma, struct vm_fault *vmf) { … if (offset >= memdesc->size) return VM_FAULT_SIGBUS; … if (memdesc->pages[pgoff]) { page = memdesc->pages[pgoff]; get_page(page); … ret = vmf_insert_page(vma, vmf->address, page); }
“`
As we have fully controlled the `kgsl_memdesc`, we can manipulate `kgsl_memdesc->pages` to map arbitrary physical memory to the userspace. This requires `kgsl_memdesc->pages` to point to our controlled data with a known virtual address, which of course is doable but is slightly (just a little bit!) more complicated than the method we are using.
There are also other `kgsl_page_ops` for other types of kgsl_mem_entry, for example, `kgsl_contiguous_ops`, which behave differently.
“`
static const struct kgsl_memdesc_ops kgsl_contiguous_ops = { .free = kgsl_contiguous_free, .vmflags = VM_DONTDUMP | VM_PFNMAP | VM_DONTEXPAND | VM_DONTCOPY, .vmfault = kgsl_contiguous_vmfault, .put_gpuaddr = kgsl_unmap_and_put_gpuaddr, };
“`
If we replace the original `kgsl_page_ops` with `kgsl_contiguous_ops`, then we can get a `kgsl_mem_entry` with the following `vm_fault (kgsl_contiguous_vmfault)` behavior.
“`
static vm_fault_t kgsl_contiguous_vmfault(struct kgsl_memdesc *memdesc, struct vm_area_struct *vma, struct vm_fault *vmf) { unsigned long offset, pfn; offset = ((unsigned long) vmf->address – vma->vm_start) >> PAGE_SHIFT; pfn = (memdesc->physaddr >> PAGE_SHIFT) + offset; return vmf_insert_pfn(vma, vmf->address, pfn); }
“`
For `kgsl_contiguous_vmfault`, it simply gets the physical address from `memdesc->physaddr` and then creates the physical-virtual mapping.
We just need to modify the `memdesc->physaddr` to the destination we are interested in, then we can map this physical address to userspace through the `vm_fault` process.
To summarize, here we are modifying the `kgsl_memdesc.physaddr` to physical address of the kernel, and `kgsl_memdesc.size` to kernel physical memory size, and `kgsl_memdesc.ops` to `kgsl_contiguous_ops`, so that we can map the whole kernel physical memory to userspace through the `vm_fault` process in one shot.
The final modified `kgsl_memdesc` is as follows:
“`
struct kgsl_memdesc { … phys_addr_t physaddr; // = KERNEL_PHYS_ADDRESS uint64_t size; // = KERNEL_PHYS_SIZE … const struct kgsl_memdesc_ops *ops; // = kgsl_contiguous_ops }
“`
Building on the results of Step 5, by setting `physaddr` to the kernel’s physical address (which in most of the Android devices is a known fixed address at the moment), we can map the entire kernel memory into userspace, including both the data area and the code area. We can also map the code area, which is typically read-only memory, as readable and writable to the userspace. This capability exists because we are remapping the kernel’s physical memory directly. Consequently, the standard virtual memory protection mechanisms are bypassed, allowing us to always map the physical memory as writable.
“`
// From Step 5, we have modified the kgsl_memdesc as follows /* struct kgsl_memdesc { … phys_addr_t physaddr; // = KERNEL_PHYS_ADDRESS uint64_t size; // = KERNEL_PHYS_SIZE … const struct kgsl_memdesc_ops *ops; // = kgsl_contiguous_ops } */ // Userspace run mmap to the kgsl_mem_entry char *kernel_base = mmap((void *)NULL, KERNEL_PHYS_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, gpu_addr)) // Now the KERNEL_PHYS_ADDRESS is mapped to kernel_base // If we try to access to kernel_base // the following code in the kernel will be execute // to setup the physical-virtual mapping /* static vm_fault_t kgsl_contiguous_vmfault(struct kgsl_memdesc *memdesc, … return vmf_insert_pfn(vma, vmf->address, pfn); } */ // Now we can do arbitrary read/write to the kernel memory // Replace the Linux Banner char *p = “Exploit by Xiling Gong of Android RedTeam, Google:)n”; memcpy(kernel_base + LINUX_PROC_BANNER, p, strlen(p) + 1); // Modify the function sel_read_enforce memcpy(kernel_base + SEL_READ_ENFORCE, PATCHED_CODE, sizeof(PATCHED_CODE));
“`
From the above code snippet, we can see, after we finish `mmap`, we can modify the read-only data section (e.g., Linux Banner), or the read-only code section (e.g., `sel_read_enforce`) in the kernel memory.
In the example code snippet, we have directly modified code of the function `sel_read_enforce`, which is called when userspace tries to read the SELinux configuration (for example `adb shell getenforce`). When userspace triggers `sel_read_enforce`, our arbitrary code will run. This means we are running arbitrary code in the kernel, which is the highest privilege, including Root privilege and the ability to disable SELinux.
Let’s summarize the overall exploitation steps as follows:
In this section, we will explain in detail some of the issues we encountered during the exploitation. These issues are also frequently asked by other security researchers.
1. The vulnerability is a race condition issue. Some of the race condition issues are quite unstable – if the issue fails to trigger, it will have some side effects (like memory corruption). The good news is, this race condition issue is quite stable, nothing bad happens if the race fails. Actually when the race fails, that means the backend physical memory is not bound to the Virtual Buffer Object (VBO), that’s what the driver expects in normal case.
2. Of course, if we don’t know the race result, we can still get the exploit to work. We can just keep racing (for example, one hour), until we think we have collected enough freed pages. However, for a more reliable and more effective exploit, it’s always better to know the race result if we could. From the issue description, we know that if the race fails, then the VBO will be bound back to zero-page, and if the issue triggered, then the VBO will still be bound to the Freed-page. So we can write some special Sentinel into the page before we do the race, and check whether the Sentinel changed after the race. If the Sentinel remains, then that means we triggered the issue and successfully controlled the freed physical page.
3. The answer is to read it from the GPU process using a GPU command. Remember the VBO has a virtual address in the GPU process, and the GPU process is fully controlled by the userspace application. Craft the special GPU command and run it on the GPU process, then we can read the content out to another buffer. Here is some sample code. For more information, please refer to
**adrenaline** from Project Zero. `for (int i = 0; i < TARGET_VBO_SIZE / PAGE_SIZE; i++) { *write_cmds++ = cp_type7_packet(CP_MEM_TO_MEM, 5); *write_cmds++ = 0; // Dest write_cmds += cp_gpuaddr(write_cmds, dest_gpu_va + i * 4); // Src write_cmds += cp_gpuaddr(write_cmds, source_gpu_va + PAGE_SIZE * i); if ((write_cmds – write_cmd_buf) * 4 > 0xC0000) break; } SYSCHK(munmap(write_cmd_buf, write_cmd_buf_size)); kgsl_flush_memory_cache(dev_fd, write_cmd_buf_id); uint32_t cmdsize = (write_cmds – write_cmd_buf) * 4; kgsl_gpu_command_payload(dev_fd, ctx_id, 0, cmdsize, 1, 0, write_cmd_gpuaddr, cmdsize);`
4. In practice, researchers often encounter a false positive: a scenario where the race condition fails to trigger the vulnerability, yet the VBO still correctly reads the Sentinel value from the physical memory. This happens when the Unbind operation successfully executes
_before_ the Bind operation. The sequence is **Unbind -> Bind**(a race failure).
– **Unbind:** Executes on an unbound VBO range (no-op).
– **Bind:** Executes normally, successfully mapping the VBO’s virtual address to the Basic Memory Object’s physical pages.
Since the final state is a successful binding, reading the VBO still returns the Sentinel value, making this outcome indistinguishable from a true UAF success based purely on the Sentinel check. This is the false positive. The solution is to perform an
**additional, explicit unbind operation** immediately after each race attempt. This resolves the ambiguity by differentiating the kernel’s state: – **If the race was a False Positive (Unbind -> Bind):** The VBO is currently a valid, kernel-managed binding. The extra unbind executes successfully, unmapping the physical page and reverting the VBO range back to the zero-page. Subsequent reading of the VBO will show the Sentinel is gone (replaced by zeros), confirming the race failed.
– **If the race was a True UAF Success (Bind -> Unbind):** The vulnerability has already caused the Basic Memory Object’s physical page to be freed (UAF state), while the GPU’s virtual mapping remains intact. Since the physical page is no longer under KGSL’s active management, the extra unbind attempt has no functional effect on the memory state or the IOMMU mapping. Subsequent reading of the VBO will still show the Sentinel is present, confirming successful exploitation and control over the freed page.
We have described the details of how to exploit CVE-2024-23380. After we finish the first step (reproduce the issue and control physical pages), the major kernel mitigations (e.g., kCFI, W^X, DEP) will not prevent further execution, because of the powerful “Physical Pages” capability of the GPU driver. It’s been many years since GPU caught the attention of security researchers. The vulnerability (CVE-2024-23380) described in this blog has been remediated in July 2024, however, there are ongoing discoveries of GPU issues that are being addressed through regular security updates (e.g., the patched CVE-2025-21479 in 2025, CVE-2026-21385 in 2026). GPU security will remain important in the foreseeable future. How to find vulnerabilities ahead of the potential exploits, how to mitigate and ease the attack from “Physical Pages” is still an important topic as always.
We would like to thank the **external security research community** for their continued discussion, analysis, and contributions to GPU security.
We also wish to thank **all of our teammates** for their general support and assistance in the creation of this blog post. We would like to express special gratitude to **Martijn Bogaard**, **Xingyu Jin, Zi Fan Tan** for their crucial support and comprehensive review.
01. **Attacking Android Binder: Analysis and Exploitation of CVE-2023-20938**(Binder exploit)
02. **Binder Fuzzing**(Binder fuzzing)
03. **The Way to Android Root: Exploiting Your GPU on Smartphone**(BlackHat USA 2024 research)
04. **Introduction to Android GPU Vulnerability Attack and Defense**
05. **Analysis of Qualcomm GPU Vulnerabilities**
06. **Exploit CVE-2024-23380**(Bilibili Video)
07. **Android Security Overview: Protecting the user data and the system**
08. **Exploiting the Qualcomm NPU (NPU driver)**
09. **Qualcomm DSP driver unexpected exploit**(fastrpc driver)
10. **TiYunZong Exploit Chain to Remotely Root Modern Android Devices**
11. **Attacking the Qualcomm Adreno GPU**
12. **The Android kernel mitigations obstacle race**
13. **Project Zero Issue 2431: code in user-writable mapping is executed in non-protected mode**
14. **Further hardening Android GPUs**
15. **Linux Kernel Documentation: kref**(reference counting system)
16. **Qualcomm Graphics Kernel Git Commit**(Patch for CVE-2024-23380)
17. **Android Kernel Exploitation: Process Credentials**(struct cred)
18. **Linux Kernel Documentation: Memory Reclaim**
19. **Wikipedia: Page Fault**
20. **Project Zero Issue 42451155: Adrenaline**(GPU command to read content)
21. **Android Security Bulletin—July 2024**(Qualcomm components remediation)
22. **NVD Detail: CVE-2025-21479**
23. **Memory Management In The Mali Kernel Driver**(Corrupting memory without memory corruption)
24. **Rbtree in Linux**
_For technical questions about content of this post, contact androidoffsec-external@google.com. All press inquiries should be directed to press@google.com._
