简介
LLVM 的 shadow-call-stack 功能是一种编译器模式,旨在加强生成的代码以防范堆栈粉碎攻击(例如利用缓冲区溢出 bug 攻击)。
上面链接的 Clang/LLVM 文档页面介绍了该架构。封装摘要是,函数返回地址绝不会从常规堆栈重新加载,而只会从单独的“影子调用堆栈”中重新加载。这是一个额外的堆栈,但并非包含每个函数所需的任何大小的整个堆栈帧,而是只针对它记录的每个调用帧包含一个地址词:仅包含返回地址。由于影子调用堆栈是独立于其他堆栈或堆块进行分配的,这些堆栈或堆块具有自己的随机化地址,且这些地址的指针较少,因此,某种缓冲区溢出或释放后使用漏洞会覆盖内存中的返回地址,进而导致程序返回到攻击者的指令的可能性要小得多。
shadow-call-stack 和 safe-stack 插桩方案和 ABI 相关且相似,但也正交。您可以针对任何函数单独启用或停用每个参数。无论特定的 libc build 中使用或未使用什么插桩,Fuchsia 的编译器 ABI 和 libc 始终与使用或不使用这两种插桩构建的代码进行互操作。
互操作和 ABI 影响
一般来说,shadow-call-stack 不会影响 ABI。特定于机器的调用约定保持不变。在使用 shadow-call-stack 构建的程序中,有些函数可以包含一些函数,而有些函数则不包含。将两者组合起来是来自直接编译的 .o
文件、来自归档库(.a
文件)还是来自共享库(.so
文件)以任意组合进行合并无关紧要。
虽然各线程还有一些额外的状态(影子调用堆栈指针,见下文),但不使用 shadow-call-stack 的代码不需要对该状态执行任何操作,以便在调用使用安全堆栈的代码或被使用安全堆栈的代码调用时保持该状态的正确性。唯一可能的例外情况是实现自身种类的非本地退出或上下文切换(例如协程)的代码。Zircon C 库的 setjmp
/longjmp
代码会自动保存和恢复此附加状态,因此任何基于 longjmp
的内容都会正确处理所有内容,即使调用 setjmp
和 longjmp
的代码不知道 shadow-call-stack。
对于 AArch64 (ARM64),x18
寄存器通常已在 ABI 中预留为“固定”寄存器。默认情况下,如果代码不知晓 ABI 的 shadow-call-stack 扩展,如果其从未接触过 x18
,则可与 shadow-call-stack ABI 进行互操作。
任何其他架构尚不支持该功能。
在 Zircon 和 Fuchsia 中使用
Aarch64 (ARM64) 上的 Zircon 在内核中和用户模式代码中支持影子调用堆栈。这是通过 -fsanitize=shadow-call-stack
命令行选项在 Clang 编译器中启用的。对于 aarch64-fuchsia
(ARM64) 目标,它默认处于启用状态。若要针对特定编译停用该功能,请使用 -fno-sanitize=shadow-call-stack
命令行选项。
与 safe-stack 一样,没有用于指定影子调用堆栈大小的单独工具。相反,在旧版 API(例如 pthread_attr_setstacksize
)和 ABI(例如 PT_GNU_STACK
)中为“堆栈”指定的大小会用作每种堆栈的大小。由于根据特定的程序行为以不同的比例使用不同类型的堆栈,因此根据传统的单个堆栈大小选择影子调用堆栈大小是没有什么好办法。因此,每种堆栈的大小都会达到调整后的“一元”堆栈大小所预期的最糟糕情况所需要的大小。虽然这看起来很浪费,但其实只是有点浪费:至少,每个种类的堆栈都会浪费一个页面,此外,由于为永远无法访问的页面使用了更多地址空间,还增加了页面表开销。
实现细节
支持 shadow-call-stack 代码的基本补充是影子调用堆栈指针。与传统的堆栈指针一样,这是一个全局使用的寄存器。但是,每个调用帧都会推送和弹出一个返回地址字,而不是像在普通堆栈帧中那样的任意数据。
对于 AArch64 (ARM64),x18
寄存器会在函数条目处存储影子调用堆栈指针。影子调用堆栈随递增后语义而向上扩展,因此 x18
始终指向下一个可用槽。除了溢出并重新加载返回地址寄存器(x30
,也称为 LR)之外,编译器都不会改动寄存器。Fuchsia ABI 要求 x18
始终包含有效的阴影堆栈指针。也就是说,将新地址推送到位于 x18
的影子调用堆栈(模堆栈溢出)必须始终有效。
低级别代码和汇编代码注意事项
大多数代码,即使是在汇编中,也完全不需要考虑影子调用堆栈问题。调用规范没有改变。无论有无 shadow-call-stack,对堆栈(和/或不安全堆栈)的所有使用均相同;启用帧指针后,返回地址会按预期存储在机器堆栈中帧指针旁边。对于 AArch64 (ARM64),函数调用仍会照常使用 x30
作为返回地址,但重写 x30
的函数可以选择使用其他内存泄漏和重新加载。理想情况下,以汇编形式编写的非叶函数应该利用影子调用堆栈 ABI,方法是将返回地址寄存器(而不是在机器堆栈中)溢出和重新加载。
主要的例外情况是实现非局部退出或上下文切换等功能的代码。此类代码可能需要保存或恢复影子调用堆栈指针。longjmp
函数和 C++ throw
都已直接处理这种情况,因此使用这些结构的 C 或 C++ 代码无需执行任何新操作。
实现某种新型非局部退出或上下文切换的新代码需要处理影子调用堆栈指针,其处理方式与处理传统机器堆栈指针寄存器和不安全堆栈指针的方式类似。任何此类代码都应在编译时使用 #if __has_feature(shadow_call_stack)
来测试特定 build 中是否使用了 shadow-call-stack。该预处理器结构可以在 C、C++ 或汇编 (.S
) 源文件中使用。