锆石和紫红色的 SafeStack

简介

LLVM 的 safe-stack 功能是一种编译器模式,旨在加强生成的代码,以防范堆栈粉碎攻击,例如利用缓冲区溢出 bug 的攻击。

上面链接的 Clang/LLVM 文档页面介绍了常规方案。简而言之,每个线程都有两个堆栈,而不是通常的一个:一个“安全堆栈”和一个“不安全堆栈”。不安全堆栈用于可能使用指向堆栈内存的指针的所有用途,而安全堆栈仅用于任何代码都不应看到指向堆栈内存的指针的用途。因此,不安全堆栈用于通过引用传递给另一函数或将其地址存储在堆中的数组或变量,这些内存可能会受到缓冲区溢出或释放后使用 bug 及其漏洞利用的影响。安全堆栈用于编译器寄存器溢出和函数调用的返回地址。因此,例如,简单的缓冲区溢出 bug 无法用于覆盖包含函数的返回地址,而这正是利用所谓的 ROP(“面向返回的编程”)技术进行漏洞利用和攻击的基础。

该页面的兼容性部分不适用于 Zircon(或 Fuchsia)。在 Zircon 用户模式代码(包括所有 Fuchsia)中,SafeStack 的运行时支持直接包含在标准 C 运行时库中,并且在共享库 (DSO) 中一切正常。

安全堆栈影子调用堆栈插桩方案和 ABI 相关且相似,但也是正交的。您可以针对任何功能单独启用或停用每个参数。Fuchsia 的编译器 ABI 和 libc 始终与使用或不使用任何一种插桩构建的代码互操作,无论特定 libc build 中是否使用了插桩。

互操作和 ABI 效应

一般来说,safe-stack 不会影响 ABI。特定于机器的调用约定保持不变。在采用安全堆栈构建的程序中,有些函数使用安全堆栈,有些函数不使用,这没问题。无论这两种文件是直接编译的 .o 文件、归档库(.a 文件)还是共享库(.so 文件),都可以任意组合。

虽然存在一些额外的线程状态(不安全的堆栈指针,请参阅下文中的“实现详情”),但未使用安全堆栈的代码在调用或被使用安全堆栈的代码调用时,无需对该状态执行任何操作即可保持其正确性。唯一的潜在例外情况是实现自己的非本地退出或上下文切换(例如协程)的代码。Zircon C 库的 setjmp/longjmp 代码会自动保存和恢复此额外状态,因此基于 longjmp 的任何内容都已经正确处理了所有内容,即使调用 setjmplongjmp 的代码不知道安全堆栈也是如此。

在 Zircon 和 Fuchsia 中使用

在 Clang 编译器中,可通过 -fsanitize=safe-stack 命令行选项启用此功能。这是编译器针对 x86_64-fuchsia 目标的默认模式。如需针对特定编译停用该功能,请使用 -fno-sanitize=safe-stack 选项。aarch64-fuchsiariscv64-fuchsia 目标默认启用 shadow-call-stack

Zircon 支持将安全堆栈用于用户模式和内核代码。在 x86 Zircon build 中,使用 Clang(向 GN 传递 variants = [ "clang" ])进行 build 时,安全堆栈始终处于启用状态。

实现细节

支持 safe-stack 代码的关键新增功能是不安全的堆栈指针。从抽象意义上讲,这可以看作是一个额外的寄存器,就像机器的正常堆栈指针寄存器一样。机器的堆栈指针寄存器用于安全堆栈,就像它一直以来所做的那样。不安全堆栈指针的使用方式与 ABI 中具有固定用途的另一个寄存器类似,但机器实际上并没有新寄存器,并且为了实现兼容性,safe-stack 不会更改将用途分配给所有机器寄存器的基本机器特定调用约定。

Zircon 和 Fuchsia 的 C 和 C++ ABI 将不安全的堆栈指针存储在内存中,该内存与线程指针的偏移量是固定的。<zircon/tls.h> 标头定义了每台机器的偏移量。

对于 x86 用户模式,线程指针为 fsbase,这意味着汇编代码中的访问看起来像 %fs:ZX_TLS_UNSAFE_SP_OFFSET。对于 x86 内核,线程指针是 gsbase,这意味着汇编代码中的访问看起来像 %gs:ZX_TLS_UNSAFE_SP_OFFSET

对于 Aarch64 (ARM64),在 C 或 C++ 代码中,__builtin_thread_pointer() 会返回线程指针。在用户模式下,线程指针位于 TPIDR_EL0 特殊寄存器中,必须提取到普通寄存器(使用 mrs *reg*, TPIDR_EL0)才能访问内存,因此它不是汇编代码中的单条指令。在内核中,它只是相同,但使用 TPIDR_EL1 特殊寄存器代替。

低级代码和汇编代码的注释

大多数代码(即使是汇编代码)也完全不需要考虑安全堆栈问题。调用规范未发生变化。使用堆栈保存寄存器、查找返回地址等操作在有无安全堆栈的情况下都是一样的。主要例外情况是实现非本地退出或上下文切换等功能的代码。此类代码可能需要保存或恢复不安全的堆栈指针。longjmp 函数和 C++ throw 均已直接处理此问题,因此使用这些结构的 C 或 C++ 代码无需执行任何新操作。

内核中的上下文切换代码负责切换不安全的堆栈指针。在 x86 上,这一点在代码中非常明确:%gs 指向 struct x86_percpu,后者在 ZX_TLS_UNSAFE_SP_OFFSET 处有一个成员 kernel_unsafe_sparch_context_switch 将此成员复制到旧线程的 struct arch_threadunsafe_sp 字段中,然后将新线程的 unsafe_sp 复制到 kernel_unsafe_sp 中。在 ARM64 上,set_current_thread 会隐式执行此操作,因为这会更改 TPIDR_EL1 特殊寄存器,该寄存器直接指向每个线程的 struct thread,而不是像在 x86 上那样指向每个 CPU 的结构。

实现某种新的非本地退出或上下文切换的新代码需要以类似于处理传统机器堆栈指针寄存器的方式来处理不安全的堆栈指针。任何此类代码都应使用 #if __has_feature(safe_stack) 在编译时测试特定 build 中是否使用了安全堆栈。该预处理器构造可用于 C、C++ 或汇编 (.S) 源文件。