第四章 RISC-V新兴架构漏洞利用:指令集特点、堆溢出与JOP分析

RISC-V 这几年在安全圈里越来越热。我最早接触它是在一个 IoT 设备的固件分析项目里,当时第一反应是「这指令集怎么这么精简?」后来打 CTF 比赛,RISC-V 题目也开始频繁出现。说白了,它跟 x86 和 ARM 的思路不太一样,你如果拿老经验硬套,很容易翻车。

这一章我们就来聊聊 RISC-V 的漏洞利用。我会从指令集特点讲起,然后深入到堆溢出和 JOP 的实战技巧。嗯,内容有点密,但都是干货。

4.1 RISC-V 指令集特点与利用视角

RISC-V 是个开放指令集架构,没有历史包袱。它的核心设计哲学就是「精简」。这对我们做漏洞利用的人来说,意味着两件事:一是攻击面更清晰,二是 gadget 的寻找方式要重新适应。

关键差异点:

  • 寄存器数量多: 32 个通用寄存器(x0–x31),x0 恒为 0。这比 x86 的 8 个通用寄存器富裕得多,ROP/JOP 时 gadget 的上下文更灵活。
  • 指令长度固定: 基础指令集是 32 位定长,扩展指令集(如压缩指令集 RVC)是 16 位。定长指令让 gadget 扫描更规整,但也意味着指令对齐更严格。
  • 没有标志寄存器: RISC-V 没有 EFLAGS 或 CPSR,条件跳转直接比较寄存器。这改变了条件分支 gadget 的构造方式。
  • 函数调用约定: 参数通过 a0–a7 传递,返回值在 a0。栈帧布局与 x86/ARM 有差异,影响栈迁移和堆布局。

我个人习惯在分析 RISC-V 固件时,先看一眼 .text 段的指令密度。如果压缩指令集(RVC)启用,那 gadget 的地址可能不是 4 字节对齐的,扫描时要注意。

小技巧: 用 objdump 反汇编时加 -M no-aliases 选项,可以避免伪指令干扰,看到最原始的指令编码。这对 gadget 搜索很有帮助。

4.2 RISC-V 堆溢出利用实战

堆溢出在 RISC-V 上的利用思路跟其他架构类似,但有几个坑。我曾经在一个 RISC-V 的嵌入式系统里调试堆溢出,发现 glibc 的堆管理代码在移植时做了简化,很多保护机制(如 tcache 的 key 检查)被去掉了。你想想看,这相当于把保险柜的门虚掩着。

4.2.1 堆布局与 chunk 结构

RISC-V 上的堆实现通常基于 glibc 或 newlib。以 glibc 为例,chunk 结构跟 x86 基本一致,但要注意指针宽度是 32 位还是 64 位,取决于目标平台。

字段 偏移(32位) 偏移(64位) 说明
prev_size 0 0 前一个 chunk 的大小(如果前一个空闲)
size 4 8 当前 chunk 大小,低 3 位是标志位
fd 8 16 双向链表前向指针(仅空闲 chunk)
bk 12 24 双向链表后向指针(仅空闲 chunk)

堆溢出的经典手法——覆盖下一个 chunk 的 size 和 fd/bk 指针——在 RISC-V 上完全适用。但要注意,RISC-V 是小端序,跟 x86 一样,所以字节序不是问题。

4.2.2 利用步骤:unlink 攻击

我们用一个实际案例来演示。假设目标程序有一个堆缓冲区溢出漏洞,可以溢出覆盖相邻 chunk 的元数据。目标是实现任意地址写。

  1. 构造 fake chunk: 在可控的堆数据区伪造一个 chunk,设置好 size、fd 和 bk 指针。fd 指向 fake chunk 自身减去 12(32位)或 24(64位),bk 指向 fake chunk 自身减去 8(32位)或 16(64位)。
  2. 触发 unlink: 通过溢出修改下一个 chunk 的 size 字段,清除 PREV_INUSE 标志,让 glibc 认为前一个 chunk 是空闲的。然后 free 这个 chunk,触发 unlink 操作。
  3. 获得任意地址写: unlink 宏会执行 FD->bk = BK; BK->fd = FD。如果 fake chunk 的 fd 和 bk 设置得当,就能向任意地址写入一个值。

注意: RISC-V 上的 glibc 版本可能较老,没有启用 tcache 或 safe unlinking。但即使有 tcache,如果溢出能控制 tcache 的 bin 指针,同样可以实现任意分配。我在一个比赛题目中就遇到过 tcache poisoning 的变种,当时花了半小时才摸清它的堆管理逻辑。

4.3 JOP(Jump-Oriented Programming)分析

