15. 控制流强制技术 (CET) 影子堆栈

15.1. CET 背景

控制流强制技术 (CET) 涵盖了多个相关的 x86 处理器功能,这些功能提供针对控制流劫持攻击的保护。CET 可以保护应用程序和内核。

CET 引入了影子堆栈和间接分支跟踪 (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 注释中标记,可以从 readelf/llvm-readelf 输出中验证。

readelf -n <application> | grep -a SHSTK
    properties: x86 feature: SHSTK

内核不直接处理这些应用程序标记。应用程序或加载器必须使用第 4 节中描述的接口启用 CET 功能。通常这会在动态加载器或静态运行时对象中完成,就像 GLIBC 中一样。

15.4. 启用 arch_prctl()

Elf 功能应由加载器使用下面的 arch_prctl 启用。它们仅在 64 位用户应用程序中受支持。它们在每个线程的基础上操作这些功能。启用状态在克隆时继承,因此如果该功能在第一个线程上启用,它将传播到应用程序中的所有线程。

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 只能通过影子堆栈启用,如果禁用影子堆栈,它会自动禁用。

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 不是 RO 且脏时,影子访问会触发页面错误,并在页面错误错误代码中设置影子堆栈访问位。

当任务 fork 一个子进程时,其影子堆栈 PTE 会被复制,并且父进程和子进程的影子堆栈 PTE 都会清除脏位。在下次影子堆栈访问时,生成的影子堆栈页面错误由页面复制/重用处理。

当创建 pthread 子进程时,内核会为新线程分配一个新的影子堆栈。新的影子堆栈创建在 ASLR 行为方面类似于 mmap()。类似地,在线程退出时,线程的影子堆栈会被禁用。

15.6.4. Exec

在 exec 时,影子堆栈功能由内核禁用。此时,用户空间可以选择重新启用或锁定它们。