ShadowCallStack

简介

ShadowCallStack 是一个代码插桩过程,目前仅在 aarch64 和 RISC-V 上实现,用于保护程序免受返回地址覆盖(例如堆栈缓冲区溢出)。它的工作原理是在非叶函数的函数序言中将函数的返回地址保存到一个单独分配的“影子调用堆栈”,并在函数尾声中从影子调用堆栈加载返回地址。返回地址也存储在常规堆栈上以与展开器兼容,但除此之外不再使用。

aarch64 的实现被认为已达到生产就绪状态,并且 Android 的 libc(bionic)中添加了一个运行时的实现。x86_64 的实现已使用 Chromium 进行了评估,发现它存在严重的性能和安全缺陷——它在 LLVM 9.0 中被移除。有关 x86_64 实现的详细信息,请参阅Clang 7.0.1 文档

比较

为了优化内存使用和缓存局部性,影子调用堆栈仅存储一个返回地址数组。这与其他方案(例如 SafeStack)形成对比,后者镜像整个堆栈,并在消耗更多内存以换取更短的函数序言和尾声以及更少的内存访问方面进行权衡。

返回流保护 是在 x86_64 上实现影子调用堆栈的纯软件实现。与 x86_64 上 ShadowCallStack 的先前实现一样,它天生就存在竞态条件,因为该架构使用堆栈进行调用和返回。

英特尔 控制流强制技术 (CET) 是一种提议的硬件扩展,它将添加本机支持,以在调用/返回时使用影子堆栈存储/检查返回地址。作为硬件实现,它不会受到竞态条件的影响,也不会产生函数代码插桩的开销,但它确实需要操作系统支持。

兼容性

编译器运行时中未提供运行时,因此必须由已编译的应用程序或操作系统提供。将运行时集成到操作系统中应该是优先选择,因为否则应用程序将需要拦截所有线程创建和销毁。

代码插桩利用了 AArch64 上的平台寄存器 x18、带软件影子堆栈的 RISC-V 上的 x3 (gp) 以及带硬件影子堆栈的 RISC-V 上的 ssp,它需要 Zicfiss-mno-forced-sw-shadow-stack(默认选项)。请注意,使用 Zicfiss 时,RISC-V 后端将默认使用基于硬件的影子调用堆栈。用户可以通过传递 -mforced-sw-shadow-stack 来强制 RISC-V 后端使用 Zicfiss 生成基于软件的影子调用堆栈。为简单起见,我们将将其称为 SCSReg。在某些平台上,SCSReg 是保留的,而在其他平台上,它被指定为一个临时寄存器。这通常意味着任何可能与使用 ShadowCallStack 编译的代码在同一个线程上运行的代码,要么必须针对其 ABI 保留 SCSReg 的平台之一(当前为 Android、Darwin、Fuchsia 和 Windows),要么必须使用一个标志进行编译以保留该寄存器(例如 -ffixed-x18)。如果绝对必要,可以使用未保留寄存器的代码与使用 ShadowCallStack 的代码在同一个线程上运行,方法是在堆栈上临时保存寄存器值(Android 中的示例),但这应该谨慎进行,因为它存在泄露影子调用堆栈地址的风险。

由于它需要一个专用寄存器,因此 ShadowCallStack 功能与任何可能使用 SCSReg 的其他功能不兼容。但是,ShadowCallStack 本身并不需要使用特定寄存器;原则上,一个平台可以选择为 ShadowCallStack 保留和使用另一个寄存器,但这将与 AAPCS64 和 RISC-V psABI 中发布的 ABI 标准不兼容。

使用 ShadowCallStack 编译并可能被展开(即使用 -fexceptions 编译的函数,这是 C++ 中的默认值)的函数需要特殊的展开信息。某些展开器(例如 libgcc 4.9 展开器)无法理解此展开信息,并在遇到它时会导致段错误。但是,LLVM libunwind 会正确处理此展开信息。这意味着如果异常与 ShadowCallStack 一起使用,则程序必须使用兼容的展开器。

安全性

ShadowCallStack 旨在成为 -fstack-protector 的更强大的替代方案。它可以防止非线性溢出和对返回地址插槽的任意内存写入。

代码插桩利用了 SCSReg 寄存器来引用影子调用堆栈,这意味着对影子调用堆栈的引用不必存储在内存中。这使得可以实现一个运行时,避免将影子调用堆栈的地址暴露给能够读取任意内存的攻击者。但是,攻击者仍然可以尝试利用操作系统[1] [2] 或处理器[3] 中暴露的侧信道来发现影子调用堆栈的地址。

除非在分配影子调用堆栈时采取谨慎措施,否则攻击者可能会使用其他分配的地址来猜测其地址。因此,应选择该地址以使这变得困难。一种方法是分配一个没有读/写权限的大型保护区域,在其中随机选择一个较小的区域用作影子调用堆栈的地址,并将仅该区域标记为读/写。这也能在一定程度上缓解处理器侧信道问题。我们的意图是 Android 运行时将这样做,但平台首先需要更改以避免在某些进程中使用 setrlimit(RLIMIT_AS) 来限制内存分配,因为这也会限制可以分配的保护区域的数量。

运行时需要影子调用堆栈的地址才能在销毁线程时释放它。如果整个程序都使用 SCSReg 保留编译,这很简单:地址可以从存储在 SCSReg 中的值(例如通过屏蔽掉低位)推导出来。如果使用保护区域,则保护区域起始地址可以存储在影子调用堆栈本身的起始位置。但是,如果使用未保留 SCSReg 编译的代码可能在运行时管理的线程上运行(例如,Android 上的情况),则该地址必须存储在其他地方。在 Android 上,我们将保护区域起始地址存储在 TLS 中,并在线程退出时释放整个保护区域(包括影子调用堆栈)。考虑到保护区域起始地址本身已经可以猜测到,因此这被认为是可以接受的。

影子调用堆栈的地址可能泄露的一种方式是在 setjmplongjmp 使用的 jmp_buf 数据结构中。Android 运行时避免了这种情况,方法是仅将 SCSReg 的低位存储在 jmp_buf 中,这要求影子调用堆栈的地址与其大小对齐。

该架构的调用和返回指令(blret)对寄存器而不是堆栈进行操作,这意味着即使没有 ShadowCallStack,叶函数通常也能免受返回地址覆盖。

使用

要启用 ShadowCallStack,只需在编译和链接命令行中都传递 -fsanitize=shadow-call-stack 标志。在 aarch64 上,还需要传递 -ffixed-x18,除非您的目标已保留 x18。在 RISC-V 上不需要传递其他标志,因为基于软件的影子堆栈使用 x3 (gp),它始终被保留,而基于硬件的影子调用堆栈使用一个专用寄存器 ssp。但是,在 RISC-V 上使用基于软件的影子堆栈时,在链接器中禁用 GP 松弛非常重要。这可以通过在 GNU ld 中使用 --no-relax-gp 标志来完成,并且在 LLD 中默认关闭。

低级 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"))) 来指定即使在全局启用时也不应将 ShadowCallStack 检测应用于该函数。

示例

以下示例代码

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