不费吹灰之力击败KASLR

访问原始链接 Google 翻译

引言

最近我一直在研究Pixel内核漏洞利用,作为这项研究的一部分,我发现自己拥有一个极好的任意写入原语……但却没有KASLR泄漏。由于需要是发明之母,凭直觉,我开始研究Linux内核的线性映射。

Linux线性映射

线性映射是内核虚拟地址空间中的一个区域,它是物理内存的直接1:1非结构化表示。在与Jann合作的过程中,我了解到内核如何决定在虚拟地址空间中放置这个区域。为了能够在已root的手机上分析内核内部结构,Jann编写了一个工具来调用跟踪BPF的特权BPF_FUNC_probe_read_kernel辅助函数,该函数设计上允许任意内核读取。相关代码可在此处找到。给定物理地址的线性映射虚拟地址通过以下宏计算:

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

在Arm64上,PAGE_OFFSET简单地定义为:

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

由于Android上的CONFIG_ARM64_VA_BITS为39,很容易计算出PAGE_OFFSET = 0xffffff8000000000。
PHYS_OFFSET通过以下方式计算:

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是一个导出变量,可以在/proc/kallsyms中查找。使用Jann的bpf_arb_read程序,很容易看到这个值:

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 #

这个值(0x80000000)看起来并不特别随机。实际上,memstart_addr理论上在每次启动时是随机的,但在arm64上,实际上已经有一段时间没有发生这种情况了。事实上,根据提交1db780bafa4c,这甚至不再是理论上的——线性映射的虚拟地址随机化不再是arm64 Linux内核支持的功能。

系统性问题在于,由于CONFIG_MEMORY_HOTPLUG=y,Linux和Android上(理论上)可以热插拔内存。这个功能在Android上被启用,因为它用于虚拟机内存共享。当新内存被插入到已经运行的系统中时,Linux内核必须能够寻址这块新内存,包括将其添加到线性映射中。Arm64上的Android使用4 KiB的页面大小和3级分页,这意味着内核中的虚拟地址被限制在39位,这与典型的X86-64桌面系统不同,后者使用4级分页并拥有48位虚拟地址空间(内核和用户空间合计);线性映射必须适应这个空间,进一步缩小了其可用区域。考虑到理论上的最大物理内存量远大于整个可能的线性映射区域范围,内核将线性映射放置在尽可能低的虚拟地址,以便理论上能够处理未来可能热插拔的大量(高达256GB)假设物理内存。虽然在技术上不必在内存热插拔支持和线性映射随机化之间做出选择,但Linux内核开发者决定不投入工程努力以实现一种保留线性映射随机化的内存热插拔方式。

因此,我们现在知道PHYS_OFFSET将始终是0x80000000,因此,phys_to_virt的计算变得纯粹是静态的——给定任何物理地址,您可以通过以下公式计算相应的线性映射虚拟地址:

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

内核物理地址非随机化

加剧这个问题的是,在Pixel手机上,引导加载程序每次启动时都在相同的物理地址解压内核本身:0x80010000。

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

理论上,引导加载程序可以在每次启动时将内核放置在随机的物理地址,许多(但不是所有)其他手机,如三星S25,就是这样做的。不幸的是,Pixel手机是一个在静态物理地址解压内核的设备示例。

计算静态内核虚拟地址

这意味着我们可以静态计算任何内核.data条目的内核虚拟地址。以下是我在Pixel 9上计算内核.data中modprobe_path字符串的线性映射地址的示例:

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:/ #

因此,modprobe_path将始终可以在内核虚拟地址0xffffff8001ff2398访问,除了其正常映射外,即使启用了KASLR。实际上,在Pixel设备上,您可以通过计算其偏移量并简单地添加硬编码的静态内核基地址0xffffff8000010000来推导内核符号的有效虚拟地址。简而言之,与其破解KASLR偏移,不如直接使用0xffffff8000010000作为内核基地址。

