❌

Normal view

A 0-click exploit chain for the Pixel 9 Part 2: Cracking the Sandbox with a Big Wave

14 January 2026 at 19:00

With the advent of a potential Dolby Unified Decoder RCE exploit, it seemed prudent to see what kind of Linux kernel drivers might be accessible from the resulting userland context, the mediacodec context. As per the AOSP documentation, the mediacodec SELinux context is intended to be a constrained (a.k.a sandboxed) context where non-secure software decoders are utilized. Nevertheless, using my DriverCartographer tool, I discovered an interesting device driver, /dev/bigwave that was accessible from the mediacodec SELinux context. BigWave is hardware present on the Pixel SOC that accelerates AV1 decoding tasks, which explains why it is accessible from the mediacodec context. As previous research has copiously affirmed, Android drivers for hardware devices are prime places to find powerful local privilege escalation bugs. The BigWave driver was no exception - across a couple hours of auditing the code, I discovered three separate bugs, including one that was powerful enough to escape the mediacodec sandbox and get kernel arbitrary read/write on the Pixel 9.

The (Very Short) Bug Hunt

The first bug I found was a duplicate that was originally reported in February of 2024 but remained unfixed at the time of re-discovery in June of 2025, over a year later, despite the bugfix being a transposition of two lines of code. The second bug presented a really fascinating bug-class that is analogous to the double-free kmalloc exploitation primitive - but with a different linked list entirely. However it was the third bug I discovered that created the nicest exploitation primitive. Fixes were made available for all three bugs on January 5, 2026.

The Nicest Bug

Every time the /dev/bigwave device is opened, the driver allocates a new kernel struct called inst which is stored in the private_data field of the fd. Within the inst is a sub-struct called job, which tracks the register values and status associated with an individual invocation of the BigWave hardware to perform a task. In order to submit some work to the bigo hardware, a process uses the ioctl BIGO_IOCX_PROCESS, which fetches Bigwave register values from the ioctl caller in AP userland, and places the job on a queue that gets picked up and used by a separate thread, the bigo worker thread. That means that an object whose lifetime is inherently bound to a file descriptor is transiently accessed on a separate kernel thread that isn’t explicitly synced to the existence of that file descriptor. During BIGO_IOCX_PROCESS ioctl handling, after submitting a job to get executed on bigo_worker_thread, the ioctl call enters wait_for_completion_timeout with a timeout of 16 seconds waiting for bigo_worker_thread to complete the job. After those 16 seconds, if bigo_worker_thread has not signaled job completion, the timeout period ends and the ioctl dequeues the job from the priority queue. However, if a sufficient number of previous jobs were stacked onto the bigo_worker_thread, it is possible that bigo_worker_thread was so delayed that it has only just dequeued and is concurrently processing the very job that the ioctl has considered to have timed out and is trying to dequeue. The syscall context in this case simply returns back to userland, and if at this point userland closes the fd associated with the BigWave instance, the inst (and thusly the job) is destroyed while bigo_worker_thread continues to reference the job.

The highlights indicate any accesses to the UAF’d object:

static int bigo_worker_thread(void *data)
{
	...

	while(1) {
		rc = wait_event_timeout(core->worker,
			dequeue_prioq(core, &job, &should_stop),
			msecs_to_jiffies(BIGO_IDLE_TIMEOUT_MS)); //The job is fetched from the queue
		...

		inst = container_of(job, struct bigo_inst, job); //The job is an inline struct inside of the inst which gets UAF'd

		...

		rc = bigo_run_job(core, job);

		...
		job->status = rc;
		complete(&inst->job_comp);
	}
	return 0;
}

...

