clang之ShadowCallStack

2,374 阅读9分钟

Clang 12 documentation

Clang 12 documentation包含了一系列工具,如 AddressSanitizerThreadSanitizerLeakSanitizerLibTooling等。

  1. clang之AddressSanitizer
  2. clang之MemorySanitizer
  3. clang之LeakSanitizer
  4. clang之UndefinedBehaviorSanitizer
  5. clang之Hardware-assisted-AddressSanitizer
  6. clang之SafeStack
  7. clang之ShadowCallStack
  8. clang之ThreadSanitizer
  9. clang之Thread-Safety-Analysis
  10. clang之DataFlowSanitizer

这部分是对clang文档 Clang 12 documentation ShadowCallStack 的翻译。仅供参考。

介绍

ShadowCallStack 是一个编译器插桩的pass,当前仅在aarch64中实现了,用于在程序中防止返回地址重写(如栈缓冲溢出)。其工作原理是:对于主干函数 non-leaf functions,将函数的返回地址保存到函数开头的一个独立分配的影子调用栈(shadow call stack)中,在函数结尾处从影子调用栈中加载函数返回地址。返回地址也会存储在通常的栈中,用于与堆栈回溯器保持兼容,但实际上并未使用。

aarch64上的实现已经可以使用于生产环境了,且一个runtime的实现已经被加到了Android的libc(bionic)中了。x86_64上的实现已经使用Chromium来评估过了,然而却有严重的性能和安全影响,所以从LLVM 9.0中移除了。关于x86_64上的实现细节,可以在Clang 7.0.1文档中查找到。

对照

为了优化内存问题和缓存命中率,影子调用栈仅存储了保存返回地址的一个数组。这与其他工具形成对照,如SafeStack,会映射整个栈,且花费更多内存用于使得函数开头和结尾更短,以便获取更少的内存访问。

返回流程守护(Return Flow Guard)是x86_64上的一个纯软件实现的影子调用栈。与x86_64上的已有的影子调用栈的实现一样,因为体系架构上函数调用和返回都要使用到栈,所以影子调用栈技术在x86_64上可以说是原生内在支持的(inherently racy)。

Intel的CET技术(Control-flow Enforcement Technology)是一个提倡使用的硬件扩展,能够添加一个原生支持,以便使用影子栈在调用或返回时机去存储或检查返回地址。作为一个硬件实现,它并没有竞争条件和函数插桩带来的缺点和性能损耗,但它却需要操作系统的支持。

兼容性

在编译器运行时并未提供一个runtime,所以必须要由被编译的应用或操作系统来提供一个runtime。应该优先将该runtime集成到操作系统中,否则所有的线程创建和销毁操作都需要被应用程序拦截。

编译器插桩需要使用到平台寄存器x18。在一些平台上,x18是保留的;而在其他平台上,x18是用来作为暂存寄存器(scratch register)的。这通常意味着,任何代码,只要和用影子调用栈编译过的代码运行在同一线程上,都必须要么针对ABI保留有x18寄存器的平台(当前有Android、Darwin, Fuchsia 和 Windows)进行适配,要么使用 -ffixed-x18 标记来编译。如果绝对需要的话,没有使用 -ffixed-x18 编译的代码,可能与使用影子调用栈的代码运行在同一线程上,即通过在栈上临时保留寄存器值的方式(如在Android上),但是这样需要格外小心,因为可能导致影子调用栈的地址泄漏。

因为x18寄存器的使用,影子调用栈特性与其他可能使用到x18寄存器的特性会不兼容。然而,并没有一个固有的原因导致影子调用栈必须要特别使用到x18寄存器。原则上,一个平台可以选择保留和使用其他寄存器用于影子调用栈,但是这会与AAPCS64不兼容。

使用影子调用栈编译过的代码需要特别的回溯信息(unwind information),有可能未被回溯,如使用 -fexceptions 编译的函数(这在C++中是默认的)。一些回溯器(unwinder)(如libgcc 4.9的回溯器)并不理解这种回溯信息,所以遇到这种情况会出现段错误。然而,LLVM的libunwind就能够正确处理这种回溯信息。这意味着,如果这些特例要和影子调用栈一起使用,程序就必须要使用一个兼容的回溯器。

什么是 unwind information,参考stackoverflow上的一个回答: what's mean about “compact unwind info” in linker synthesized

当一个异常发生的时候,就需要用到回溯信息来回溯堆栈。回溯堆栈包括确定帧指针(frame pointer)、栈指针(stack pointer)、返回地址,以及其他任何保存数据的寄存器的存储地址,这样就能从上一个栈帧中恢复状态。对于任意的给定栈帧,如果有一个回溯处理函数,用于处理编程语言的异常处理机制中的 catchfinally 特性,如C++和Objective-C。当前栈帧的所有以上信息都能从指令寄存器(instruction pointer)中来推断。当一个函数从它的最初始指令开始运行时,详细信息都改变了,因为每一条指令都可能修改相关的寄存器,或者将已保存的寄存器值与栈进行数据交换(入栈或出栈)。回溯信息描述了如何从一个指令寄存器中来推断以上所有这些值。在一个二进制中,会嵌入有很多种格式的回溯信息。一个常见的格式就是 DWARF 回溯信息。这确实会占用不少的磁盘空间。苹果开发了更为简洁的回溯信息,确实会使用较少的磁盘空间。

