RISC-V Linux 影子栈(zicfiss)详解:基于硬件 CFI 的函数返回地址保护实现与用户态接口

RISC-V Linux 影子栈(zicfiss)详解:基于硬件 CFI 的函数返回地址保护实现与用户态接口

【免费下载链接】linux Linux kernel source tree 【免费下载链接】linux 项目地址: 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.cinclude/uapi/linux/prctl.harch/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/x5sspopchk x1/x5 都会在该场景下触发 AMO/store page fault。这一性质极大简化了内核在 fork() 期间的**写时复制(COW)**处理:内核可以像对待普通读写内存一样,把影子栈页面转换为只读内存;当用户态随后执行 sspushsspopchk 时,内核按需执行 COW。从内核实现看,arch/riscv/kernel/usercfi.c 通过 vm_mmap_shadow_stack() 分配影子栈映射,并在 arch/riscv/kernel/process.c(见 arch/riscv/kernel/usercfi.cshstk_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.carch_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_STATUS74获取当前线程的影子栈配置(即通过 PR_SET_SHADOW_STACK_STATUS 配置的值)
PR_SET_SHADOW_STACK_STATUS75设置当前影子栈配置;启用时将为该线程分配一个影子栈
PR_LOCK_SHADOW_STACK_STATUS76锁定指定的影子栈配置,阻止后续修改(包括未定义位)

相关的状态位在 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.carch_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 的注释可知,zicfissCSR_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,且 siginfosi_code = SEGV_CPERR,随后走常规的信号投递流程。

这意味着被检测到的 ROP/返回地址篡改会以标准的 SIGSEGVSEGV_CPERR)形式送达用户态,应用程序可以通过信号处理器捕获它进行诊断或安全响应;而默认行为则是进程终止。

7. 影子栈 token:防 pivoting 的关键机制

普通 store 无法写入影子栈,因此影子栈内容不会因杂散写入被篡改。但攻击者仍有一种"栈切换"(pivoting)手段:直接写 CSR CSR_SSP 来改变程序当前活动的影子栈。正常情况下,CSR_SSP 的写入应仅限于上下文切换、栈展开(stack unwinds)、longjmp 或 Go/Rust 等语言中的协程/绿色线程上下文切换等场景。一旦攻击者利用内存破坏漏洞,借助上下文切换例程把 CSR_SSP pivot 到任意影子栈,就可能在受保护的模式下执行攻击代码。

影子栈 token(shadow stack token) 正是为缓解此问题而设计的校验机制,其约定如下:

  1. 切出时:软件离开一个影子栈前,应把当前影子栈指针(SSP)本身保存到影子栈上——这个保存的值即为 shadow stack token
  2. 切入时:软件切换到另一个影子栈时,应从该影子栈指针处读出 token,并校验该 token 是指向影子栈自身的指针(即 token 值 = 影子栈内保存它的位置 + 一个槽位大小,验证其"自指"性质);
  3. 校验通过后:软件才执行对 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.camo_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)时的完整流程为:

  1. 影子栈 token 被保存在当前影子栈自身之上(复用现有的影子栈分配,不额外分配新影子栈);
  2. 更新后的影子栈指针被保存到 sigcontext__sc_riscv_cfi_state.ss_ptr 字段;
  3. 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/clone3CLONE_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_SSPENVCFG_SSE 等)位于 arch/riscv/include/asm/csr.harch/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 【免费下载链接】linux 项目地址: https://gitcode.com/GitHub_Trending/li/linux

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值