static int bigo_run_job(struct bigo_core *core, struct bigo_job *job)
{
	...

	inst = container_of(job, struct bigo_inst, job);
	bigo_bypass_ssmt_pid(core, inst->is_decoder_usage);
	bigo_push_regs(core, job->regs); //The register values of the bigwave processor are set (defined by userland)
	bigo_core_enable(core);
	ret = wait_for_completion_timeout(&core->frame_done,
			msecs_to_jiffies(core->debugfs.timeout)); //pause for 1 second
	...
        //At this point inst/job have been freed
	bigo_pull_regs(core, job->regs); //A pointer is taken directly from the freed object
	*(u32 *)(job->regs + BIGO_REG_STAT) = status;
	if (rc || ret)
		rc = -ETIMEDOUT;
	return rc;
}
void bigo_pull_regs(struct bigo_core *core, void *regs)
{
	memcpy_fromio(regs, core->base, core->regs_size); //And the current register values of the bigwave processor are written to that location
}

By spraying attacker-controlled kmalloc allocations (for example via Unix Domain Socket messages) we can control the underlying UAF pointer job->regs, so we can control the destination of our write. Additionally since we set the registers at the beginning of execution, by setting the registers in such a way that the BigWave processor does not execute at all, we can ensure that the end register state is nearly identical to the original register state - hence we can control what is written as well. And just like that, we have a half decent 2144-byte arbitrary write! And all without leaking the KASLR slide!

Defeating KASLR (by doing nothing at all)

Exploiting this issue with KASLR enabled would normally involve reallocating some other object over the bigo inst with a pointer at the location of inst->job.regs, leading to memory corruption of the object pointed to by that overlapped pointer. That would require finding some allocatable object with a pointer at that location, and also finding a way to take advantage of being able to overwrite the sub-object. Finding such an object is difficult but not impossible, especially if you consider cross-cache attacks. It is, however, quite tedious and is not really my idea of a fun time. Thankfully I found a much simpler strategy which essentially allows the generic bypass of KASLR on Pixel in its entirety, the details of which you can read about in my previous blog post. The end-result of that sidequest is the discovery that instead of needing to leak the KASLR base, you can just use 0xffffff8000010000 instead, particularly when it comes to overwriting .data in the kernel. This dramatically simplifies the exploit, and substantially improves the exploit’s potential reliability.

Creating an arbitrary read/write

At this point, I have a mostly-arbitrary write primitive anywhere in kernel .data - I have an aliased location for, and can modify, any kernel globals I want. However the complete call at the end of the bigo_worker_thread job execution loop serves to complicate exploitation a little bit. complete calls swake_up_locked which performs a set of list operations on a list_head node inside of the bigo inst:

static inline int list_empty(const struct list_head *head)
{
return READ_ONCE(head->next) == head;
}

void swake_up_locked(struct swait_queue_head *q) //The q is located at &inst->job_comp.wait (so attacker controlled)
{
	struct swait_queue *curr;

	if (list_empty(&q->task_list))
		return;

	curr = list_first_entry(&q->task_list, typeof(*curr), task_list);
	wake_up_process(curr->task);
	list_del_init(&curr->task_list);
}

While the first list_empty call would be the simplest to forge, it would also require knowing the location of the inst in kernel memory as q is an inline struct inside of inst. Unfortunately, our KASLR bypass does not give us this, nor is it particularly easy to acquire, as the inst is in kernel heap, not kernel .data. That means we need to instead forge a valid list entry for the q to point to as well as know the location of a task to pass to wake_up_process(). Finally we need to actually forge enough of a list to survive a list_del_init on an entry in the q->task_list, which involves list nodes, and second list nodes that point to the first list node. This might sound quite difficult to forge given the limitation we’ve previously noted about our KASLR bypass, but in fact, it’s not so bad, since our arbitrary write has already happened by this point - so we know the location of memory that we control somewhere in kernel .data. This means we can forge arbitrary list nodes within that space in .data, and we can place pointers to those future forged list nodes in the original heap spray we use to replace the inst. We ALSO know the location of a single task struct in the kernel virtual address space - the init task! init’s task struct is in the kernel .data, so we can reference it through the linear map. A spurious wake_up_process on the init_task will be entirely inconsequential while avoiding a crash. You can see the code to set up these linked list nodes in setup_linked_list in the exploit.

