15. 控制流强制技术 (CET) 影子栈¶
15.1. CET 背景¶
控制流强制技术 (CET) 涵盖了几个相关的 x86 处理器特性,可提供针对控制流劫持攻击的保护。CET 可以保护应用程序和内核。
CET 引入了影子栈 (shadow stack) 和间接分支跟踪 (IBT)。影子栈是从内存中分配的辅助栈,不能被应用程序直接修改。当执行 CALL 指令时,处理器会将返回地址同时压入正常栈和影子栈。在函数返回时,处理器会弹出影子栈中的副本并将其与正常栈中的副本进行比较。如果两者不同,处理器会引发控制保护故障。IBT 验证间接 CALL/JMP 目标是否为编译器通过 ‘ENDBR’ 操作码所标记的预期目标。并非所有 CPU 都同时具备影子栈和间接分支跟踪功能。目前在 64 位内核中,仅支持用户空间影子栈和内核 IBT。
15.2. 使用影子栈的要求¶
要使用用户空间影子栈,你需要支持它的硬件、配置了该功能的内核以及使用该功能编译的用户空间库。
内核 Kconfig 选项是 X86_USER_SHADOW_STACK。当编译进内核后,可以通过内核参数 nousershstk 在运行时禁用影子栈。
要构建启用用户影子栈的内核,需要 Binutils v2.29 或 LLVM v6 及更高版本。
在运行时,如果处理器支持 CET,/proc/cpuinfo 会显示 CET 特性。“user_shstk” 意味着当前的内核和硬件支持用户空间影子栈。
15.3. 应用程序启用¶
应用程序的 CET 功能在其 ELF note 中进行标记,可以通过 readelf/llvm-readelf 的输出进行验证
readelf -n <application> | grep -a SHSTK
properties: x86 feature: SHSTK
内核不直接处理这些应用程序标记。应用程序或加载器必须使用第 4 节中描述的接口来启用 CET 特性。通常这会在动态加载器或静态运行时对象中完成,GLIBC 就是这种情况。
15.4. 启用 arch_prctl()¶
ELF 特性应该由加载器使用以下的 arch_prctl() 来启用。它们仅在 64 位用户应用程序中受支持。这些操作以逐线程(per-thread)的方式对特性进行操作。启用状态在 clone 时继承,因此如果在第一个线程上启用了该特性,它将传播到应用程序中的所有线程。
- 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”是要锁定的所有特性的掩码。所有设置的位都会被处理,未设置的位会被忽略。该掩码与现有值进行 OR 运算。因此,在此处设置的任何特性位在此之后都无法再被启用或禁用。
- arch_prctl(ARCH_SHSTK_UNLOCK, unsigned long features)
解锁特性。“features”是要解锁的所有特性的掩码。所有设置的位都会被处理,未设置的位会被忽略。仅能通过 ptrace 起作用。
- arch_prctl(ARCH_SHSTK_STATUS, unsigned long addr)
将当前启用的特性复制到传给 addr 的地址中。这些特性的描述方式与传给其他函数中 ‘features’ 的位相同。
返回值如下。成功时返回 0。出错时,errno 可以是
-EPERM if any of the passed feature are locked.
-ENOTSUPP if the feature is not supported by the hardware or
kernel.
-EINVAL arguments (non existing feature, etc)
-EFAULT if could not copy information back to userspace
支持的特性位有
ARCH_SHSTK_SHSTK - Shadow stack
ARCH_SHSTK_WRSS - WRSS
目前通过此接口支持影子栈和 WRSS。WRSS 只能与影子栈一起启用,如果影子栈被禁用,WRSS 会自动禁用。
15.5. Proc 状态¶
要检查应用程序是否真正在使用影子栈运行,用户可以读取 /proc/$PID/status。它将根据启用的功能报告 “wrss” 或 “shstk”。这些行看起来像这样
x86_Thread_features: shstk wrss
x86_Thread_features_locked: shstk wrss
15.6. 影子栈的实现¶
15.6.1. 影子栈大小¶
任务的影子栈从内存中分配,其固定大小为 MIN(RLIMIT_STACK, 4 GB)。换句话说,影子栈被分配为正常栈的最大大小,但上限为 4 GB。在 clone3 系统调用的情况下,会传入一个栈大小,影子栈会使用该大小而不是 rlimit。
15.6.2. 信号¶
主程序及其信号处理函数使用相同的影子栈。由于影子栈仅存储返回地址,因此一个较大的影子栈可以覆盖程序栈和信号备用栈同时耗尽的情况。
当发生信号时,旧的信号前状态会被压入栈中。当启用影子栈时,影子栈特定的状态会被压入影子栈。目前这仅包含旧的 SSP(影子栈指针),它以设置了第 63 位的特殊格式压入。在 sigreturn 时,该旧的 SSP 令牌会由内核进行验证和恢复。内核还会将正常的恢复器地址压入影子栈,以帮助用户空间避免在经过恢复器的 sigreturn 路径上发生影子栈违规。
因此,影子栈信号帧格式如下
|1...old SSP| - Pointer to old pre-signal ssp in sigframe token format
(bit 63 set to 1)
| ...| - Other state may be added in the future
影子栈进程中不支持 32 位 ABI 信号。当启用影子栈时,Linux 通过在 32 位地址空间之外分配影子栈来阻止 32 位执行。当执行进入 32 位模式时(无论是通过远调用还是返回到用户空间),硬件会产生一个 #GP,该故障将作为段错误传递给进程。当转换到用户空间时,寄存器的状态将如同正要返回的用户空间 IP 引发了该段错误一样。
15.6.3. Fork¶
影子栈的 vma 设置了 VM_SHADOW_STACK 标志;其 PTE 被要求是只读且脏的。当影子栈 PTE 不是只读且脏的时,影子访问会触发页错误,并在页错误错误码中设置影子栈访问位。
当任务 fork 一个子任务时,其影子栈 PTE 被复制,并且父进程和子进程的影子栈 PTE 的脏位都会被清除。在下一次影子栈访问时,由此产生的影子栈页错误将通过页复制/重用来处理。
创建 pthread 子线程时,内核会为新线程分配一个新的影子栈。在 ASLR 行为方面,新影子栈的创建表现得类似于 mmap()。类似地,在线程退出时,该线程的影子栈会被禁用。
15.6.4. Exec¶
在 exec 时,影子栈特性会被内核禁用。此时,用户空间可以选择重新启用或锁定它们。