好久沒更新博客了, 最近調研安全編譯選項(各類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. 與其它安全特性兼容
已經一點了, 以后有空再寫吧...