With that roadblock resolved, it’s time to figure out what in .data to target with our arbitrary write. Our goal is to change our unreliable arbitrary write of 2144 bytes to a reliable arbitrary read/write that causes significantly less collateral damage to the memory around it. I decided to try reimplementing the strategy I reversed from an ITW exploit a couple years ago. This technique involves creating a type-confusion by replacing some of the VFS/fops handlers in the ashmem_misc data structure with other VFS handlers for other file types. In fact, because of CFI you cannot replace the handler function pointers with pointers to just any location in the kernel .text. You must replace the VFS handlers with other VFS handlers. Rather conveniently however, I can use configfs VFS handlers for my exploit, just like the ITW exploit. The final layout of the fops table and private_data of the struct file look like this:

The fops handlers in green will access the private_data structure as a struct ashmem_area, or asma, while the fops handlers in yellow access the same private_data structure as a configfs buffer. For the configfs fops handlers, the memory pointed to by page will be accessed - that is where we will want our arbitrary read/write to read or write. We will set our target using the ASHMEM_SET_NAME ioctl.

One additional complication however, is that the linear mapping of the kernel .text is not executable, so I can’t use .text region linear map addresses to the VFS handlers when forging my ashmem_misc data structure. In practice, it’s not particularly difficult to leak the actual KASLR slide. Before targeting ashmem_misc, I first use my arbitrary write to target the sel_fs_type object in the kernel .data. This structure has a string, name, that is printed when reading /proc/self/mounts. By replacing that string pointer using my arbitrary write, and then reading /proc/self/mounts, I can turn my unreliable arbitrary write into an arbitrary read instead! Using this arbitrary read, I can read the ashmem_fops structure (also through the linear map) which gives me pointers at an offset from the kernel base, allowing me to calculate the KASLR slide.

I then perform my arbitrary write again to overwrite the ashmem_misc structure with a pointer to a new forged ashmem_fops table that I construct at the same time - such is the perk of overwriting far more data than I need.

However, the astute among you may have realized that this massive 2144 byte arbitrary write has a major drawback too, as such a large write will clobber all of the data surrounding whatever I’m actually targeting with the write - this could lead to all sorts of extraneous crashes and kernel panics. In practice, spurious crashing can occur, but the phone is surprisingly quite stable. My experience was that it seemed to crash upon toggling the wifi on/off - but otherwise the phone seems to work mostly fine.

Once the forged ashmem_misc structure has been inserted, we now have a perfectly reliable arbitrary read/write, albeit with the phone extraneously crashing sometimes. Upon getting arb read/write, I set SELinux to permissive (just flip the flag in the selinux_state kernel object), fork off a new process, then use my arb read/write to point the new process’s task creds to init_cred. At this point, I now have a process with root credentials, and SELinux disabled.

Integrating into the Dolby exploit

Combining two exploits into one chain requires a fair amount of engineering effort from both exploits. The Dolby exploit will be delivering the Bigwave exploit as a shellcode payload, (patched into the process using /proc/self/mem) so I need to convert my exploit to work as a binary blob. It also needs to be much smaller than my static compilation environment supported. The lowest hanging fruit was to remove the static libc requirement and have the exploit include wrappers for all the syscalls and libc functions it needs. When I set about to complete this rather tedious task, I realized that this is something an LLM would probably be quite good at. So instead of implementing the sycall wrappers myself, I simply copy-pasted my source code into Gemini and asked it to create the needed header file of syscall wrappers for me. Naturally the AI-generated header file caused many compilation errors (as it surely would have if I had tried to do it too). I took those compilation errors, gave them back to the same Gemini window, and asked it to amend the header file to resolve those errors. The amended header file caused gcc to emit whole new and exciting compilation failures - but the errors looked different than before, so I simply repeated the process. After 4 or 5 attempts, Gemini was able to generate a header file that not only compiled - it worked perfectly. This provides some insight into how attackers might be able to use (or more likely are already using) LLMs to make their exploit process more efficient.

