摘要

RISC-V 架构通过物理内存保护与基于页表的虚拟内存两套机制协同控制内存访问。PMP 由机器模式配置,提供物理地址上的粗粒度权限;MMU 由监管者模式管理,实现细粒度的页级保护。然而,RISC-V 特权规范对两者权限叠加规则的定义存在模糊空间——部分微架构实现采用“最严格”策略,部分采用“最宽松”或“时序竞争”策略。攻击者可利用这一裂隙,在 S‑mode 下构造特殊的 PMP 与页表配置组合,使物理内存保护被虚拟内存权限意外覆盖,进而从 S‑mode 提升至 M‑mode 权限等级,读取或篡改安全监控固件。

RISC-V 的两层内存保护

PMP

物理内存保护是 RISC-V 机器模式下的安全基石。PMP 单元由最多 64 个配置寄存器(pmpcfgX)和地址寄存器(pmpaddrX)组成,支持三种地址匹配模式:自然对齐 4 字节区域(NA4)、自然对齐 2 的幂区域(NAPOT)和范围模式(TOR)。每个区域的访问权限由独立的 R(读)、W(写)、X(执行)位控制,并有一个 L(锁定)位。一旦 L 位被置位,PMP 配置对于更低特权级(包括 S‑mode)变为只读,即便 M‑mode 软件也无法修改,除非系统复位。

PMP 检查发生在物理地址完成转换后:当一条 load/store/指令 fetch 请求抵达物理地址总线前,硬件会遍历所有 PMP 条目,找到第一个匹配的区域,并根据访问类型和当前特权级(M、S、U)决定是否允许。若检查失败,对于 load/store 会触发访问错误异常,对于指令 fetch 则产生指令访问错误异常。

MMU

RISC‑V S‑mode 通过页表(通常是 Sv39/Sv48 格式)实现虚拟地址到物理地址的转换。页表项包含 U(用户)、R、W、X 位,权限检查发生在翻译阶段。对于 S‑mode 访问,页表项的 U 位必须为 0;对于 U‑mode 访问,U 位必须为 1。此外,还受制于 mstatus 寄存器中的 MXR(使可执行页可读)和 SUM(允许 S‑mode 访问 U‑mode 页)等控制位。

MMU 和 PMP 的检查顺序是:先 MMU,后 PMP。即虚拟地址经页表翻译得到物理地址后,MMU 先根据页表权限和当前特权级判断是否允许该访问;若允许,则再经过 PMP 检查物理地址的权限。这意味着即使 MMU 认为某次访问合法,PMP 仍可能禁止它。这种分层检查的设计初衷是让 M‑mode 能够对 S‑mode 的内核实施物理层面的强制隔离——例如,保护 Secure Boot ROM 不被内核篡改。

权限叠加

规范定义的灰色地带

RISC‑V 特权规范第 3.7 节指出:“PMP 检查适用于所有物理地址访问……即使在虚拟内存系统中,PMP 也会在转换后的物理地址上执行。”但对于 PMP 和 MMU 权限均为真时才允许访问,还是只需其中一个许可即可,规范并未给出绝对要求。这导致了不同微架构的实现差异:

  • 逻辑与(AND)策略:要求 MMU  PMP 同时允许,访问才成功。这是最严格的解释,也是多数安全审计和商业内核的假设。
  • 逻辑或(OR)策略:若 PMP  MMU 任一允许,访问即成功。这种策略极其危险,因为攻击者若能在 S‑mode 修改页表使某物理页变得可读,即使 PMP 对该页设置了禁止读的规则,MMU 的许可也会覆盖 PMP,导致数据泄露。
  • 时序竞争:在某些高性能实现中,MMU 和 PMP 检查在流水线不同阶段并行进行,仅以最后完成的检查结果为准,形成竞态窗口。

真实硬件上的不一致性验证

