Linux x86 CET 影子栈(Shadow Stack):从内核配置到 arch_prctl 实战详解
【免费下载链接】linuxLinux kernel source tree项目地址: https://gitcode.com/GitHub_Trending/li/linux
CET(Control-flow Enforcement Technology)是 x86 处理器面向控制流劫持攻击(如 ROP)的硬件防护机制,其中影子栈是用户态防护的核心。本文以 Linux 内核文档 shstk.rst 为主线,完整梳理影子栈的原理、启用条件、arch_prctl()接口、信号/克隆/执行等生命周期行为,并结合 arch/x86/kernel/shstk.c 等源码揭示内核在幕后实际做了什么,帮助你在 64 位 x86 系统上正确启用并验证影子栈保护。
CET 背景:影子栈与 IBT
CET 涵盖一组相关的 x86 处理器特性,用于保护应用和内核免受控制流劫持攻击。它引入两项关键机制:
- 影子栈(Shadow Stack):一个由处理器从内存中分配的第二栈区,应用无法直接修改。执行
CALL指令时,处理器把返回地址同时压入普通栈和影子栈;函数返回时,处理器弹出影子栈中的副本与普通栈副本比对,两者不一致则触发控制保护故障(control-protection fault)。 - 间接分支跟踪(IBT):验证间接
CALL/JMP的目标确实是编译器用ENDBR操作数标记过的合法目标。
需要注意两点前提(与 shstk.rst 一致):并非所有 CPU 都同时支持影子栈和 IBT;当前 64 位内核中,仅支持用户态影子栈和内核 IBT(即内核本身使用影子栈、用户态使用 IBT 的组合不在当前支持范围内)。
Kconfig 中的说明也印证了这一点:arch/x86/Kconfig 中X86_USER_SHADOW_STACK的 help 文本指出,影子栈保护是检测函数返回地址被破坏的硬件特性,有助于缓解 ROP 攻击;应用必须显式启用才能获得保护,旧的用户态"不会免费获得"防护;且支持影子栈的 CPU 最早于 2020 年发布。
启用影子栈的前提条件
使用用户态影子栈需要三者齐备:
- 硬件支持:CPU 支持 CET 影子栈;
- 内核配置:编译时打开 Kconfig 选项
X86_USER_SHADOW_STACK。从源码结构看,该选项还depends on AS_WRUSS、depends on X86_64、depends on PER_VMA_LOCK,并select X86_CET(见 arch/x86/Kconfig); - 用户态库支持:用户态库(如 glibc)编译时启用 CET 相关特性。
运行时可以通过内核启动参数nousershstk整体禁用影子栈。其实现位于 arch/x86/kernel/cpu/common.c:
if (cmdline_find_option_bool(boot_command_line, "nousershstk")) setup_clear_cpu_cap(X86_FEATURE_USER_SHSTK);即启动参数会清掉USER_SHSTK这个 CPU 特性位,之后arch_prctl启用路径将返回-ENOTSUPP。
工具链方面,要构建支持影子栈的内核,需要Binutils v2.29或LLVM v6及以上版本(WRUSS汇编指令需要较新的汇编器支持)。
运行时验证:/proc/cpuinfo
若处理器支持 CET,/proc/cpuinfo会显示对应特性;其中user_shstk表示当前内核与硬件组合下支持用户态影子栈。这个特性位的定义在 arch/x86/include/asm/cpufeatures.h:
#define X86_FEATURE_USER_SHSTK (11*32+23) /* "user_shstk" Shadow stack support for user mode applications */特性位只有在硬件SHSTK特性存在且CONFIG_X86_USER_SHADOW_STACK打开时才会被设置,见 setup_cet():
user_shstk = cpu_feature_enabled(X86_FEATURE_SHSTK) && IS_ENABLED(CONFIG_X86_USER_SHADOW_STACK); ... if (user_shstk) set_cpu_cap(c, X86_FEATURE_USER_SHSTK);通过 hardening 配置快速启用
如果希望开箱即用地开启包括影子栈在内的一批安全加固选项,可以直接启用 arch/x86/configs/hardening.config,其中包含:
CONFIG_X86_USER_SHADOW_STACK=y应用如何标记 CET 能力(ELF note)
编译时启用了 CET 特性的应用,其 ELF 文件会带有相应的 note,可用readelf或llvm-readelf验证:
readelf -n <application> | grep -a SHSTK properties: x86 feature: SHSTK关键事实:内核并不直接解析这些 ELF 标记。应用或加载器必须通过下一节的arch_prctl()接口显式启用 CET 特性;在 glibc 等动态链接器中,这一步通常发生在动态加载器或静态运行时对象内部。
arch_prctl() 启用接口详解
ELF 特性应由加载器通过如下arch_prctl()启用。这些操作仅在 64 位用户应用中受支持,按线程粒度生效;启用状态在clone时继承,因此只要第一个线程启用了该特性,它就会传播到应用的所有线程。
接口定义在 arch/x86/include/uapi/asm/prctl.h:
#define ARCH_SHSTK_ENABLE 0x5001 #define ARCH_SHSTK_DISABLE 0x5002 #define ARCH_SHSTK_LOCK 0x5003 #define ARCH_SHSTK_UNLOCK 0x5004 #define ARCH_SHSTK_STATUS 0x5005 #define ARCH_SHSTK_SHSTK (1ULL << 0) #define ARCH_SHSTK_WRSS (1ULL << 1)各接口语义如下:
| 接口 | 参数含义 | 行为 |
|---|---|---|
arch_prctl(ARCH_SHSTK_ENABLE, feature) | feature为单个特性 | 启用该特性,一次只能操作一个特性 |
arch_prctl(ARCH_SHSTK_DISABLE, feature) | feature为单个特性 | 禁用该特性,一次只能操作一个特性 |
arch_prctl(ARCH_SHSTK_LOCK, features) | features为特性掩码 | 把特性锁定在当前启用/禁用状态。掩码中所有置位都会被处理,未置位被忽略;该掩码与既有值做 OR。锁定后对应的特性位不能再被启用或禁用 |
arch_prctl(ARCH_SHSTK_UNLOCK, features) | features为特性掩码 | 解锁特性。仅能通过 ptrace 对其它线程操作(用于CONFIG_CHECKPOINT_RESTORE场景) |
arch_prctl(ARCH_SHSTK_STATUS, addr) | addr为存放结果的地址 | 把当前已启用的特性位复制到addr指向的用户空间地址 |
返回值为 0 表示成功;失败时 errno 为:
-EPERM:请求修改的特性已被锁定;-ENOTSUPP:特性不被硬件或内核支持;-EINVAL:参数非法(如不存在的特性位、一次传入多个特性等);-EFAULT:无法把信息复制回用户空间。
当前该接口支持的两个特性位是ARCH_SHSTK_SHSTK(影子栈)和ARCH_SHSTK_WRSS(WRSS,即"允许用户态回写影子栈"特性)。WRSS 只能在影子栈启用时才可启用,且影子栈被禁用时 WRSS 会自动禁用——这与源码中wrss_control()的行为一致:若ARCH_SHSTK_SHSTK未启用,则返回-EPERM(见 arch/x86/kernel/shstk.c);shstk_disable()在禁用影子栈时会把MSR_IA32_U_CET清零,同时清掉 WRSS(见 shstk.c)。
内核侧 prctl 分发的源码视角
上述语义的最终实现集中在 shstk_prctl():
long shstk_prctl(struct task_struct *task, int option, unsigned long arg2) { unsigned long features = arg2; if (option == ARCH_SHSTK_STATUS) { return put_user(task->thread.features, (unsigned long __user *)arg2); } if (option == ARCH_SHSTK_LOCK) { task->thread.features_locked |= features; return 0; } /* Only allow via ptrace */ if (task != current) { if (option == ARCH_SHSTK_UNLOCK && IS_ENABLED(CONFIG_CHECKPOINT_RESTORE)) { task->thread.features_locked &= ~features; return 0; } return -EINVAL; } /* Do not allow to change locked features */ if (features & task->thread.features_locked) return -EPERM; /* Only support enabling/disabling one feature at a time. */ if (hweight_long(features) > 1) return -EINVAL; ... }几个值得注意的实现细节:
- 锁定检查先于启停操作:
features & task->thread.features_locked时直接返回-EPERM,这正是文档中-EPERM错误来源; - 一次只允许一个特性:
hweight_long(features) > 1时返回-EINVAL,对应"一次只能操作一个特性"的约束; - UNLOCK 仅限 ptrace:对非当前线程(即被 ptrace 附加的线程)只放行
ARCH_SHSTK_UNLOCK,且要求编译时打开CONFIG_CHECKPOINT_RESTORE; - 特性状态实际保存在
task_struct->thread.features中,结构定义见 arch/x86/include/asm/shstk.h(struct thread_shstk记录每个线程影子栈的base和size)。
通过 /proc/$PID/status 验证运行状态
要确认某应用是否真的在影子栈保护下运行,可以读/proc/$PID/status,其中会根据实际启用情况报告wrss或shstk:
x86_Thread_features: shstk wrss x86_Thread_features_locked: shstk wrss输出逻辑在 arch/x86/kernel/cpu/proc.c,直接遍历thread.features和thread.features_locked两个位域并拼出字符串。
影子栈的内核实现
影子栈大小分配
一个任务的影子栈从内存分配为固定大小MIN(RLIMIT_STACK, 4 GB):即按普通栈的最大尺寸分配,但上限 4 GB。使用clone3系统调用时,由于传入了显式的栈大小,影子栈会以该值替代 rlimit。对应源码是 adjust_shstk_size():
static unsigned long adjust_shstk_size(unsigned long size) { if (size) return PAGE_ALIGN(size); return PAGE_ALIGN(min_t(unsigned long long, rlimit(RLIMIT_STACK), SZ_4G)); }此外有两个从源码结构中可以确认的实现约束:
- 影子栈总是分配在4 GB 以上地址空间(
MAP_ABOVE4G),从而把影子栈挡在 32 位地址空间之外,这是后文 32 位 ABI 信号不支持的基础; VM_SHADOW_STACK的 vma 带有守护页(guard page)。注释解释了原因:INCSSP指令最多能把 SSP 移动 2040 字节,且移动前后会读取"被弹出"的元素,因此一个页的间隙就足以防止单次操作把 SSP 挪到相邻栈上(见 alloc_shstk() 上方注释)。
启用影子栈的完整动作在 shstk_setup() 中:分配 vma 后,把MSR_IA32_PL3_SSP写为影子栈顶地址(addr + size,影子栈向下增长),并在MSR_IA32_U_CET中置CET_SHSTK_EN位;这两个 MSR 操作在 FPU 寄存器锁内完成,保证不会在上下文切换窗口中被打断。
信号路径
主程序与信号处理函数共用同一个影子栈。由于影子栈只存储返回地址,一个足够大的影子栈可以覆盖"程序栈与信号替代栈都耗尽"的情形。
信号发生时的处理:
- 信号前的旧状态被压入普通栈;
- 影子栈相关的状态被压入影子栈。目前这项状态只有旧的 SSP,以一个特殊格式压入——最高位(bit 63)置 1;
sigreturn时,内核校验并恢复这个旧 SSP 令牌;- 内核还会把普通 restorer 地址压入影子栈,帮助用户态避免经过 restorer 的 sigreturn 路径上触发影子栈违例。
影子栈信号帧格式(与文档一致):
|1...old SSP| - 指向信号前旧 ssp 的指针,sigframe 令牌格式 (bit 63 置 1) | ...| - 未来可能添加其它状态源码印证:put_shstk_data()在写入时强制data | SHSTK_DATA_BIT(bit 63 置位),防止信号帧令牌被当成返回地址处理(shstk.c);信号投递时setup_signal_shadow_stack()先压入旧 SSP 令牌、再压入 restorer 地址,最后更新MSR_IA32_PL3_SSP(shstk.c);sigreturn时restore_signal_shadow_stack()弹出令牌并校验,其校验包括:令牌所在地址必须落在VM_SHADOW_STACK的 VMA 内、恢复后的 SSP 必须 8 字节对齐且小于TASK_SIZE_MAX(shstk_pop_sigframe())。
32 位 ABI 信号在影子栈进程中不受支持。Linux 的做法是把影子栈分配在 32 位地址空间之外,从而阻止 32 位执行:一旦通过远调用(far call)或返回用户态进入 32 位模式,硬件会触发#GP,以段错误(segfault)投递给进程,且寄存器状态表现为"被返回的用户态 IP 引发了该段错误"。
Fork 与线程
影子栈的 vma 带有VM_SHADOW_STACK标志,其 PTE 要求为只读且脏(read-only and dirty)。当某个影子栈 PTE 不再是 RO+dirty 时,影子栈访问会触发一个页面错误码中带有影子栈访问位的缺页异常。
- fork():任务 fork 子进程时,影子栈的 PTE 被复制,且父、子双方的影子栈 PTE 都清除脏位;下次影子栈访问时,产生的影子栈缺页按缺页复制/复用的常规路径处理(copy-on-write 语义);
- pthread(带 CLONE_VM 的 clone):内核为新线程分配一个全新的影子栈,新建影子栈在 ASLR 行为上类似
mmap();线程退出时其影子栈被禁用并释放。
源码层面,shstk_alloc_thread_stack() 体现了三种分支:影子栈未启用则直接跳过;CLONE_VFORK子进程与父进程共享同一影子栈(shstk->base清零以免释放逻辑误操作);不带CLONE_VM的子进程沿用父进程的影子栈副本(靠 COW 保护);只有共享地址空间的新线程才会走alloc_shstk()分配独立影子栈。释放逻辑在 shstk_free(),并特别处理了 fork(CLONE_VM) 失败后子进程退出时释放影子栈、以及防止二次释放(WARN_ON(!shstk->size))的场景。
Exec
exec时内核会禁用影子栈特性,此后用户态可以自行选择重新启用或锁定。对应实现是 reset_thread_features():
void reset_thread_features(void) { memset(¤t->thread.shstk, 0, sizeof(struct thread_shstk)); current->thread.features = 0; current->thread.features_locked = 0; }清空了thread_shstk及特性位/锁定位,因此新映像的加载器(如 glibc)需要重新走一遍arch_prctl(ARCH_SHSTK_ENABLE, ...)流程。
小结:启用影子栈的完整检查清单
- 内核编译开启
CONFIG_X86_USER_SHADOW_STACK(可用 arch/x86/configs/hardening.config 一并启用),工具链满足 Binutils ≥ 2.29 或 LLVM ≥ 6; - 确认
cat /proc/cpuinfo中出现user_shstk(若启动参数带了nousershstk则该位不会出现); - 确认应用 ELF 带有 CET note:
readelf -n <app> | grep -a SHSTK; - 由动态加载器(如 glibc)调用
arch_prctl(ARCH_SHSTK_ENABLE, ARCH_SHSTK_SHSTK)及按需的ARCH_SHSTK_WRSS,并可配合ARCH_SHSTK_LOCK锁定; - 运行时通过
/proc/$PID/status中的x86_Thread_features行确认shstk/wrss实际生效。
核心实现全部集中在 arch/x86/kernel/shstk.c(约 630 行),接口声明在 arch/x86/include/asm/shstk.h,UAPI 常量在 arch/x86/include/uapi/asm/prctl.h;进一步阅读建议结合 shstk.rst 原文与上述源码交叉对照。
【免费下载链接】linuxLinux kernel source tree项目地址: https://gitcode.com/GitHub_Trending/li/linux
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考