This effort results in a much smaller ELF than before (7 KB instead of 500 KB) but just an ELF is not enough - I need the generated blob to work if the dolby exploit simply starts executing from the top of the shellcode. The good news however is that my exploit can operate entirely without a linker - all that is necessary is to prepend a jump to the ELF that sets the PC to the entrypoint. I also include β€œ-mcmodel=tiny -fPIC -pie” in the gcc arguments so that the generated code will work agnostic to the shellcode’s location or alignment in memory.

Finalizing the exploit

Kernel arbitrary read/write is motivating enough as a security researcher to demonstrate the impact of the vulnerability, but it seemed incumbent to create some more accessible demo in order to demonstrate impact more broadly. I added code so that the exploit executed an included shell script, then wrote a shell script that took a picture and sent that picture back to an arbitrary IP address.

In the final part of this blog series, we will discuss what lessons we learned from this research.

Defeating KASLR by Doing Nothing at All

3 November 2025 at 09:00

Introduction

I’ve recently been researching Pixel kernel exploitation and as part of this research I found myself with an excellent arbitrary write primitive…but without a KASLR leak. As necessity is the mother of all invention, on a hunch, I started researching the Linux kernel linear mapping.

The Linux Linear Mapping

The linear mapping is a region in the kernel virtual address space that is a direct 1:1 unstructured representation of physical memory. Working with Jann, I learned how the kernel decided where to place this region in the virtual address space. To make it possible to analyze kernel internals on a rooted phone, Jann wrote a tool to call tracing BPF’s privileged BPF_FUNC_probe_read_kernel helper, which by design permits arbitrary kernel reads. The code for this is available here. The linear mapping virtual address for a given physical address is calculated by the following macro:

#define phys_to_virt(x)    ((unsigned long)((x) - PHYS_OFFSET) | PAGE_OFFSET)

On Arm64 PAGE_OFFSET is simply:

#define VA_BITS			(CONFIG_ARM64_VA_BITS)
#define _PAGE_OFFSET(va)	(-(UL(1) << (va)))
#define PAGE_OFFSET		(_PAGE_OFFSET(VA_BITS))

As CONFIG_ARM64_VA_BITS is 39 on Android, it’s easy to calculate PAGE_OFFSET = 0xffffff8000000000.
PHYS_OFFSET is calculated by:

extern s64			memstart_addr;
/* PHYS_OFFSET - the physical address of the start of memory. */
#define PHYS_OFFSET		({ VM_BUG_ON(memstart_addr & 1); memstart_addr; })

memstart_addr is an exported variable that can be looked up in /proc/kallsyms. Using Jann’s bpf_arb_read program, it’s easy to see what this value is:

tokay:/ # grep memstart /proc/kallsyms                                         
ffffffee6d3b2b20 D memstart_addr
ffffffee6d3f2f80 r __ksymtab_memstart_addr
ffffffee6dd86cc8 D memstart_offset_seed
tokay:/ # cd /data/local/tmp
tokay:/data/local/tmp # ./bpf_arb_read ffffffee6d3b2b20 8                                              <
ffffffee6d3b2b20  00 00 00 80 00 00 00 00                          |........|
tokay:/data/local/tmp #

This value (0x80000000) doesn’t look particularly random. In fact, memstart_addr was theoretically randomized on every boot, but in practice this hasn’t happened for a while on arm64. In fact as of commit 1db780bafa4c it’s no longer even theoretical - virtual address randomization of the linear map is no longer a supported feature in arm64 Linux kernel.

