LLVM笔记(6) - CompilerRT之safestack

好久没更新博客了, 最近调研安全编译选项(各类sanitizer), 抽空做个笔记. 本来想系统的分析一下compiler-rt代码, 但是最近实在太懒了, 所以先介绍最简单的安全栈safestack, 之后有空再补上compiler-rt框架以及其它sanitizer工具.

1. 什么是safestack

safestack是Code Pointer Integrity (CPI) Project的部分实现. CPI(代码指针完整性)是为了阻止控制流劫持攻击而提出的一种通过保证代码指针安全性的设计, 关于CPI的具体内容可以参考论文官网. safestack是CPI的一个组件, 但也可以单独使用(用于防止基于栈的控制流攻击), 它通过将栈分为两个独立区域, safe stack(用于存储函数返回地址, 寄存器spill, 保证安全访问的局部变量)和unsafe stack(其它存储在栈上的内容)来保证即使栈空间溢出也不会影响到程序流执行(链接地址不会被覆写).

2. 一个基于栈攻击的例子

目前CPI并没有完整的实现, 其preview版本可以通过源码下载. 但safestack已作为compiler-rt的一部分整合在LLVM工程中, 通过-fsanitize=safe-stack选项可以开启该特性.
以下是一个简单的示例, test()函数中栈空间被改写导致程序流没有正常返回, 而是进入hihack().

[21:32:13] hansy@hansy:~/llvm-mono (master)$ cat ~/1.c
#include <stdio.h>
void hijack() {
  puts("stack hijack\n");
}
int test() {
  long a = 0;
  long *pa = &a;
  *(pa + 2) = hijack;
  return a;
}
int main() {
  return test();
}
[21:41:41] hansy@hansy:~/llvm-mono (master)$ ./llvm_install/bin/clang ~/1.c -w && ./llvm_install/bin/llvm-objdump -D a.out > a.s
[21:41:44] hansy@hansy:~/llvm-mono (master)$ grep test\: a.s -A 15
00000000004004f0 test:
  4004f0: 55                           	pushq	%rbp
  4004f1: 48 89 e5                     	movq	%rsp, %rbp
  4004f4: 48 c7 45 f8 00 00 00 00      	movq	$0, -8(%rbp)
  4004fc: 48 8d 45 f8                  	leaq	-8(%rbp), %rax
  400500: 48 89 45 f0                  	movq	%rax, -16(%rbp)
  400504: 48 8b 45 f0                  	movq	-16(%rbp), %rax
  400508: 48 b9 d0 04 40 00 00 00 00 00	movabsq	$4195536, %rcx
  400512: 48 89 48 10                  	movq	%rcx, 16(%rax)
  400516: 48 8b 45 f8                  	movq	-8(%rbp), %rax
  40051a: 5d                           	popq	%rbp
  40051b: c3                           	retq
  40051c: 0f 1f 40 00                  	nopl	(%rax)
[21:52:04] hansy@hansy:~/llvm-mono (master)$ ./a.out 
stack hijack

段错误 (核心已转储)

通过反汇编test()可以看到程序的栈地址从高到低依次为:
---------------- top
link addr (push by hardware)
frame pointer (%rbp)
var a (%rbp - 8)
var pa (%rbp - 16)
---------------- bottom
因此修改(pa + 2)地址正好覆写硬件压栈的地址, 当执行retq以后程序跳转至hijack(), 程序流被改写. 现在来看看使用safestack后会怎样.

[22:02:03] hansy@hansy:~/llvm-mono (master)$ ./llvm_install/bin/clang ~/1.c -w -fsanitize=safe-stack && ./llvm_install/bin/llvm-objdump -D a.out > a.s
[22:02:22] hansy@hansy:~/llvm-mono (master)$ grep test\: a.s -A 25
0000000000401860 test:
  401860: 55                           	pushq	%rbp
  401861: 48 89 e5                     	movq	%rsp, %rbp
  401864: 48 8b 05 75 17 20 00         	movq	2103157(%rip), %rax
  40186b: 64 48 8b 08                  	movq	%fs:(%rax), %rcx
  40186f: 48 89 ca                     	movq	%rcx, %rdx
  401872: 48 83 c2 f0                  	addq	$-16, %rdx
  401876: 64 48 89 10                  	movq	%rdx, %fs:(%rax)
  40187a: 48 89 ca                     	movq	%rcx, %rdx
  40187d: 48 83 c2 f8                  	addq	$-8, %rdx
  401881: 48 c7 41 f8 00 00 00 00      	movq	$0, -8(%rcx)
  401889: 48 89 55 f8                  	movq	%rdx, -8(%rbp)
  40188d: 48 8b 55 f8                  	movq	-8(%rbp), %rdx
  401891: 48 c7 42 10 40 18 40 00      	movq	$4200512, 16(%rdx)
  401899: 8b 71 f8                     	movl	-8(%rcx), %esi
  40189c: 64 48 89 08                  	movq	%rcx, %fs:(%rax)
  4018a0: 89 f0                        	movl	%esi, %eax
  4018a2: 5d                           	popq	%rbp
  4018a3: c3                           	retq
  4018a4: 66 2e 0f 1f 84 00 00 00 00 00	nopw	%cs:(%rax,%rax)
  4018ae: 66 90                        	nop

