击败KASLR:无需任何操作即可绕过内核地址空间布局随机化

本文深入分析了Linux内核线性映射的非随机化问题,揭示了在Pixel设备上无需泄露KASLR即可计算内核虚拟地址的方法。通过详细的技术架构说明和实际代码示例,展示了如何利用这一特性进行内核利用,并探讨了对设备安全性的影响。

击败KASLR:无需任何操作即可绕过内核地址空间布局随机化

作者:Seth Jenkins,Project Zero

引言

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

Linux线性映射

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

给定物理地址的线性映射虚拟地址通过以下宏计算:

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

在Arm64上,PAGE_OFFSET简单定义为:

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

由于CONFIG_ARM64_VA_BITS在Android上为39,很容易计算出PAGE_OFFSET = 0xffffff8000000000。

PHYS_OFFSET通过以下方式计算:

1
2
3
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程序,很容易看到这个值是什么:

1
2
3
4
5
6
7
8
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上启用,因为它用于VM内存共享。当新内存插入到已运行的系统中时,Linux内核必须能够寻址此新内存,包括将其添加到线性映射中。

Android在arm64上使用4 KiB的页面大小和3级分页,这意味着内核中的虚拟地址限制为39位,与典型的X86-64桌面使用4级分页并具有48位虚拟地址空间(内核和用户空间合计)不同;线性映射必须适应此空间,进一步缩小其可用区域。鉴于理论上的最大物理内存量远大于整个可能的线性映射区域范围,内核将线性映射放置在最低可能的虚拟地址,以便理论上能够处理过量(高达256GB)的假设未来热插拔物理内存。

虽然技术上不必在内存热插拔支持和线性映射随机化之间做出选择,但Linux内核开发人员决定不投入工程精力以实现保留线性映射随机化的内存热插拔方式。

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

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

内核物理地址非随机化

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

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

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

计算静态内核虚拟地址

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
tokay:/ # grep modprobe_path /proc/kallsyms
ffffffee6ddf2398 D modprobe_path
tokay:/ # grep stext /proc/kallsyms
ffffffee6be10000 T _stext
// 从内核基址的偏移量为 0xffffffee6ddf2398 - 0xffffffee6be10000 = 0x1fe2398
// 物理地址将为 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小工具或更一般的PC控制。但通常,Linux内核攻击者的目标不是在内核上下文中执行任意代码——任意读写是更频繁期望的原语。

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

即使在内核位置在物理地址空间中随机化的设备上,线性映射非随机化仍然显著软化了利用尝试。这特别是因为涉及喷洒内存(无论是内核结构还是甚至用户空间mmap!)的技术可以落在可预测的物理地址上——并且这些物理地址通过线性映射在内核虚拟地址空间中很容易引用。这潜在地为攻击者提供了一种方法,将内核数据结构或甚至仅仅是攻击者控制的用户空间内存放置在已知的内核虚拟地址。

我创建了一个简单的程序,在三星S23上分配了大量(约5 GB)的物理内存(通过mmap和页面错误),然后使用/proc/pagemap创建分配的物理页帧号(pfn)列表。我运行此程序100次(每次之间重启),然后计算每个pfn在100个执行周期中出现的频率。然后将pfn集及其出现频率计数转换为图像,其中每个pfn由单个像素表示。像素的绿色越亮,该页面被攻击者控制的频率越高,白色像素表示每次都被分配的pfn。黑色像素表示从未被分配的pfn——通常是因为这些pfn编号未映射到物理内存,或者因为它们以确定性方式每次都被使用。非常感谢Jann Horn开发了从我收集的数据创建此图像的工具。

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

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

预后

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

结论

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

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计