The systemic issue is that memory can (theoretically) be hot plugged in Linux and on Android because of CONFIG_MEMORY_HOTPLUG=y. This feature is enabled on Android due to its usage in VM memory sharing. When new memory is plugged into an already running system, it must be possible for the Linux kernel to address this new memory, including adding it onto the linear map. Android on arm64 uses a page size of 4 KiB and 3-level paging, which means virtual addresses in the kernel are limited to 39 bits, unlike typical X86-64 desktops which use 4-level paging and have 48 bits of virtual address space (for kernel and userspace combined); the linear map has to fit within this space further shrinking the area available for it. Given that the maximum amount of theoretical physical memory is far larger than the entire possible linear map region range, the kernel places the linear map at the lowest possible virtual address so it can theoretically be prepared to handle exorbitant (up to 256GB) quantities of hypothetical future hot-plugged physical memory. While it is not technically necessary to choose between memory hot-plugging support and linear map randomization, the Linux kernel developers decided not to invest the engineering effort to implement memory hot-plugging in a way that preserves linear map randomization.

So we now know that PHYS_OFFSET will always be 0x80000000, and thusly, the phys_to_virt calculation becomes purely static - given any physical address, you can calculate the corresponding linear map virtual address by the following formula:

#define phys_to_virt(x)    ((unsigned long)((x) - 0x80000000) | 0xffffff8000000000)

Kernel physical address non-randomization

Compounding this issue, it also happens that on Pixel phones, the bootloader decompresses the kernel itself at the same physical address every boot: 0x80010000.

tokay:/ # grep Kernel /proc/iomem
  80010000-81baffff : Kernel code
  81fc0000-8225ffff : Kernel data

Theoretically, the bootloader can place the kernel at a random physical address every boot, and many (but not all) other phones, such as the Samsung S25, do this. Unfortunately, Pixel phones are an example of a device that simply decompresses the kernel at a static physical address.

Calculating static kernel virtual addresses

This means that we can statically calculate a kernel virtual address for any kernel .data entry. Here’s an example of me computing that linear map address for the modprobe_path string in kernel .data on a Pixel 9:

tokay:/ # grep modprobe_path /proc/kallsyms                                    
ffffffee6ddf2398 D modprobe_path
tokay:/ # grep stext /proc/kallsyms                                            
ffffffee6be10000 T _stext
//Offset from kernel base will be 0xffffffee6ddf2398 - 0xffffffee6be10000 = 0x1fe2398
//Physical address will be 0x80010000 + 0x1fe2398 = 0x81ff2398
//phys_to_virt(0x81ff2398) = 0xffffff8001ff2398

tokay:/ # /data/local/tmp/bpf_arb_read ffffff8001ff2398 64                     
ffffff8001ff2398  00 73 79 73 74 65 6d 2f 62 69 6e 2f 6d 6f 64 70  |.system/bin/modp|
ffffff8001ff23a8  72 6f 62 65 00 00 00 00 00 00 00 00 00 00 00 00  |robe............|
[ zeroes ]
tokay:/ # reboot                                                                                                         sethjenkins@sethjenkins91:~$ adb shell
tokay:/ $ su
tokay:/ # /data/local/tmp/bpf_arb_read ffffff8001ff2398 64
ffffff8001ff2398  00 73 79 73 74 65 6d 2f 62 69 6e 2f 6d 6f 64 70  |.system/bin/modp|
ffffff8001ff23a8  72 6f 62 65 00 00 00 00 00 00 00 00 00 00 00 00  |robe............|
[ zeroes ]
tokay:/ #

So modprobe_path will always be accessible at the kernel virtual address 0xffffff8001ff2398, in addition to its normal mapping, even with KASLR enabled. In practice, on Pixel devices you can derive a valid virtual address for a kernel symbol by calculating its offset and simply adding a hardcoded static kernel base address of 0xffffff8000010000. In short, instead of breaking the KASLR slide, it is possible to just use 0xffffff8000010000 as a kernel base instead.