00000000004018b0 main:
  4018b0: 55                           	pushq	%rbp
  4018b1: 48 89 e5                     	movq	%rsp, %rbp
  4018b4: 48 83 ec 10                  	subq	$16, %rsp
[22:02:27] hansy@hansy:~/llvm-mono (master)$ ./a.out 
段错误 (核心已转储)

改写后的汇编存储空间发生变化:
---------------- top
link addr (push by hardware)
frame pointer (%rbp)
var pa (%rbp - 8)
---------------- bottom
同时pa中存储的地址是(%fs:(%rax) - 8), 因此hijack()函数地址被存储到(%fs:(%rax) + 8)而非返回地址. 我们可以通过打印日志了解栈空间是如何变化的.

[20:35:45] hansy@hansy:~/llvm-mono (master)$ cat 1.ll 
*** IR Dump Before Safe Stack instrumentation pass ***
; Function Attrs: noinline nounwind optnone safestack uwtable
define dso_local i32 @test() #0 {
entry:
  %a = alloca i64, align 8
  %pa = alloca i64*, align 8
  store i64 0, i64* %a, align 8
  store i64* %a, i64** %pa, align 8
  %0 = load i64*, i64** %pa, align 8
  %add.ptr = getelementptr inbounds i64, i64* %0, i64 2
  store i64 ptrtoint (void ()* @hijack to i64), i64* %add.ptr, align 8
  %1 = load i64, i64* %a, align 8
  %conv = trunc i64 %1 to i32
  ret i32 %conv
}
*** IR Dump After Safe Stack instrumentation pass ***
; Function Attrs: noinline nounwind optnone safestack uwtable
define dso_local i32 @test() #0 {
entry:
  %unsafe_stack_ptr = load i8*, i8** @__safestack_unsafe_stack_ptr
  %unsafe_stack_static_top = getelementptr i8, i8* %unsafe_stack_ptr, i32 -16
  store i8* %unsafe_stack_static_top, i8** @__safestack_unsafe_stack_ptr
  %pa = alloca i64*, align 8
  %0 = getelementptr i8, i8* %unsafe_stack_ptr, i32 -8
  %a.unsafe2 = bitcast i8* %0 to i64*
  store i64 0, i64* %a.unsafe2, align 8
  %1 = getelementptr i8, i8* %unsafe_stack_ptr, i32 -8
  %a.unsafe1 = bitcast i8* %1 to i64*
  store i64* %a.unsafe1, i64** %pa, align 8
  %2 = load i64*, i64** %pa, align 8
  %add.ptr = getelementptr inbounds i64, i64* %2, i64 2
  store i64 ptrtoint (void ()* @hijack to i64), i64* %add.ptr, align 8
  %3 = getelementptr i8, i8* %unsafe_stack_ptr, i32 -8
  %a.unsafe = bitcast i8* %3 to i64*
  %4 = load i64, i64* %a.unsafe, align 8
  %conv = trunc i64 %4 to i32
  store i8* %unsafe_stack_ptr, i8** @__safestack_unsafe_stack_ptr
  ret i32 %conv
}

可以看到变化前test()函数包含两条alloca(栈空间分配)指令. 其中%pa只被load与store引用, 且其长度与alloca大小一致. 而%a做为二重指针被存入%pa, 编译器保守处理将其看作非安全访问, 因此%a地址被替换为%a.unsafe2(unsafe_stack_ptr - 8). 在后文中会详细解释这个特殊地址的由来, 以及虽然控制流未被改写但是为何仍然core dump(safestack的局限性).

3. 两种栈保护特性比较

