Monday, November 3, 2025

Defeating KASLR by Doing Nothing at All

 Posted by Seth Jenkins, Project Zero

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.