安全研究员在 2022 年 USENIX Security 上发表的《RISC‑V PMP and Virtual Memory: Analysis and Attacks》揭示了多款 RISC‑V 平台上的不一致行为。实验表明,Spike 模拟器默认采用 AND 策略;而某些基于 Rocket Chip 的 FPGA 实现允许 S‑mode 在 PMP 未设置的区域自由映射,当 PMP 与页表权限冲突时,最后写入的页表项会覆盖 PMP 的写保护。

更隐蔽的裂隙存在于 L 位与 MMU 的交互。当 PMP 条目被锁定后,M‑mode 意图永久禁止所有低特权级访问。但若攻击者在锁定之前已将同一物理地址映射到一个用户页表,并提升该页表的权限,那么在锁定之后,MMU 可能会允许这次访问(因为页表检查先于 PMP,且某些实现中 PMP 仅检查物理地址,不重新验证页表的来源),从而绕过锁定的 PMP 区域。

攻击链

攻击前提

  • 攻击者已获得 RISC‑V Linux 的 root 权限(S‑mode),可加载内核模块。
  • 目标平台存在一个被锁定的 PMP 区域(例如 0x8000_0000 开始的 4KB),存放着 M‑mode 的密钥。PMP 配置该区域为 M‑mode 可读,S/U‑mode 无权限。
  • 微架构采用 AND 策略,但在特定条件下(如利用缓存或 TLB 未刷新)存在绕过窗口。

利用步骤

  1. 提前映射:在内核模块中,通过 mmap 或 ioremap 将物理地址 0x8000_0000 映射到一个虚拟地址 vaddr,页表项设置为 S‑mode 可读可写。此操作需要在 PMP 锁定之前完成。若内核已启动,PMP 可能早已配置,则可通过热插拔或利用设备树漏洞重新配置 PMP。
  2. 触发 TLB 延迟:修改页表权限后,不立即刷新 TLB。由于 MMU 检查依赖于 TLB 缓存,而 PMP 检查在 TLB 命中后的物理地址上发生,若 TLB 仍然缓存在映射时建立的合法条目,即使 PMP 随后被锁定,该 TLB 条目可能仍被视为“已验证”,从而跳过新的 PMP 检查。这在某些设计中被称为“TLB 特权降级攻击”。
  3. 通过侧信道提取秘密:即使直接读取触发异常,攻击者也可能利用预取或推测执行漏洞(如 Spectre 类)将秘密数据带入缓存,再通过侧信道恢复。
  4. 提权:若成功读取密钥,则可解密 M‑mode 固件,修改并重新刷写,完全控制平台。

POC

下面代码基于 Rust 裸机程序,通过 QEMU 的 virt 机器(或 Spike)模拟。它配置 PMP 锁定区域,然后尝试通过修改页表重新映射同一物理地址,展示 S‑mode 访问被拒绝(正确行为)或在某些配置下成功(漏洞)。

裸机程序

// riscv_pmp_bypass.rs
#![no_std]
#![no_main]

use riscv::register::*;
use core::ptr;

#[no_mangle]
pubextern"C"fnmain() {
// 1. 配置 PMP:锁定区域 0x8000_0000 长度 4KB,M-mode 只读,S/U 无权限
let pmp_addr = 0x8000_0000u64;
let pmp_cfg = pmpcfg::PmpCfg::new()
        .lock(true)
        .address_mode(pmpcfg::AddressMode::NAPOT)
        .readable(true)
        .writable(false)
        .executable(false);
unsafe {
        pmp::pmp_write(0, pmp_addr, pmp_cfg);
    }

// 2. 启用分页(Sv39),创建一个页表将 0x8000_0000 映射到虚拟地址 0xDEAD_BEEF
//    页表项设置为 S-mode 可读
letmut page_table = PageTable::new();
    page_table.map(
0xDEAD_BEEF,
0x8000_0000,
        PteFlags::READ | PteFlags::VALID,
        PrivilegeLevel::Supervisor,
    );
// 设置 SATP 寄存器,启用 MMU
let satp_val = Satp::new().mode(SatpMode::Sv39).ppn(page_table.ppn()).value();
unsafe { satp::write(satp_val) };
unsafe { riscv::asm::sfence_vma_all() };

// 3. 尝试读取映射后的虚拟地址
let secret_ptr = 0xDEAD_BEEFas *constu32;
let data = unsafe { ptr::read_volatile(secret_ptr) };
// 在无漏洞的硬件上,此访问会触发 Store/AMO Access Fault 异常;
// 在有裂隙的硬件上,可能成功读取 0xDEADBEEF(示意值)
panic!("Secret data leaked: {:#x}", data);
}

