RISC-V Linux 影子栈(zicfiss)详解:基于硬件 CFI 的函数返回地址保护实现与用户态接口
【免费下载链接】linux Linux kernel source tree 项目地址: https://gitcode.com/GitHub_Trending/li/linux
导读
本文以 Linux 内核 Documentation/arch/riscv/zicfiss.rst 为核心,系统讲解 RISC-V zicfiss(Shadow Stack,影子栈)扩展如何为 Linux 用户态程序提供硬件级的函数返回地址保护。文章覆盖影子栈的 PTE 编码与访问语义、sspush/sspopchk 指令行为、ELF 属性标记、动态加载器与 prctl() 的启用流程、SIGSEGV/SEGV_CPERR 违规处理、影子栈 token 与信号处理中的影子栈切换机制,并结合本仓库(Linux kernel source tree)中 arch/riscv/kernel/usercfi.c、include/uapi/linux/prctl.h、arch/riscv/kernel/signal.c 等源码逐层印证内核实现细节。读完本文,你将掌握 RISC-V 影子栈的完整技术原理、内核为用户态提供的确切 ABI,以及在自己的工具链与运行时中启用、校验和锁定影子栈的实战方法。
1. 特性概述:为什么需要影子栈
内存破坏类漏洞通常以崩溃收场,但在具备创造力的攻击者手中,它们可能演变成一系列安全危害。其中一类典型危害是代码复用攻击(code-reuse attack):攻击者利用栈上可被篡改的返回地址,将它们串联成返回导向编程(ROP)链,从而劫持程序的控制流,破坏程序的控制流完整性(CFI,Control Flow Integrity)。
问题的根源在于:返回地址存放在可读可写的常规栈(read-write memory)中,因此容易遭到破坏,一旦被改写,攻击者就能间接控制程序计数器(PC)。RISC-V 的 zicfiss 扩展为此提供了专门的硬件方案——影子栈(shadow stack):编译器在函数序言(prologue)中将返回地址同时保存到影子栈,函数尾声(epilogue)再从影子栈取回并校验,使返回地址的写入不再依赖常规栈这种可被任意改写的存储介质。
zicfiss 扩展对体系结构做了如下三方面改动(对应 Documentation/arch/riscv/zicfiss.rst):
- 影子栈内存的 PTE 编码:第一级地址转换中一个此前保留的编码
PTE.R=0, PTE.W=1, PTE.X=0被定义为影子栈页面的页表项编码。普通指令对该页面的读写行为与常规内存不同(详见第 2 节)。 sspush x1/x5指令:将寄存器x1/x5(即ra/t0,返回地址的载体)压入(存储到)影子栈。sspopchk x1/x5指令:从影子栈弹出(加载)一个值与x1/x5比较,若不相等,CPU 会抛出software check exception,且*tval = 3。
编译器工具链负责保证:函数序言中除常规栈外,还执行 sspush x1/x5 保存返回地址;函数尾声执行 ld x5, offset(x2) 从常规栈装载返回地址后,紧跟 sspopchk x5,确保常规栈中弹出的值与影子栈弹出的值一致。一旦常规栈的返回地址被篡改,sspopchk 的比较就会失败并触发异常,攻击者精心构造的 ROP 链将无法在函数返回时拿到合法目标地址。
1.1 内核中的能力位与命令行开关
从源码看,内核在 arch/riscv/kernel/usercfi.c 中通过 is_user_shstk_enabled() 判断用户态影子栈是否可用,条件有二:CPU 必须支持影子栈(cpu_supports_shadow_stack()),且启动命令行未通过 riscv_nousercfi= 禁用 bcfi(backward CFI,即影子栈对应的返回地址保护):
bool is_user_shstk_enabled(void)
{
return (cpu_supports_shadow_stack() &&
!(riscv_nousercfi & CMDLINE_DISABLE_RISCV_USERCFI_BCFI));
}
同一文件的 setup_global_riscv_enable()(arch/riscv/kernel/usercfi.c)解析 riscv_nousercfi= 内核命令行参数,支持取值 all(全部禁用)、fcfi(仅禁用正向 CFI/landing pad)、bcfi(仅禁用影子栈)。这为在没有 zicfiss 硬件的平台上调试或回退提供了便利。
2. 影子栈保护与 Linux 内存管理器
影子栈页面具有特殊的页表编码(PTE.R=0, PTE.W=1, PTE.X=0),并配套了专属的访问指令,因此内核与硬件为其定义了以下访问语义:
- 普通 store 到影子栈内存会触发 store access fault:这保护影子栈内存免受杂散写入(stray writes)的破坏,普通数据写指令无法向影子栈写入内容。
- 普通 load 从影子栈内存读取是允许的:这使得栈回溯工具(stack trace utilities)或 backtrace 函数能够读取真实的调用栈,并确认其未被篡改。换言之,影子栈是"可读不可写"的。
- 只有影子栈指令才能产生影子栈 load 或影子栈 store:普通指令无法以影子栈语义访问该内存。
- 影子栈 load/store 作用于只读内存时,会触发 AMO/store page fault:因此
sspush x1/x5与sspopchk x1/x5都会在该场景下触发 AMO/store page fault。这一性质极大简化了内核在fork()期间的**写时复制(COW)**处理:内核可以像对待普通读写内存一样,把影子栈页面转换为只读内存;当用户态随后执行sspush或sspopchk时,内核按需执行 COW。从内核实现看,arch/riscv/kernel/usercfi.c 通过vm_mmap_shadow_stack()分配影子栈映射,并在 arch/riscv/kernel/process.c(见 arch/riscv/kernel/usercfi.c 的shstk_alloc_thread_stack())中处理fork/clone/clone3时的影子栈分配逻辑。 - 影子栈 load/store 作用于读写或读写执行内存时,会触发 access fault:这是致命条件(fatal condition),因为影子栈指令绝不应该操作可读写或可读写可执行的内存。
这些语义组合在一起,构成了影子栈"专用、单向、可审计"的访问模型:普通数据流无法污染它,而调试与审计工具可以读取它以验证返回地址链的完整性。
3. ELF 与 psABI:编译产物的标记方式
为了让动态加载器判断一个共享对象是否具备影子栈支持,工具链需要在产物中记录相应的属性。按 psABI 约定,编译器会在目标文件的 notes 段中,为 GNU_PROPERTY_RISCV_FEATURE_1_AND 属性设置 GNU_PROPERTY_RISCV_FEATURE_1_BCFI 宏(见 Documentation/arch/riscv/zicfiss.rst 第 3 节)。
这意味着:"是否用 -march 开启了 zicfiss 并编译"这一信息被固化在 ELF 属性中。加载器、链接器与内核均可以读取该属性来决定后续行为。由于影子栈对返回地址进行硬件级校验,只有全部依赖对象都带 BCFI 属性,程序的影子栈保护才是自洽的——这正是第 4 节中由动态加载器统一启用影子栈的原因。
4. Linux 内核的启用机制:交给动态加载器
用户态程序通常会在地址空间中加载多个共享对象(shared objects)。要确保所有依赖都已用影子栈支持编译,是件困难的工作;任何一个不带 zicfiss 支持的库都会在它执行 sspopchk 时引发异常。因此,内核设计上把"是否启用影子栈"的决策交给动态加载器(dynamic loader):
- 加载器在完成所有对象的加载、并确认全部对象都具备影子栈支持后,通过
prctl()为用户程序开启影子栈; - 若后续通过
dlopen动态加载了一个未以zicfiss编译的对象,加载器需要再次通过prctl()关闭(或保持关闭)影子栈,以避免不兼容的代码在不具备影子栈语义的环境下运行。
从实现角度看,内核在 arch/riscv/kernel/usercfi.c 的 arch_set_shadow_stack_status() 中实现了"启用即分配影子栈"的逻辑:当请求启用且任务尚未启用时,内核通过 calc_shstk_size() 计算影子栈大小(见 arch/riscv/kernel/usercfi.c:默认取 RLIMIT_STACK / 8 与 512 MiB 的较小值,并按页对齐),随后分配影子栈、设置基址与活动指针;当请求禁用时,通过 shstk_release() 释放影子栈映射。
5. prctl() 接口:用户态开关影子栈
Linux 为影子栈的管理新增了三个与架构无关的 prctl(在 include/uapi/linux/prctl.h 中定义):
| prctl | 编号 | 说明 |
|---|---|---|
PR_GET_SHADOW_STACK_STATUS | 74 | 获取当前线程的影子栈配置(即通过 PR_SET_SHADOW_STACK_STATUS 配置的值) |
PR_SET_SHADOW_STACK_STATUS | 75 | 设置当前影子栈配置;启用时将为该线程分配一个影子栈 |
PR_LOCK_SHADOW_STACK_STATUS | 76 | 锁定指定的影子栈配置,阻止后续修改(包括未定义位) |
相关的状态位在 include/uapi/linux/prctl.h 中定义:
# define PR_SHADOW_STACK_ENABLE (1UL << 0)
# define PR_SHADOW_STACK_WRITE (1UL << 1)
# define PR_SHADOW_STACK_PUSH (1UL << 2)
这三个 prctl 是架构无关的(architecture-agnostic),在未实现的架构上返回 -EINVAL。具体语义如下。
5.1 prctl(PR_SET_SHADOW_STACK_STATUS, unsigned long arg)
- 若
arg = PR_SHADOW_STACK_ENABLE且 CPU 支持zicfiss,内核为任务启用影子栈。动态加载器在确认地址空间中所有已加载对象都支持影子栈后,可发出该 prctl。 - 若随后
dlopen了未以zicfiss编译的对象,动态加载器可以以arg = 0(即PR_SHADOW_STACK_ENABLE被清除)再次调用该 prctl 来关闭影子栈。
内核侧实现(arch/riscv/kernel/usercfi.c)会:拒绝未知标志(status & ~PR_SHADOW_STACK_SUPPORTED_STATUS_MASK 返回 -EINVAL)、拒绝已锁定任务的修改、处理启用/禁用与影子栈的分配/释放,最终通过 set_shstk_status()(arch/riscv/kernel/usercfi.c)更新任务的 envcfg 寄存器(置位/清除 ENVCFG_SSE,定义于 arch/riscv/include/asm/csr.h),从而在硬件层面打开或关闭影子栈执行。
5.2 prctl(PR_GET_SHADOW_STACK_STATUS, unsigned long * arg)
返回当前任务的间接分支跟踪状态(文档原文如此表述,实际即影子栈启用状态);若已启用,返回 PR_SHADOW_STACK_ENABLE。对应实现为 arch/riscv/kernel/usercfi.c 的 arch_get_shadow_stack_status(),它把 PR_SHADOW_STACK_ENABLE 位通过 copy_to_user 写回用户指针。
5.3 prctl(PR_LOCK_SHADOW_STACK_STATUS, unsigned long arg)
锁定当前任务影子栈的启用状态。追求严格安全姿态的用户空间可能不希望后续加载无 zicfiss 支持的对象,此时可使用该 prctl 禁止在当前任务上关闭影子栈。实现(arch/riscv/kernel/usercfi.c)要求任务已启用影子栈且 arg == 0,然后通过 set_shstk_lock() 置位 ubcfi_locked;此后 arch_set_shadow_stack_status() 会因 is_shstk_locked(t) 直接返回 -EINVAL。
5.4 map_shadow_stack 系统调用
除 prctl 外,内核还提供 map_shadow_stack 系统调用(编号 453,见 include/uapi/asm-generic/unistd.h),实现于 arch/riscv/kernel/usercfi.c:用户态可显式指定地址与大小分配影子栈,flags 支持 SHADOW_STACK_SET_TOKEN(在基址处创建 token)。从 arch/riscv/kernel/sys_riscv.c 的注释可知,zicfiss 的 CSR_SSP(地址 0x011,定义于 arch/riscv/include/asm/csr.h)是用户态可写的 CSR,用户态理论上可自行在分配后建立 token;但为与其他架构的 map_shadow_stack 保持可移植性,RISC-V 仍遵循"flags 中带 token 标志时在基址设置 token"的约定。
6. 影子栈启用后的返回违规处理
影子栈启用后,与返回相关的违规由硬件与内核共同处理:
- CPU 在执行
sspopchk x1/x5时,若x1/x5与影子栈栈顶值不匹配,则设置*tval = 3并抛出software check exception(见 Documentation/arch/riscv/zicfiss.rst 第 5 节)。 - Linux 内核将该异常视为
SIGSEGV,且siginfo的si_code = SEGV_CPERR,随后走常规的信号投递流程。
这意味着被检测到的 ROP/返回地址篡改会以标准的 SIGSEGV(SEGV_CPERR)形式送达用户态,应用程序可以通过信号处理器捕获它进行诊断或安全响应;而默认行为则是进程终止。
7. 影子栈 token:防 pivoting 的关键机制
普通 store 无法写入影子栈,因此影子栈内容不会因杂散写入被篡改。但攻击者仍有一种"栈切换"(pivoting)手段:直接写 CSR CSR_SSP 来改变程序当前活动的影子栈。正常情况下,CSR_SSP 的写入应仅限于上下文切换、栈展开(stack unwinds)、longjmp 或 Go/Rust 等语言中的协程/绿色线程上下文切换等场景。一旦攻击者利用内存破坏漏洞,借助上下文切换例程把 CSR_SSP pivot 到任意影子栈,就可能在受保护的模式下执行攻击代码。
影子栈 token(shadow stack token) 正是为缓解此问题而设计的校验机制,其约定如下:
- 切出时:软件离开一个影子栈前,应把当前影子栈指针(SSP)本身保存到影子栈上——这个保存的值即为
shadow stack token; - 切入时:软件切换到另一个影子栈时,应从该影子栈指针处读出 token,并校验该 token 是指向影子栈自身的指针(即 token 值 = 影子栈内保存它的位置 + 一个槽位大小,验证其"自指"性质);
- 校验通过后:软件才执行对
CSR_SSP的写操作完成切换。
这里的"软件"既可以是用户态任务运行时本身(管理单线程内的多个上下文),也可以是内核:内核在向用户任务投递信号、需要保存影子栈指针时,采用同样的流程,在用户任务自身的影子栈上保存 token。这样一来,每次 sigreturn 时内核都会读取并验证 token,然后才切换到相应影子栈。该机制有效阻止了攻击者仅凭 sigreturn 任意切换影子栈的企图——攻击者除了调用 sigreturn,还必须保证存在一个有效的 shadow stack token。
7.1 内核中的 token 创建与校验实现
内核在 arch/riscv/kernel/usercfi.c 中实现了 token 的创建与恢复:
create_rstor_token():要求 SSP 按SHSTK_ENTRY_SIZE(即sizeof(void *))对齐,使用ssamoswap.d指令(见 arch/riscv/kernel/usercfi.c 的amo_user_shstk(),以内联汇编 +.option arch, +zicfiss的方式访问影子栈)在ssp - SHSTK_ENTRY_SIZE处写入值为ssp的 token。注意:由于普通 store 被禁止,内核特意使用ssamoswap这一影子栈指令来完成影子栈写入。restore_user_shstk():读出 token 后,校验(token - shstk_ptr) == SHSTK_ENTRY_SIZE,即 token 必须指向它自己所在位置的下一个槽位(自指校验);校验失败会打印受限速率的内核日志(bad restore token)并返回-EINVAL,成功则更新活动影子栈指针。
8. 信号影子栈:sigcontext 中的 CFI 状态
为了在信号处理过程中正确保存与恢复影子栈,RISC-V 在 sigcontext 中新增了如下结构(见 Documentation/arch/riscv/zicfiss.rst 第 7 节,UAPI 定义位于 arch/riscv/include/uapi/asm/ptrace.h):
struct __sc_riscv_cfi_state {
unsigned long ss_ptr; /* shadow stack pointer */
};
信号投递(signal delivery)时的完整流程为:
- 影子栈 token 被保存在当前影子栈自身之上(复用现有的影子栈分配,不额外分配新影子栈);
- 更新后的影子栈指针被保存到
sigcontext下__sc_riscv_cfi_state.ss_ptr字段; - 在
sigreturn时,内核从sigcontext获取ss_ptr,验证影子栈上保存的 token,然后切换(恢复)影子栈。
对应内核实现位于 arch/riscv/kernel/signal.c:
- 信号保存侧(约 arch/riscv/kernel/signal.c):若任务启用了影子栈(
is_shstk_enabled(current)),调用save_user_shstk()在影子栈上创建 token 并把新的ss_ptr写入__sc_riscv_cfi_state; - 信号恢复侧(约 arch/riscv/kernel/signal.c):从
sigcontext读回ss_ptr,调用restore_user_shstk()校验 token 并恢复活动影子栈。
这条链路确保信号处理程序无论嵌套多深,sigreturn 后都能回到被中断时那个合法、未被篡改的影子栈上下文,从机制上杜绝了通过信号机制对影子栈的任意劫持。
9. 影子栈的线程/进程生命周期管理
影子栈的生命周期与任务的 fork/clone/exec/exit 紧密相关,内核在 arch/riscv/kernel/usercfi.c 中做了细致处理:
fork/clone3且CLONE_VM:子线程需要独立的影子栈,shstk_alloc_thread_stack()会按args->stack_size(或默认值)为其分配新的影子栈;CLONE_VFORK:子进程共享父进程的影子栈(将 base/size 置 0 作为特殊标记),释放逻辑会跳过它,避免误释放父进程的资源;- 非
CLONE_VM:子进程使用父进程影子栈的 COW 副本(对应第 2 节的只读转换 + 按需 COW 机制); exit或失败路径:shstk_release()仅在任务与current共享mm且影子栈 base 非 0 时执行vm_munmap()释放。
影子栈的默认大小策略(arch/riscv/kernel/usercfi.c)体现了设计上的取舍:影子栈只保存返回地址而不保存任何变量,因此 512 MiB 上限对绝大多数应用已绰绰有余:
static unsigned long calc_shstk_size(unsigned long size)
{
if (size)
return PAGE_ALIGN(size);
return PAGE_ALIGN(min(rlimit(RLIMIT_STACK) / 8, SZ_512M));
}
10. 硬件能力检测与配套文档
zicfiss 属于 RISC-V 用户态 CFI(UCFI)体系,其硬件能力可通过 riscv_hwprobe 系统调用查询:RISCV_HWPROBE_EXT_ZICFISS 位定义于 arch/riscv/include/uapi/asm/hwprobe.h,内核在 arch/riscv/kernel/sys_hwprobe.c 中报告该扩展。与之配套的还有正向 CFI(间接跳转保护)扩展 zicfilp,其文档位于 Documentation/arch/riscv/zicfilp.rst,两者共同构成完整的 RISC-V 控制流完整性方案;zicfiss 内核实现的辅助定义(CSR_SSP、ENVCFG_SSE 等)位于 arch/riscv/include/asm/csr.h 与 arch/riscv/include/asm/usercfi.h,架构使能入口见 arch/riscv/Kconfig。
结语
RISC-V zicfiss 影子栈以"专用 PTE 编码 + 专属指令 + 内核 token/COW 管理"的组合拳,为用户态函数返回地址提供了硬件级保护,有效阻断 ROP 类代码复用攻击。在内核侧,Linux 通过 prctl 三件套(PR_SET/GET/LOCK_SHADOW_STACK_STATUS)、map_shadow_stack 系统调用、信号处理中的 __sc_riscv_cfi_state 与 token 机制,以及完善的 fork/clone 生命周期管理,构建了一套自洽且可审计的用户态 CFI 支撑框架。对于内核开发者与安全研究人员,理解上述 ABI 与源码实现,是安全地在自己平台上启用、评测或扩展 RISC-V 影子栈能力的第一步。
【免费下载链接】linux Linux kernel source tree 项目地址: https://gitcode.com/GitHub_Trending/li/linux
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