栈金丝雀(-fstack-protector)也是一个栈溢出保护特性, 其原理是在每个函数起始与结束处插桩. 在被调函数开辟栈空间前将生成的随机数作为guard存到栈上, 当被调函数返回前重新生成随机数并与guard比较, 若不相同则报错退出.
从设计上看栈金丝雀的目标是保护整个栈空间, 而安全栈侧重于防止基于栈的控制流攻击.
从防护效果上看栈金丝雀虽然能保护整个栈空间, 但是其依赖于栈溢出是顺序覆写的假设, 并不能百分百保证程序流正确性(如上文case中非线性修改绕过guard或破解随机数使其失效).
另一方面由于安全栈通过将非安全的栈空间与其它空间分离开来, 这从理论上可以保证程序流不被改写(最极端的例子是原始栈只保存链接地址和压栈的参数), 但这同时让非安全栈数据空间缺乏保护(无法检测非安全栈的溢出).
从性能开销上看安全栈也优于栈金丝雀, 前者几乎没有性能负载(0.05%), 后者我手边没有资料, 但就从实现来讲增加的指令是肯定多于前者的(需要经历生成随机数, 保存种子, 重新计算并比较的过程).
从防护方式上看栈金丝雀检测到溢出后会直接报错退出, 而安全栈仍会正常执行程序流(即使非安全栈上数据已被修改), 这可能增加问题定位难度.
最后两者可以结合使用, 但是由于安全栈把栈划分成两部分, 而栈金丝雀只能作用于原生栈, 因此栈金丝雀保护效果减弱.

4. safestack的实现

作为compiler-rt的一个组件, safestack也分成两部分实现: 编译器插桩及compiler-rt支持.

5. 编译器插桩部分

当添加-fsanitize=safe-stack选项后, 编译器会为函数添加safestack属性. 如果不想为某个函数添加安全栈特性, 可以在函数声明时添加__attribute__((no_sanitize("safe-stack"))).
LLVM中实现名为SafeStackLegacyPass(lib/CodeGen/SafeStack.cpp). 这部分代码比较简单就不具体分析了, 简要列举下几个关键函数:
SafeStack::findInsts()收集了函数所有的alloca, return, call指令并判断是否需要放入unsafe stack(注意intrinsic并不会被收集, 因此包含修改内存的intrinsic的函数可能生成不正确的栈).
SafeStack::IsSafeStackAlloca()用于判断对一条alloca指令的所有访问是否永远是safe access的, 具体方法是DFS遍历所有use. 注意这里load与store的处理是不一致的, 对于load而言alloca指令只能是地址操作数, 所以只需判断访问是否越界, 而对于store而言其本身也能做立即数, 此时保守处理默认unsafe(这就是为什么上文中变量a处于unsafe stack而指针pa反而落在safe stack, 感兴趣的读者可以自己打印该pass前后的日志分析一下).
TargetLoweringBase::getSafeStackPointerLocation()是架构相关的hook, 用于返回unsafe stack地址, 其中的变量名与compiler-rt保持一致(定义在compiler-rt中), 对于不使用compiler-rt的情况则需自己提供实现, 移植代码时需要注意.
SafeStack::moveStaticAllocasToUnsafeStack()负责计算并分配unsafe stack空间.
SafeStack::createStackRestorePoints()用于longjmp/exception返回时恢复栈指针, 这块手边没有例子, 也没仔细看, 以后再补充.

6. compiler-rt部分

compiler-rt部分主要实现unsafe stack的内存分配, 栈地址的返回以及几个builtin函数, 代码见compiler-rt/lib/safestack.
先来看下builtin函数: __builtin__get_unsafe_stack_ptr() / __builtin__get_unsafe_stack_bottom()(另有废弃接口__builtin__get_unsafe_stack_start()) / __builtin__get_unsafe_stack_top()分别返回当前线程的unsafe stack的栈指针 / 栈底 / 栈顶.
compiler-rt中还定义了线程存储的全局变量unsafe_stack_start / unsafe_stack_size / unsafe_stack_guard, 其初始化见构造函数__safestack_init(). 可以看到unsafe stack实际是mmap映射出来的一块内存区域. 由于栈空间是线程独立的, 所以可以看到safestack.cpp还拦截了pthread_create(), 这块具体以后写sanitizer时候再分析吧.
最后一个问题, 为什么启用safe stack后仍然core dump了? 因为unsafe stack是mmap出来的区域, 在栈顶偏移访问了越界的地址(上文case太简单导致test调用时unsafe stack还是空的, 指针本来就指向栈顶, 再偏移就溢出了). 所以safe stack并不能100%保证程序正常运行, 只能保证不被hijack.

7. 移植safe stack

比移植ASAN简单多了, 编译器侧不用做修改(有需要可以修改上文提到的hook). 如果不启用compiler-rt需要定义hook中使用到的指针. 如果启用compiler-rt那肯定是基于linux或其它OS了, 什么都无需修改.

8. 与其它安全特性兼容

已经一点了, 以后有空再写吧...

posted @ 2019-12-05 01:11  Five100Miles  阅读(1329)  评论(1编辑  收藏  举报