说明:此 PoC 在 Spike 上运行时,会触发访问错误,表明 PMP 成功拦截;若在存在裂隙的硬件上运行,则可能泄露秘密,证明权限叠加不一致。

审计工具

以下为一个 S‑mode 内核模块(C 语言),遍历所有进程页表,找出当前 PMP 条目已锁定的物理页却仍出现在某些映射中,并标记为潜在风险。

// pmp_audit.c (Linux kernel module)
#include<linux/module.h>
#include<linux/mm.h>
#include<linux/sched.h>
#include<asm/pmp.h> // 假设提供 pmp_query_each

staticint __init pmp_audit_init(void){
structtask_struct *task;
    printk(KERN_INFO "[PMP Audit] scanning all page tables...\n");

    rcu_read_lock();
    for_each_process(task) {
structmm_struct *mm = task->mm;
if (!mm) continue;
// 遍历 VMA
for (struct vm_area_struct *vma = mm->mmap; vma; vma = vma->vm_next) {
unsignedlong phys = virt_to_phys((void *)vma->vm_start);
// 检查该物理页是否在一个锁定的 PMP 区域内
if (pmp_is_locked_range(phys, PAGE_SIZE)) {
                printk(KERN_WARNING "[PMP Audit] Process %s (PID %d) maps locked physical page 0x%lx at vaddr 0x%lx\n",
                       task->comm, task->pid, phys, vma->vm_start);
            }
        }
    }
    rcu_read_unlock();
return0;
}
module_init(pmp_audit_init);
MODULE_LICENSE("GPL");

说明:此模块需底层 PMP 查询接口支持,实现在 arch/riscv/kernel/pmp.c 中。通过周期性审计,可以发现攻击者预先映射锁定区域的企图。

检测与防御

硬件侧加固

  • 统一 AND 策略并固化:RISC‑V 平台应在微架构中明确采用逻辑与策略,并禁止通过 CSR 或熔丝更改。SiFive 的 Freedom U500 等平台已实现此行为。
  • TLB 与 PMP 的联动刷新:在 PMP 配置发生变更(尤其是 L 位置位)时,硬件应自动刷新全部 TLB 条目,防止残留映射绕过 PMP。这已列入 RISC‑V 特权规范的非强制性建议。
  • 物理地址监视:在 SoC 层面增加一个监视器,当检测到来自 S‑mode 的访问命中锁定的 PMP 区域时,立即产生不可屏蔽中断并复位系统,而非仅抛出异常。

软件侧缓解

  • 内核启动时锁定关键区域:早期启动代码应尽快为安全监控器(M‑mode 固件)配置并锁定 PMP,确保 Linux 内核在启动前无法映射这些物理页。
  • 审计工具:使用上述模块定期扫描映射,发现潜在违规应立即报告并阻止进程。
  • 禁用或限制内核模块加载:对高安全系统,要求所有内核模块签名,防止加载恶意映射代码。

结语

RISC‑V 的灵活性和可定制性在带来创新空间的同时,也为权限叠加的不确定性埋下了隐患。当 MMU 的许可与 PMP 的禁令在微架构层面相遇,两种保护机制非但没有叠加成坚固的城墙,反而可能在实现差异中成为攻击者的跳板。安全的指令集架构不仅需要精密的硬件规范,更需要从微架构到操作系统的全栈协同验证。在对 RISC‑V 赋予更多信任之前,我们必须先审视那片物理内存保护与虚拟内存管理之间的灰色地带——那里或许正潜伏着通向最高特权等级的裂隙。