初探 Shadow Stack
endbr64
是什么?
以前逆向程序的时候一直好奇 Linux 环境下编译的 C 程序每个函数开头的汇编指令 endbr64
有什么用。查阅 Intel 编程手册,其中对这个指令的描述是:
Terminate an Indirect Branch in 64-bit Mode
呃,啥?
经过一些搜索后发现这个竟然是一种新型的针对 ROP 的保护机制,作为 pwner 这下不得不了解了。
Intel CET
Intel CET(Control Flow Enforcement Technology / 控制流加固技术)是近年刚兴起的技术,其中包含 Shadow Stack(SHSTK)和 IBT(Indirect Branch Tracking),Intel Tiger Lake CPU 及以上才支持。下面是我个人对这两个技术粗略不严谨的理解:
Shadow Stack 是程序运行时在不同虚拟内存段中同时维护两个调用栈,其中一个是“影子栈”,只存储返回地址。CPU 在函数调用与返回过程将同时操作两个栈,如果在函数返回时发现正常调用栈的返回地址与影子栈中的不同就产生段错误。这样一来只通过栈缓冲区溢出等方式修改栈上函数返回值以劫持控制流的方式就失效了,几乎所有 ROP / JOP 也都将失效。
Indirect Branch Tracking 是 CPU 每当执行长跳转时都检查自己下一个指令是否为 endbr64 / 32,如果是就不执行任何操作(类似 nop
),如果不是就产生段错误。(实际实现是 CPU 内部维护一个状态机)
目前 Windows 只支持 Shadow Stack(Windows CFG,类似 Intel CET),还不支持 endbr 标记的 IBT;Linux 上也几乎没有支持,算是高度实验性的功能。接下来是在 Linux 上实验启用 CET 的具体操作,主要看 Shadow Stack。
验证 Intel CET 是否可用
首先需要内核启用 CET 特性,Linux 6.2 默认启用 IBT,Linux 6.6 正式合并 SHSTK。在命令行中验证启用状态:
$ sudo dmesg | grep CET
[ 0.111658] CET detected: Indirect Branch Tracking enabled
$ cat /proc/cpuinfo | grep shstk
flags : ... user_shstk ...
$ cat /proc/cpuinfo | grep ibt
flags : ... ibt ...
验证某程序是否在编译时启用 CET:
$ readelf -n <application> | grep -a SHSTK
Properties: x86 feature: IBT, SHSTK
$ readelf -n <application> | grep -a IBT
Properties: x86 feature: IBT, SHSTK
验证当前程序(cat)运行时是否已启用 Shadow Stack:(通常不会启用)
$ cat /proc/self/status | grep shstk
$ cat /proc/<pid>/status | grep shstk # pid 为 <pid> 的程序是否启用 Shadow Stack
GCC / glibc 环境启用 Intel CET SHSTK / IBT
我的环境:
System: Archlinux
Kernel: 6.11.6-zen1-1-zen
glibc: GNU C Library (GNU libc) stable release version 2.40
编译时
使用 gcc 编译时添加以下编译参数:
gcc -fcf-protection=full
运行时
目前 glibc 动态链接器 ld.so 默认不启用 Intel CET 特性(见源码),需要设置以下环境变量以强制启用:
GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK
[!NOTE]
将以上环境变量中
on
改为permissive
可以以更兼容的方式启用 CET(SHSTK / IBT),当程序本身或其动态链接库不支持 CET 特性时,动态链接器将自动关闭 CET。
效果
编写这样一个简单的 C 程序:
# File: test_cet.c
#include <stdio.h>
int main(void) {
puts("Hello, shadow stack!");
return 0;
}
编译运行:
$ gcc -fcf-protection=branch test_cet.c -o test_cet
$ GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK ./test_cet
Hello, shadow stack!
程序正常输出,但这并不是重点。接下来使用 gdb + pwndbg 调试验证 Shadow Stack 确实已启用:
$ gdb ./a.out
pwndbg> r
...
pwndbg> set environment GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK
pwndbg> start
...
pwndbg> vmmap
...
0x7ffff7400000 0x7ffff7c00000 rw-p 800000 0 [anon_7ffff7400]
...
pwndbg> b _IO_file_write
...
pwndbg> c
...
pwndbg> tele 0x7ffff7bff000 1000
00:0000│ 0x7ffff7bff000 ◂— 0
... ↓ 496 skipped
1f1:0f88│ 0x7ffff7bfff88 —▸ 0x7ffff7e99aac (sbrk+108) ◂— test eax, eax
1f2:0f90│ 0x7ffff7bfff90 —▸ 0x7ffff7e252e6 (__default_morecore+22) ◂— cmp rax, -1
1f3:0f98│ 0x7ffff7bfff98 —▸ 0x7ffff7e2622b (sysmalloc+1019) ◂— mov r10, qword ptr [rbp - 0x50]
1f4:0fa0│ 0x7ffff7bfffa0 —▸ 0x7ffff7e27435 (_int_malloc+3413) ◂— mov rcx, qword ptr [rbp - 0x38]
1f5:0fa8│ 0x7ffff7bfffa8 —▸ 0x7ffff7e27435 (_int_malloc+3413) ◂— mov rcx, qword ptr [rbp - 0x38]
1f6:0fb0│ 0x7ffff7bfffb0 —▸ 0x7ffff7e28002 (malloc+434) ◂— test rax, rax
1f7:0fb8│ 0x7ffff7bfffb8 —▸ 0x7ffff7e012fd (_IO_file_doallocate+173) ◂— mov eax, 1
1f8:0fc0│ 0x7ffff7bfffc0 —▸ 0x7ffff7e0d2dd (new_do_write+93) ◂— movzx edi, word ptr [rbx + 0x80]
1f9:0fc8│ 0x7ffff7bfffc8 —▸ 0x7ffff7e0e191 (_IO_do_write+33) ◂— cmp rbx, rax
1fa:0fd0│ 0x7ffff7bfffd0 —▸ 0x7ffff7e0e70b (_IO_file_overflow+283) ◂— cmp eax, -1
1fb:0fd8│ 0x7ffff7bfffd8 —▸ 0x7ffff7e03dba (puts+474) ◂— cmp eax, -1
1fc:0fe0│ 0x7ffff7bfffe0 —▸ 0x555555555160 (main+23) ◂— mov eax, 0
1fd:0fe8│ 0x7ffff7bfffe8 —▸ 0x7ffff7da8e08 (__libc_start_call_main+120) ◂— mov edi, eax
1fe:0ff0│ 0x7ffff7bffff0 —▸ 0x7ffff7da8ecc (__libc_start_main+140) ◂— mov r14, qword ptr [rip + 0x1c10a5]
1ff:0ff8│ 0x7ffff7bffff8 —▸ 0x555555555075 (_start+37) ◂— hlt
pwndbg> bt
#0 _IO_new_file_write (f=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=0x5555555592a0, n=18) at fileops.c:1174
#1 0x00007ffff7e0d2dd in new_do_write (fp=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=0x5555555592a0 "Hello, Intel CET!\n",
to_do=to_do@entry=18) at /usr/src/debug/glibc/glibc/libio/libioP.h:1030
#2 0x00007ffff7e0e191 in _IO_new_do_write (fp=fp@entry=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=<optimized out>, to_do=18) at fileops.c:426
#3 0x00007ffff7e0e70b in _IO_new_file_overflow (f=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, ch=10) at fileops.c:784
#4 0x00007ffff7e03dba in __GI__IO_puts (str=0x555555556004 "Hello, Intel CET!") at ioputs.c:41
#5 0x0000555555555160 in main ()
#6 0x00007ffff7da8e08 in __libc_start_call_main (main=main@entry=0x555555555149 <main>, argc=argc@entry=1,
argv=argv@entry=0x7fffffffe2f8) at ../sysdeps/nptl/libc_start_call_main.h:58
#7 0x00007ffff7da8ecc in __libc_start_main_impl (main=0x555555555149 <main>, argc=1, argv=0x7fffffffe2f8, init=<optimized out>,
fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffe2e8) at ../csu/libc-start.c:360
#8 0x0000555555555075 in _start ()
可以看到程序运行时多出一个超大的内存段,这就是 Shadow Stack。断点一个 libc 调用层级较深的函数,可以看到 Shadow Stack 中存储了逐层函数返回地址(调用栈)。
CET 默认不启用确实有道理,在我自己电脑上测试,全局设置环境变量(on
)后连 gdb 都打不开了... 整个系统也变得极不稳定。
Fatal signal: 段错误
----- Backtrace -----
0x5ef6d4be903e ???
0x5ef6d4d107c0 ???
0x5ef6d4d109bb ???
0x746656c4c1cf ???
0x7466508bf125 ???
---------------------
A fatal error internal to GDB has been detected, further
debugging is not possible. GDB will now terminate.
This is a bug, please report it. For instructions, see:
<https://www.gnu.org/software/gdb/bugs/>.
fish: Job 1, 'GLIBC_TUNABLES=glibc.cpu.x86_sh…' terminated by signal SIGSEGV (Address boundary error)
用户态启用 SHSTK 底层实现
Linux 用户态动态开关 Shadow Stack 主要依赖 arch_prctl
系统调用:
arch_prctl(ARCH_SHSTK_ENABLE, unsigned long feature)
启用‘feature’指定的特性。一次只能操作一个特性。
arch_prctl(ARCH_SHSTK_DISABLE, unsigned long feature)
关闭‘feature’指定的特性。一次只能操作一个特性。
arch_prctl(ARCH_SHSTK_LOCK, unsigned long features)
锁定特性的启用或关闭状态。‘features’ 是所有需要锁定的特性的遮罩(按位或)。没有按位或设定的特性的锁定状态不变,之后无法再启用或关闭已锁定的特性。
arch_prctl(ARCH_SHSTK_UNLOCK, unsigned long features)
锁定特性。‘features’ 是所有需要解锁的特性的遮罩。没有按位或设定的特性的锁定状态不变。只能在 ptrace 时使用。
arch_prctl(ARCH_SHSTK_STATUS, unsigned long addr)
将当前启用的特性拷贝到 addr 地址处。特性启用状态描述方式同上述用法中传入的 ‘features’。
可指定的特性:
ARCH_SHSTK_SHSTK - Shadow stack
ARCH_SHSTK_WRSS - WRSS
Glibc 动态链接器 ld.so 中有关启用 SHSTK 的源码(sysdeps/unix/sysv/linux/x86_64/dl-cet.h
):
/* 用宏启用 shadow stack 以避免调用栈下溢。(刚启用时 shadow stack 为空,此时若返回将触发保护。)*/
#define ENABLE_X86_CET(cet_feature) \
if ((cet_feature & GNU_PROPERTY_X86_FEATURE_1_SHSTK)) \
{ \
long long int kernel_feature = ARCH_SHSTK_SHSTK; \
INTERNAL_SYSCALL_CALL (arch_prctl, ARCH_SHSTK_ENABLE, \
kernel_feature); \
}
#define X86_STRINGIFY_1(x) #x
#define X86_STRINGIFY(x) X86_STRINGIFY_1 (x)
/* 如果已在 GL(dl_x86_feature_1) 启用,则在调用 _dl_init 前启用 shadow stack。调用 _dl_setup_x86_features 以初始化 shadow stack。*/
#define RTLD_START_ENABLE_X86_FEATURES \
"\
# 检查 shadow stack 是否已在 GL(dl_x86_feature_1) 启用。\n\
movl _rtld_local+" X86_STRINGIFY (RTLD_GLOBAL_DL_X86_FEATURE_1_OFFSET) "(%rip), %edx\n\
testl $" X86_STRINGIFY (X86_FEATURE_1_SHSTK) ", %edx\n\
jz 1f\n\
# 如果在 GL(dl_x86_feature_1) 中启用,启用 shadow stack。\n\
movl $" X86_STRINGIFY (ARCH_SHSTK_SHSTK) ", %esi\n\
movl $" X86_STRINGIFY (ARCH_SHSTK_ENABLE) ", %edi\n\
movl $" X86_STRINGIFY (__NR_arch_prctl) ", %eax\n\
syscall\n\
1:\n\
# 将 GL(dl_x86_feature_1) 传参给 _dl_cet_setup_features。\n\
movl %edx, %edi\n\
# 为调用 _dl_cet_setup_features 对齐栈指针。\n\
andq $-16, %rsp\n\
call _dl_cet_setup_features\n\
# 从 %r12 和 %r13 恢复 %rax 和 %rsp。\n\
movq %r12, %rax\n\
movq %r13, %rsp\n\
"
所以其实可以这样手动在程序中使用 shadow stack 而无需依赖 glibc ld:
#include <stdio.h>
#define ENABLE_SHSTK \
asm("mov $0x5001, %rdi;" \
"mov $0x1, %rsi;" \
"mov $158, %rax;" \
"syscall;");
#define DISABLE_SHSTK \
asm("mov $0x5002, %rdi;" \
"mov $0x1, %rsi;" \
"mov $158, %rax;" \
"syscall;");
int main(void) {
ENABLE_SHSTK
puts("hello, world!");
DISABLE_SHSTK
return 0;
}
SHSTK 内核底层实现
SHSTK 的 arch_prctl 最后会转到 shstk_prctl
,SHSTK 的主要逻辑就在 arch/x86/kernel/shstk.c
。这里没什么好分析的,因为 SHSTK 本身由硬件实现,内核只负责检查传入参数、管理 shadow stack 内存页、设置相关寄存器等。
在申请 shadow stack 内存页 do_mmap
时,flags
参数传入了一个新的标志 VM_SHADOW_STACK
,这样 mmap 会额外申请一个保护页(guard page),这个页一旦被读写就会触发段错误。Shadow stack 调用栈指针(ssp
)移动方式有 push
、pop
和 incssp
指令,其中 incssp
指令移动 ssp
后会读其相邻内存,且一次最多只能移动 2040 字节。加入 guard page 后可以防止程序通过 incssp
指令将 ssp
移动到非法内存区。
SHSTK 启用后的攻击思路
Shadow Stack 启用后,传统的 ROP 等技术失效,但并非无法做到类似链式 gadgets 的任意代码执行。可以采用一种叫“伪面向对象编程”(Counterfeit Object-Oriented Programming / COOP)的新技术,其基本思路是在类似 C++ 的面向对象语言中通过修改虚函数表为 vfgadgets 来达到类似 ROP 的效果。vfgadget 分为 Main Loop Gadget(将其他 gadgets 串联起来)、Argument Loader Gadget(类似 ROP 中的 pop rdi; ret;
)、Invoker Gadget(类似 ROP 中的 system
)、Collector Gadget(存储 Invoker 的返回值)。
搜寻 vfgadgets 的工具(类似 ropper
):https://github.com/x86-512/VXpp
COOP 详解:https://www.offsec.com/blog/bypassing-intel-cet-with-counterfeit-objects/
实际用了下,只有在较大型的程序里可能凑齐 vfgadgets,不容易实现攻击。