英文原文,即:

Unwind info is the information necessary to unwind the stack when an exception is thrown/raised. Unwinding the stack involves determining where the frame pointer, stack pointer, return address, and any saved registers were stored so state can be restored for the previous frame. It also determines, for any given stack frame, if there's an unwind handler function, to handle "catch" and "finally" features of exception handling in languages like C++ and Objective-C. All of that information for the current frame is determined from the instruction pointer. As execution proceeds through a function from its very first instruction, the details change because each instruction may modify the relevant registers and/or push or pop saved register values to and from the stack. The unwind info describes how to determine from an instruction pointer where to find all of these values. There are various forms of unwind info that can be embedded in a binary. One common form is DWARF unwind info. That is fairly space inefficient. Apple developed compact unwind info because, believe it or not, it actually uses much less space.

安全

影子调用栈的目的是为了作为 -fstack-protector 的更强替代方案。它能防止返回地址受到非线性溢出和错误的内存写操作的影响。

编译器插桩会使用x18寄存器来作为影子调用栈的引用,意味着影子调用栈的引用并不会存储在内存中。这就意味着,可以实现一个runtime来防止影子调用栈的地址被暴露给能够读取任意内存的攻击者。然而,攻击者依然可以尝试通过操作系统或处理器暴露出来的其他通道来发现影子调用栈的地址。

攻击者可能会根据其他内存分配的地址来猜测出影子调用栈的地址,除非在分配影子调用栈的时候格外小心。因此,要选择一个难以被攻击者猜测出来的地址。一种实现方式是分配一大块守护的内存区域,禁止该内存区域的读写权限,随机选择其中的一小块内存区域作为影子调用栈的地址,将该地址标记为可读写。这样也可以减轻通过处理器其他通道发起的攻击。实际上,Android runtime将会做这个工作,但是平台必须首先禁用 setrlimit(RLIMIT_AS),以在特定进程中限制内存的分配,同时这也会限制可以分配的守护区域的数量。

runtime需要获取影子调用栈的地址,以便于能够在销毁线程的时候释放它。如果整个程序是通过 -ffixed-x18 来编译的,那么影子调用栈的地址就能从x18寄存器中存储的值来推断出来(例如,通过将低位的bit抹掉)。如果使用了守护的内存区域,则守护内存区域的起始地址就会被存储在影子调用栈自身的起始地址上。如果未使用 -ffixed-x18 编译的代码,能够运行在runtime管理的线程上,比如在Android平台上,该地址就必须存储在其他地方了。在Android上,我们将守护区域的起始地址存储在TLS(线程本地存储)中,在线程退出的时候销毁包含影子调用桢的整个守护区域。这是可以接受的,因为守护区域的起始地址其实本来就是可能猜测出来的。

有一种方式中影子调用栈的地址可能会泄漏,即在使用 setjmplongjmpjmp_buf 的数据结构中。Android runtime通过在 jmp_buf 中仅存储x18寄存器的低bit位的方式来避免这种情况,这就要求影子调用栈的地址与其大小对齐。

体系架构的调用和返回指令(bl和ret)作用在一个寄存器上,而非栈上。这意味着即使不使用影子调用栈,分支函数通常也不会受到返回地址重写的危害。

用法

在编译和链接的命令行中使用 -fsanitize=shadow-call-stack 标记,即可开启 ShadowCallStack。在aarch64上,需要传递 -ffixed-x18 参数,除非目标平台已经保留了 *x188 寄存器。

底层API

__has_feature(shadow_call_stack)

在一些场景下,可能需要根据 ShadowCallStack 是否开启来执行不同的代码。宏定义 __has_feature(shadow_call_stack) 可以用于这个目的:

#if defined(__has_feature)
#  if __has_feature(shadow_call_stack)
// code that builds only under ShadowCallStack
#  endif
#endif

attribute((no_sanitize("shadow-call-stack")))

在一个函数声明中使用 attribute((no_sanitize("shadow-call-stack"))) ,可以指定在该函数上不使用影子调用栈的插桩,即便已经全局开启了。

样例

如下样例:

int foo() {
  return bar() + 1;
}

使用 -O2 优化级别编译的时候,生成如下aarch64的汇编代码:

stp     x29, x30, [sp, #-16]!
mov     x29, sp
bl      bar
add     w0, w0, #1
ldp     x29, x30, [sp], #16
ret

添加 -fsanitize=shadow-call-stack 会输出如下汇编代码:

str     x30, [x18], #8
stp     x29, x30, [sp, #-16]!
mov     x29, sp
bl      bar
add     w0, w0, #1
ldp     x29, x30, [sp], #16
ldr     x30, [x18, #-8]!
ret