{% set rfcid = "RFC-0159" %} {% include "docs/contribute/governance/rfcs/_common/_rfc_header.md" %}
{{ rfc.name }}: {{ rfc.title }}
总述
本文档提出了对内核 API 的更改,以支持带有只执行段的二进制文件。本文档向 zx_system_get_features
中加入了新特性判断,更改了 launchpad 和 process_builder 加载器,并更改了 Fuchsia 的树内 libc
中的动态链接器来支持 '--x' 参数。其为内核最终实现在支持的硬件上对只执行页映射的支持提出了计划。
我们通常并不需要读取已加载的可执行内存。默认启用只执行代码能增强 Fuchsia 用户空间进程的安全性, 并有推进了最小权限的工程最佳做法。
动机
ARM 的内存管理单元(memory management unit,MMU)在 ARMv7m 中加入了对只执行页的支持,允许内存页被映射为既不可读也不可写的只执行状态。 虽然可写的代码页很早就被认为有安全威胁,但允许代码保持可读也将应用暴露于不必要的风险中。具体而言,对代码页 的读取常常成为攻击链的第一步,防止对代码的读取能对攻击形成阻碍。详见可读代码的安全性。 而且,支持只执行页不仅很好地符合了 Fuchsia 的权限模型,也更符合最小特权原则:代码通常并不需要被读,而只用执行。
相关方
协调人:
- cpu@google.com
审阅人:
- phosek@google.com
- mvanotti@google.com
- maniscalco@google.com
- travisg@google.com
背景
只执行内存
只执行内存(execute-only memory,XOM)指没有读和写权限,只能执行的内存页。ARMv7m 及之后的指令集架构(instruction set architecture,ISA)原生支持 XOM,但对 旧 ISA 的支持也在考虑中。进一步的讨论在 XOM 与 PAN。
本文几乎仅关注 AArch64,但其具体实现是架构无关的。当其他架构的硬件和工具链支持成熟后,应该能很容易受益于 Fuchsia 中的只执行支持。
代码页权限
最开始,计算机支持对物理内存的直接内存访问,没有任何检查或保护。MMU 的引入提供了关键的抽象,它以 虚拟内存的形式将程序视角的内存和底层的物理资源解耦。这促成了一种更加灵活又安全的编程模型, 使得操作系统的作者可以通过进程的抽象,来提供程序间强大的隔离能力。如今的 MMU 提供了大量关键 功能,如内存分页、快速地址翻译和权限检查,还使得用户对内存区域的访问和使用有了显著的控制。 这种控制是通过内存页权限来实现的。内存页权限一般控制内存页是否可读可写或可执行。 这是程序安全和错误隔离的关键属性,因为这样就能通过硬件强制的权限检查限制程序滥用系统资源的能力。
既能写又能执行的内存相当危险,因为它为攻击者利用缓冲区溢出等常见漏洞达到任意代码执行提供了一种容易的途径。
由于这个原因,许多操作系统的配置显式地禁止内存页同时可写和可执行(W^X)。这已成为标准十多年,
OpenBSD 于 2003 年在 OpenBSD 3.3 中加入了对 W^X 的支持 openbsd-wxorx。也请参考
SELinux 的 W^X 政策 selinux-wxorx。可写的内存对于即时编译 (JIT) 这种在
运行时往内存里写入可执行指令的技术确实有用。由于对 W|X 页的获取可能被禁止,JIT 需要绕过它。一种简便的
方法是先将代码写入不可执行页,再通过例如 mprotect 或者 zx_vmar_protect 来将页保护状态变为可执行
但不可写 example-fuchsia-test。在几乎所有情况下 W|X 的页权限都过大了。与此类似,可执行
页也几乎不怎么需要读,参见例外。允许对可执行页的读操作通常并无必要,也不应
成为默认配置。
可读代码
由于 ARM 的指令是定长的,对立即数大小有限制,所以 load 操作会以相对 PC 寻址的方式实现。具体来说,
伪指令 ldr Rd, =imm 会在该代码附近的字面量池(literal pool)中放置 imm。这与 XOM 不兼容,因为它将数据放在了必须可读的
.text 段中。我们在代码库里搜索字面量池的使用以确保没有对可执行段的读操作时,
发现 Zircon 中有一些 ldr Rd, =imm 的使用,但现在都移除了。Clang 不会在 aarch64 中使用
字面量池,而会生成多条指令来创建一个大立即数。Clang 有一个 -mexecute-only 标志,其别名为 -mpure-code,
但这只在 arm32 上有意义,因为 aarch64 本就蕴涵这个标志。
示例:大立即数
本示例展示 Clang 在给定不同的 target 时如何将这段 C 代码编译为汇编 clang-example。 其中上面是 aarch64,下面是 arm32:
XOM 与 PAN
特权模式访问禁止(privileged access never,PAN)是 ARM 芯片中阻止内核态以正常方式访问用户页内存的安全特性。这种特性有助于防范
潜在的内核漏洞,因为内核无法用正常的 load 和 store 指令接触用户内存。操作系统若要访问这些内存页,
需要先关闭 PAN,或使用 ldtr 和 sttr 指令。PAN 现在在 Fuchsia 中并未启用,但已有在 Zircon
中提供相应支持的计划 pan-fxb。
aarch64 的页表项有四个控制页权限的位。其中两个用于用户和特权模式执行禁止(privileged execute-never,PXN),另两个用来描述这两个访问级别 的读和写权限。只执行的映射既无写权限也无读权限,但允许用户执行。
这张来自 ARMv8 参考手册的表格展示了使用这四个二进制位能表示的内存保护状态。EL0 表示用户空间的例外级别。 第0和第2行展示了创建用户空间只执行页的方法。请参阅 ARMv8 参考手册的表 D5-34 Stage 1。
| UXN | PXN | AP[2:1] | 从更高例外级别访问 | 从 EL0 访问 |
|---|---|---|---|---|
| 0 | 1 | 00 | R, W | X |
| 0 | 1 | 01 | R, W | R, W, X |
| 0 | 1 | 10 | R | X |
| 0 | 1 | 11 | R | R, X |
遗憾的是,PAN 决定内存页是否不可特权模式访问的算法,会检查该页是否用户可读。 在 PAN 眼中,用户只执行页看起来就是可被特权模式访问的页。 这使得内核能在本不应该的地方访问用户内存,从而绕过了 PAN 的设计意图,使得 PAN 与 XOM 不兼容 pan-issue。 这样一来,尽管 PAN 还能用来探测内核 bug,但它再也无法用来防止那些意图通过攻破内核来接触用户内存的攻击。
这个问题导致 Linux 和 Android 都放弃了对 XOM 的支持。特别是 Android,先在 Android 10 中 加入了支持,并将其设为所有 aarch64 二进制的默认选项,又在 Android 11 中将其无限期放弃 linux-revert。他们计划在能解决这个问题的硬件更加普及后再重启这一特性,但尚无将其重新加入的 明确时间表。
ARM 随后提出了一种称为“增强”PAN(enhanced PAN,ePAN)的解决方案,将 PAN 改为不仅检查内存页是否用户可读,也 检查其是否用户可执行。然而,带有此特性的硬件也许好多年都不会出现在 Fuchsia 的目标设备上。Linux 在 ePAN 出现后重新添加了他们的 XOM 实现 linux-re-land。设备对 ePAN 的支持情况不在我们的 掌控中,PAN 与 XOM 的不兼容也不应阻碍内核对 PAN 的实现 详见。
根据表 2,并不存在一种配置能将读权限从内核中剥离。唯一的例外是 PAN,其能在内核试图访问用户可读 的页时引发异常。因此,没有办法为内核创建一种只执行映射,因为内核没法将某页标记为 EL1 可执行的同时 让它不可读。所以,只执行映射只能为用户空间进程创建。
为 XOM 硬件构建
ELF 中的段权限表明代码需要哪些权限才能正确运行。也就是说,软件在构建时并不需要知道硬件是否支持 XOM,而应该只要不需要读代码页就无条件地使用 XOM。至于怎样将那些权限强制到系统允许的最大程度,应该由 操作系统和程序加载器决定 elf-segment-perm。
虚拟内存权限
POSIX 规定 mmap 可以允许对没有显式设置 PROT_READ 的页的读取操作 posix-mmap。x86 平台的 Linux 和 macOS,
以及 M1 芯片上的 macOS,在遇到来自 mmap 的内存页请求时,对于只设置了 PROT_EXEC 的情况都不会
失败,而是将被请求内存页设为 PROT_READ | PROT_EXEC。这些系统调用的实现是在能力范围内“尽力”满足
用户的请求。与此相对,Fuchsia 的系统调用在能否满足用户请求的问题上从来很明确。zx_vmar_* 系统调用
并不会像 POSIX 中的对应调用按照标准允许的一样静默提升内存页权限。请求内存页时不设置 ZX_VM_PERM_READ
目前必定失败,因为硬件和操作系统不支持映射没有读权限的页。要平滑地迁移到支持带有只执行段的二进制和
用户空间程序分配只执行内存,需要一种在请求前判断操作系统能否映射只执行页的方法。
可读代码的安全性
许多攻击依赖于读取代码页来找出“针对性指令序列”(gadget),即感兴趣的可执行代码,来收集关于进程的信息。地址空间 布局随机化(address space layout randomization,ASLR)是一种将二进制段加载到进程地址空间中半随机的位置的操作系统技术。Fuchsia 和 许多其他操作系统利用这种技术来防范依赖于知晓代码或数据在内存中的位置的攻击。让代码不再可读进一步 减小了攻击面。
代码复用攻击,如“return-to-libc”rtl-attack,用于将函数控制流返回到已知地址。libc 常常 成为返回或跳转的目的地,因为其包含对攻击者有用的丰富功能,并且进程极有可能会链接 libc。人们已经证明, 典型程序中可用的 gadget 是图灵完备的(Turing-complete)。这赋予了攻击者执行任意代码的能力。
许多时候攻击者的目标是拿到 shell。ASLR 给这些攻击增加了难度,因为每次运行程序时函数地址都不同。 然而,ASLR 并不是完美的解决方案。攻击者虽然不能通过分析二进制找到函数地址,但仍可以 分析内存中的代码页来达到目的。XOM 使得攻击者无法以这种方式绕过 ASLR。 欲找出特定代码页中位置信息的攻击者将被迫另寻他法。
通用记号
“rwx/r-x/–x”
这些记号表示 ELF 段的权限。该段按照对应权限被映射到进程地址空间。这种记号在描述文件权限和 readelf 之类的
工具描述 ELF 的段权限时是通用的。r、w 和 x 分别表示读、写和执行,“-”表示对应权限未授予。
只执行段的权限表示为“--x”。
R^X, W|X 等等…
如前所述,R、W 和 X 指的是读、写和执行。“^”和“|”是 C 风格的操作符,意为“异或”和“或”。 R^X 读作 “读异或执行”。
“ax”
这是汇编器中的一种语法,其将一个节(section)标记为已分配且可执行。链接器目前会
将“ax”节放进“r-x”段里,而 lld 中的 --execute-only 标志会将这些段
标记为“--x”。
设计
为了支持 XOM,提高我们用户空间程序的安全性,我们的工具链和加载器都需要升级。clang 驱动需要 给链接器传递“--execute-only”标志来让“ax”节映射到“--x”段而不是“r-x”段。 加载器也需要修改那些要求至少有读权限的健全性检查,因为现在不一定有读权限了。
由于只有在支持 ePAN 的硬件上才能启用 XOM,我们需要支持平滑过渡。我们有以下选项:
- 将
vmar_*系列函数改成跟很多mmap实现一样的尽力而为。 - 创造一种查询内核是否支持只执行映射的方法,并在 XOM 不可用时让加载器将“--x”段的权限提升 到“r-x”。
- 加入新的
ZX_VM_PERM_READ_IF_XOM_UNSUPPORTED标志来让加载器使用“--x”段。
无论怎么选,都有潜在的静默提权问题。第一种选项最容易实现,加载器除了移除健全性检查外 什么都不用改。第二种选项也没有复杂多少,只用在加载器决定向操作系统请求哪些内存权限前 加一个简单的判断。第三种选项很有帮助,因为它在用户代码中更不容易出错。
第一种选项会破坏 Fuchsia 目前与用户空间的严格约定,即要求必须明确系统调用能否满足。 第二和第三种选项也会导致加载 ELF 文件时对内存权限的处理产生歧义。然而这是符合 ELF 规范的。段权限并不是说分配给这个段的内存只能有这些权限,而是说分配的内存必须至少有这些权限 程序才能正常运行。ELF 加载器也有权把“--x”的段映射进“r-x”的内存 elf-segment-perm。
第一种选项会对 Fuchsia 当前明确表达系统调用的处理方式的约定造成破坏,并不理想。选项 2 和 3 都有价值,本 RFC 提出的实现将基于这两种选项。
实现
系统调用新增
将添加新标志 ZX_VM_PERM_READ_IF_XOM_UNSUPPORTED,其会使在 options 中接收
权限标志的各种 zx_vmar_* 系统调用在 XOM 不受支持的情况下隐式添加读权限。按照逻辑,
ZX_VM_PERM_READ_IF_XOM_UNSUPPORTED 只能与 ZX_VM_PERM_EXEC 一起使用,不能
与 ZX_VM_PERM_READ 一起使用。然而接收该标志的各种系统调用并不会处理得这么死板。
ZX_VM_PERM_READ_IF_XOM_UNSUPPORTED 可以安全地与任意其他标志组合,仅在系统不能映射
只执行页的情况下它会被当作 ZX_VM_PERM_READ。
将为 zx_system_get_features 添加新 kind 值 ZX_FEATURE_KIND_VM,其会返回
与 ZX_FEATURE_KIND_CPU 类似的 bitset。也会有一个新特性 ZX_VM_FEATURE_CAN_MAP_XOM。
目前的实现总会保持这个位为假,因为 XOM 目前暂不会启用。加载器不会使用这个,因为“r-x”内存
权限对于“--x”段也是合法的,但让用户空间能够查询这一功能仍然很重要。
系统加载器 ABI 更改
目前和以后的加载器会保证即使在硬件不支持 XOM 的情况下“--x”段也能加载进内存。
加载器在映射只执行段时会添加 ZX_VM_PERM_READ_IF_XOM_UNSUPPORTED。
自带动态链接器 ABI 更改
相似地,Fuchsia 的 SDK 自带的 libc 中的动态链接器在为带有
ZX_VM_PERM_READ_IF_XOM_UNSUPPORTED 的“--x”段分配内存时,也会在必要的时候提权。
编译器工具链更改
clang 驱动也会改为在目标为 aarch64-*-fuchsia 时总是向链接器传递
--execute-only。我们也将需要一种退出这种行为的方法,最可能的是向链接器添加一个新的
‘--no-execute-only’ 标志,这样程序可以轻易退出新的默认行为。
内核 XOM 实现
一旦支持 ePAN 的硬件到来,内核就可以满足对只有 ZX_VM_PERM_EXECUTE 的内存页的请求。
arm64 的 user-copy 实现可能需要更新,以使其与用户内存的约束方式保持一致。user_copy 应该被更新
为使用 ldtr 和 sttr 指令。这将确保用户不能欺骗内核为其读取不可读的页。此外,内核在一些地方
假设了映射总是可读,这些也需要进行适当的修改。这项工作将在以后完成。
不需要的更改
zx_process_read_memory 不需要更改,调试器在调试只执行二进制时也应该正常工作。
zx_process_read_memory 忽略它所读取的页面的权限,只检查进程句柄是否有
ZX_RIGHT_READ 和 ZX_RIGHT_WRITE。
zx_vmar_protect 将继续像目前这样工作。值得注意的是,这意味着进程可以在必要时
用读权限保护其代码页。
性能
预计没有对性能的影响。
安全
在 XOM 在内核中实现之前,二进制文件使用“--x”段只会跟使用“r-x”段一样安全。 一旦硬件和操作系统都支持了 XOM,决定使用只执行内存的程序将变得更安全。参见 代码页权限,XOM 与 PAN和 可读代码的安全性 这几节。
隐私
除了在 安全 中提到的以外无需额外考虑。
测试
当我们在内核中强制 XOM 支持时,
会在构建时能知道应该返回什么的地方,对 zx_system_get_features 进行普通(trivial)的测试。
针对 ZX_VM_PERM_READ_IF_XOM_UNSUPPORTED,会测试其能否在
zx_system_get_features 报告操作系统无法创建只执行页时使内存页可读。
类似地,elfload 库也没有什么真正的测试,只有并不测试预期功能的模糊测试。 而它的功能其实是在对依赖它的其他组件的测试中测试的。这里应该加一些测试来保证“--x”段被正确映射。 process_builder 库确实有测试,这些测试将确保它在 XOM 不可用时正确请求可读和可执行内存。
对当前动态链接器的改变不会被直接测试。有一个新的动态链接器在计划中,它将有广泛的测试, 包括对“--x”段的测试。
对 clang driver 的更改会在上游 LLVM 中得到测试。
虽然 test bot 的硬件不支持 ePAN, 我们也不会在其他这样的硬件上启用 XOM,但我们仍会在 test bot 上设置启用 XOM 的测试配置。 这将有助于我们找到那些需要阅读代码页,从而需要退出只执行的树内程序。
文档
对 zx_system_get_features 的更改及用户空间需要查询 ZX_VM_FEATURE_CAN_MAP_XOM
的原因会被记录在文档中。新的 ZX_VM_PERM_READ_IF_XOM_UNSUPPORTED 标志也会被记录。对各种加载器和 clang 驱动的默认行为的更改
不会在此 RFC 外被记录。
负面影响、后备方案和未知因素
我们不知道现在和以后有多少外部代码会依赖于可执行代码的可读性。这可能来自于手写汇编中对 .text 段中 数据常量的使用,由其他工具链或程序自审编译的代码。无论如何,需要可读的内存页的程序仍会受益, 因为他们依赖的共享库,包括 libc,会被标记为只执行。将我们 clang 工具链的默认改为只执行段 会破坏依赖于可读代码的程序。在编译时没有简单的方法能判断程序是否依赖这一行为。但一旦能够认定程序 需要“r-x”段,不使用默认的“--x”会很简单。
对于那些只需要读取部分代码的程序,目前的工具不能简单支持。--execute-only linker
标志会从所有可执行段中去掉读权限。没有办法按需把某一个节标记为可读。
需要这一行为的程序将需要完全禁用只执行。
风险
如果 clang 驱动默认使用 --execute-only,就可能有代码虽然读取的是“--x”段,但要等到
支持 XOM 的硬件和内核落地后才会暴露问题。这给那些未做更改的软件造成了潜在的前向兼容问题。
对树内软件固然会有测试,但不太可能去测试树外软件。
已有工作与参考
因为在许多 POSIX 实现中对 mmap 权限标志的处理有歧义,所以它们不需要类似
zx_system_get_features(ZX_FEATURE_KIND_CAN_MAP_XOM, &feature) 的东西。
在较新的苹果芯片上,Darwin 支持 XOM,但其实现使用了专有硬件特性,可靠性更高。 这些芯片对于从内核和用户内存中去除单独权限位的功能具有硬件支持。这在 macOS 的用户空间中 没有启用。apple-xom