ROP 在 RISC-V 上依然有效,但 JOP 在某些场景下更实用。为什么?因为 RISC-V 的函数调用指令 jalr 和返回指令 ret 都是通过寄存器跳转的,这给 JOP 提供了天然的 gadget 基础。

4.3.1 JOP 与 ROP 的对比

特性 ROP JOP
控制流转移方式 ret 指令(依赖栈) jalr/jr 指令(依赖寄存器)
gadget 结尾 ret jalr xN, xM 或 jr xN
状态保存 栈上连续布局 需要维护寄存器状态
绕过 CFI 能力 容易被 forward-edge CFI 检测 对 backward-edge CFI 更隐蔽
gadget 数量 通常较多 取决于寄存器使用模式

我个人更倾向于在 RISC-V 上优先搜索 JOP gadget,因为 jalr 指令的灵活性很高。你想想看,jalr x0, x5, 0 这条指令,它跳转到 x5 指向的地址,同时不保存返回地址(x0 是硬编码的 0)。这简直就是为 JOP 量身定做的。

4.3.2 寻找 JOP gadget

在 RISC-V 二进制中搜索 JOP gadget,我一般用 ROPgadget 工具,但会加一些自定义过滤条件。比如,搜索以 jalr x0, xN, imm 结尾的指令序列,其中 xN 是我们可以控制的寄存器。

# 示例:搜索 JOP gadget
ROPgadget --binary ./vuln.elf --rawArch=riscv --rawMode=64 | grep "jalr x0, x[0-9]\+"

常见的 JOP gadget 类型包括:

  • 加载-跳转: ld x5, [x6, 0x10]; jalr x0, x5, 0 —— 从内存加载目标地址并跳转。
  • 算术-跳转: addi x5, x6, 0x20; jalr x0, x5, 0 —— 修改寄存器后跳转。
  • 条件-跳转: bne x5, x6, label; jalr x0, x7, 0 —— 条件分支后跳转。

避坑指南: 我曾经在搜索 gadget 时忽略了压缩指令集(RVC)的影响。RVC 指令是 16 位的,可能出现在 32 位指令的中间位置,导致反汇编结果错乱。建议先用 objdump -d -M no-aliases 确认指令边界,再开始搜索。

4.3.3 JOP 调度器构造

JOP 的核心是调度器(dispatcher)。调度器是一个循环,每次从内存中取出一个 gadget 地址,跳转过去执行,然后返回调度器。在 RISC-V 上,调度器可以这样构造:

# 调度器示例(汇编伪代码)
# x5 指向 gadget 地址表
# x6 是索引计数器
loop:
    slli x7, x6, 3      # 索引乘以 8(64位)
    add  x7, x5, x7     # 计算地址
    ld   x8, 0(x7)      # 加载 gadget 地址
    addi x6, x6, 1      # 索引递增
    jalr x0, x8, 0      # 跳转到 gadget

这个调度器每次从表中加载一个地址,跳转过去执行。gadget 执行完后,需要跳转回调度器的某个位置(比如 addi x6, x6, 1 处),形成循环。当然,实际利用时还需要考虑栈平衡和寄存器保存,但核心思路就是这样。

实战要点: 在 CTF 题目中,如果目标程序开启了 CFI(控制流完整性),ROP 很容易被检测到,因为 ret 指令的目标地址必须在合法的调用点集合中。但 JOP 的跳转目标是通过 jalr 实现的,CFI 对间接跳转的检查通常更宽松。这就是 JOP 的优势所在。

4.4 本章知识体系

下面这张图梳理了 RISC-V 漏洞利用的核心脉络,从指令集特点到堆溢出,再到 JOP,每一步都有对应的技术要点。

RISC-V 漏洞利用知识体系 RISC-V 漏洞利用知识体系 RISC-V 漏洞利用 指令集特点 堆溢出利用 JOP 分析 寄存器多 定长指令 无标志位 chunk 结构 unlink 攻击 tcache 利用 gadget 搜索 调度器构造 绕过 CFI 构造 fake chunk 触发 unlink 任意地址写 搜索 jalr gadget 构建调度器 链式执行 核心思路 理解 RISC-V 指令集差异 → 堆溢出控制堆布局 → JOP 绕过 CFI 三者结合,形成完整的异架构漏洞利用链

嗯,这张图把本章的核心内容串起来了。从左到右,从指令集基础到堆溢出,再到 JOP,每一步都是环环相扣的。你在实际做题或做项目时,也可以按这个思路来拆解问题。

再次提醒: RISC-V 的生态还在快速发展,不同厂商的实现可能有差异。比如,有些芯片没有硬件乘法器,有些则没有浮点单元。这些差异会影响 gadget 的可用性。我建议你在开始利用之前,先确认目标平台的指令集扩展情况。