The linear mapping memory is even mapped rw for any kernel .data regions. The only consolation that makes using this address slightly less effective than the traditional method of leaking the KASLR slide is that .text regions are not mapped executable - so an attacker cannot use this base for e.g. ROP gadgets or more generally PC control. But oftentimes, a Linux kernel attacker’s goal isn’t arbitrary code execution in kernel context anyway - arbitrary read-write is the more frequently desired primitive.

Impact on devices with kernel physical address randomization

Even on devices where the kernel location is randomized in the physical address space, linear mapping non-randomization still softens the kernel considerably to attempts at exploitation. This is particularly because techniques that involve spraying memory (either kernel structures or even userland mmap’s!) can land at predictable physical addresses - and those physical addresses are easily referenceable in kernel virtual address space through the linear map. That potentially gives an attacker a methodology for placing kernel data structures or even simply attacker-controlled userland memory at a known kernel virtual address. I created a simple program that allocated (via mmap and page fault) a substantial quantity (~5 GB) of physical memory on a Samsung S23, then used /proc/pagemap to create a list of which physical page frame numbers (pfns) were allocated. I ran this program 100 times (rebooting in between each time), then counted how often each pfn appeared across the 100 execution cycles. The set of pfns and their counts for how often they appeared were then converted into an image where each pfn is represented by a single pixel. The brighter the green of a pixel, the more often that page was attacker controlled, with a white pixel representing a pfn that was allocated every time. A black pixel represents a pfn that was never allocated - often because those pfn numbers are not mapped to physical memory or because they are used every time in a deterministic way. A big thank you to Jann Horn for developing the tool to create this image from the data that I collected.

This data exemplifies the non-homogenous reliability of pfn allocation to userland mappings, albeit on a device that was only just rebooted. There are ranges of pfns that are allocated quite reliably, and other ranges that are quite unreliable (but still occasionally used). For example, here’s a range of pfns surrounding one of the pages that was allocated 100 times in a row. I suspect this sample is representative of the practical reliability of this technique for placing desired data at a known kernel address for at least a newly rebooted device.

While reliability may suffer on a device that hasn’t rebooted in some time, it remains high enough to be inviting to real-world attackers. Being able to place arbitrarily readable and writable data at a known kernel virtual address is a powerful exploitation primitive as an attacker can much more easily forge kernel data structures or objects and, for example, emplace pointers to those objects in heap sprays attacking UAF issues.

The Prognosis

I reported these two separate issues, lack of linear map randomization, and kernel lands at static physical address in Pixel, to the Linux kernel team and Google Pixel respectively. However both of these issues are considered intended behavior. While Pixel may introduce randomized physical kernel load addresses at some later point as a feature, there are no immediate plans to resolve the lack of randomization of the Linux kernel’s linear map on arm64.

Conclusion

Three years ago, I wrote on the state of x86 KASLR and noted how β€œit is probably time to accept that KASLR is no longer an effective mitigation against local attackers and to develop defensive code and mitigations that accept its limitations.” While it remains true that KASLR should not be trusted to prevent exploitation, particularly in local contexts, it is regrettable that the attitude around Linux KASLR is so fatalistic that putting in the engineering effort to preserve its remaining integrity is not considered to be worthwhile. The joint effect of these two issues dramatically simplified what might otherwise have been a more complicated and likely less reliable exploit. While side-channel attacks do impact the long-term viability of KASLR on all architectures, it is notable that Project Zero and the Google Threat Intelligence Group have yet to see a hardware side-channel attack for bypassing KASLR on Android in the wild. Additionally, KASLR still plays an important role in mitigating any remote kernel exploitation attempts. It is valuable from a security in-depth perspective to recognize the impact KASLR has on exploit complexity and reliability in real-world scenarios. In the future, we hope to see changes to the Linux kernel linear mapping and memory hot-plugging implementation to make this a less inviting target for attackers. Randomizing the location of the linear map in the virtual address space, increasing the entropy in physical page allocation, and randomizing the location of the kernel in the physical address space are all concrete steps that can be taken that would improve the overall security posture of Android, the Linux kernel, and Pixel.

❌