用于保护 RISC-V Linux 上函数返回的影子栈¶
本文简要介绍了 Linux 为 RISC-V 上的用户态应用程序启用影子栈而向用户空间提供的接口。
1. 功能概述¶
内存损坏问题通常会导致崩溃。然而,在富有创造力的攻击者手中,这些问题可能导致各种安全漏洞。
其中一些安全问题可能是针对程序的代码重用攻击,攻击者可以利用堆栈中损坏的返回地址,将其链接在一起来执行面向返回的编程(ROP),从而破坏程序的控制流完整性(CFI)。
返回地址存放在可读写内存的堆栈中。因此,它们极易受到损坏,这使得攻击者能够控制程序计数器。在 RISC-V 上,zicfiss 扩展提供了一个备用栈(即“影子栈”),可以在函数序言中将返回地址安全地放置在该栈上,并在尾声中检索出来。zicfiss 扩展进行了以下更改
影子栈虚拟内存的 PTE 编码:第一阶段翻译中先前保留的编码,即 PTE.R=0、PTE.W=1、PTE.X=0,成为影子栈页面的 PTE 编码。
sspush x1/x5指令将x1/x5压入(存储到)影子栈。sspopchk x1/x5指令从影子栈弹出(加载)并与x1/x5进行比较,如果不相等,CPU 会抛出一个带有*tval = 3的software check exception
编译器工具链确保函数序言除了常规栈之外,还包含 sspush x1/x5 以将返回地址保存到影子栈上。类似地,函数尾声包含 ld x5, offset(x2),随后是 sspopchk x5,以确保从常规栈弹出的值与从影子栈弹出的值相匹配。
2. 影子栈保护与 Linux 内存管理器¶
如前所述,影子栈获得了具有某些特殊属性的新页表编码,以及对影子栈进行操作的指令
对影子栈内存的常规存储操作会引发存储访问故障。这可以保护影子栈内存免受杂散写入的影响。
允许对影子栈内存进行常规加载操作。这允许堆栈跟踪实用程序或回溯函数读取真正的调用栈并确保其未被篡改。
只有影子栈指令才能生成影子栈加载或影子栈存储。
在只读内存上进行影子栈加载和存储会引发 AMO/存储页故障。因此,
sspush x1/x5和sspopchk x1/x5都会引发 AMO/存储页故障。这简化了内核在 fork() 期间对 COW 的处理。内核可以将影子栈页面转换为只读内存(就像对待常规可读写内存一样)。一旦遇到用户空间中后续的sspush或sspopchk指令,内核就可以执行 COW。在可读写或可读写执行内存上进行影子栈加载和存储会引发访问故障。这是一种致命情况,因为影子栈加载和存储绝不应该在可读写或可读写执行内存上运行。
3. ELF 和 psABI¶
工具链在目标文件的 notes 节中为属性 GNU_PROPERTY_RISCV_FEATURE_1_AND 设置了 GNU_PROPERTY_RISCV_FEATURE_1_BCFI。
4. Linux 启用¶
用户空间程序的地址空间中可能加载了多个共享对象。要确保所有依赖项都已编译并支持影子栈是一项困难的任务。因此,为程序启用影子栈的工作交给了动态加载器。
5. prctl() 启用¶
添加了 PR_SET_SHADOW_STACK_STATUS / PR_GET_SHADOW_STACK_STATUS / PR_LOCK_SHADOW_STACK_STATUS 这三个 prctl 来管理任务的影子栈启用。这些 prctl 与架构无关,如果未实现则返回 -EINVAL。
prctl(PR_SET_SHADOW_STACK_STATUS, unsigned long arg)
如果 arg = PR_SHADOW_STACK_ENABLE 并且 CPU 支持 zicfiss,则内核将为该任务启用影子栈。一旦动态加载器确定地址空间中加载的所有对象都支持影子栈,就可以发出此 prctl。此外,如果对未用 zicfiss 编译的对象执行了 dlopen,动态加载器可以发出将 arg 设置为 0 的 prctl(即清除 PR_SHADOW_STACK_ENABLE)
prctl(PR_GET_SHADOW_STACK_STATUS, unsigned long * arg)
返回间接分支跟踪的当前状态。如果已启用,它将返回 PR_SHADOW_STACK_ENABLE。
prctl(PR_LOCK_SHADOW_STACK_STATUS, unsigned long arg)
锁定任务上影子栈启用的当前状态。用户空间可能希望以严格的安全姿态运行,并且不希望加载不支持 zicfiss 的对象。在这种情况下,用户空间可以使用此 prctl 来禁止在当前任务上禁用影子栈。
6. 影子栈令牌¶
不允许对影子栈进行常规存储操作,因此无法通过任意杂散写入对其进行篡改。然而,转向/切换到影子栈的一种方法是简单地写入 CSR CSR_SSP。这将改变程序的活动影子栈。程序中对 CSR_SSP 的写入大多应限于上下文切换、栈展开、或者 Go 和 Rust 等语言中的 longjmp 或类似机制(如绿色线程的上下文切换)。CSR_SSP 写入可能会带来问题,因为攻击者可以利用内存损坏漏洞并借助上下文切换例程转向任何影子栈。影子栈令牌可以通过确保以下几点来帮助缓解此问题
当软件切换离开某个影子栈时,该影子栈指针应保存在影子栈本身上(这被称为
shadow stack token)。当软件切换到某个影子栈时,它应该从影子栈指针读取
shadow stack token,并验证该shadow stack token本身就是指向该影子栈本身的指针。一旦完成令牌验证,软件就可以执行对
CSR_SSP的写入以切换影子栈。
这里“软件”可以指用户态任务运行时本身,它作为单个线程的一部分管理各种上下文。或者“软件”可以指内核,当内核必须向用户任务传递信号并必须保存影子栈指针时。内核可以通过在用户态任务的影子栈上保存令牌来执行类似的过程。通过这种方式,每当发生 sigreturn 时,内核都可以读取并验证令牌,然后切换到影子栈。利用这种机制,内核帮助用户任务,使得攻击者无法通过任意使用 sigreturn 来利用用户任务中的任何损坏问题。攻击者除了调用 sigreturn 之外,还必须确保存在有效的 shadow stack token。
7. 信号影子栈¶
已为 RISC-V 的 sigcontext 添加了以下结构
struct __sc_riscv_cfi_state {
unsigned long ss_ptr;
};
作为信号传递的一部分,影子栈令牌保存在当前影子栈本身上。更新后的指针保存在 sigcontext 下 __sc_riscv_cfi_state 中的 ss_ptr 字段中。现有的影子栈分配用于信号传递。在 sigreturn 期间,内核将从 sigcontext 中获取 ss_ptr,验证影子栈上保存的令牌,并切换影子栈。