线性映射内存甚至为任何内核.data区域映射为rw。 唯一使得使用此地址比传统的泄漏KASLR偏移方法效果稍差的是,.text区域未映射为可执行——因此攻击者无法使用此基地址进行例如ROP gadget或更一般的PC控制。但通常,Linux内核攻击者的目标并不是在内核上下文中执行任意代码——任意读写是更常期望的原语。

对具有内核物理地址随机化的设备的影响

即使在内核位置在物理地址空间中确实随机化的设备上,线性映射非随机化仍然显著软化了内核的利用尝试。这尤其因为涉及内存喷洒(无论是内核结构还是甚至用户空间mmap!)的技术可以落在可预测的物理地址上——而这些物理地址通过线性映射在内核虚拟地址空间中很容易被引用。这潜在地为攻击者提供了一种方法,将内核数据结构甚至仅仅是攻击者控制的用户空间内存放置在已知的内核虚拟地址上。我创建了一个简单的程序,在三星S23上分配(通过mmap和页面错误)大量(约5 GB)物理内存,然后使用/proc/pagemap创建已分配的物理页面帧号(pfn)列表。我运行这个程序100次(每次之间重启),然后统计每个pfn在100次执行周期中出现的频率。然后将pfn集合及其出现频率计数转换为图像,其中每个pfn由一个像素表示。像素的绿色越亮,该页面被攻击者控制的频率越高,白色像素表示每次都被分配的pfn。黑色像素表示从未被分配的pfn——通常是因为这些pfn编号未映射到物理内存,或者因为它们每次都以确定性的方式使用。非常感谢Jann Horn开发了从我收集的数据创建此图像的工具。

这些数据例证了pfn分配到用户空间映射的非均匀可靠性,尽管是在刚刚重启的设备上。有些pfn范围分配相当可靠,而其他范围则相当不可靠(但仍然偶尔使用)。例如,这里是一个围绕连续分配100次的页面之一的pfn范围。我怀疑这个样本代表了这种技术在至少新重启的设备上将所需数据放置在已知内核地址的实际可靠性。

虽然在一段时间未重启的设备上可靠性可能会下降,但它仍然足够高,足以吸引现实世界的攻击者。能够在已知的内核虚拟地址放置任意可读和可写的数据是一个强大的利用原语,因为攻击者可以更容易地伪造内核数据结构或对象,例如,在攻击UAF问题的堆喷洒中放置指向这些对象的指针。

预测

我将这两个独立的问题,缺乏线性映射随机化和Pixel中内核位于静态物理地址,分别报告给了Linux内核团队和Google Pixel。然而,这两个问题都被视为预期行为。虽然Pixel可能在以后的某个时间点引入随机化的物理内核加载地址作为一项功能,但目前没有立即计划解决arm64上Linux内核线性映射缺乏随机化的问题。

结论

三年前,我撰写了关于x86 KASLR现状的文章,并指出“可能是时候接受KASLR不再是针对本地攻击者的有效缓解措施,并开发接受其局限性的防御代码和缓解措施了。”虽然KASLR不应被信任来防止利用,特别是在本地上下文中,这仍然是事实,但遗憾的是,围绕Linux KASLR的态度如此宿命论,以至于投入工程努力以保持其剩余完整性被认为不值得。这两个问题的共同作用极大地简化了原本可能更复杂且可能更不可靠的漏洞利用。虽然侧信道攻击确实影响了KASLR在所有架构上的长期可行性,但值得注意的是,Project Zero和Google威胁情报小组尚未在野外看到用于绕过Android上KASLR的硬件侧信道攻击。此外,KASLR在缓解任何远程内核利用尝试方面仍然发挥着重要作用。从深度安全的角度来看,认识到KASLR在现实场景中对漏洞利用复杂性和可靠性的影响是有价值的。未来,我们希望看到Linux内核线性映射和内存热插拔实现的改变,使其对攻击者的吸引力降低。随机化线性映射在虚拟地址空间中的位置,增加物理页面分配的熵,以及随机化内核在物理地址空间中的位置,都是可以采取的具体步骤,将改善Android、Linux内核和Pixel的整体安全态势。