第四章 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 的元数据。目标是实现任意地址写。
- 构造 fake chunk: 在可控的堆数据区伪造一个 chunk,设置好 size、fd 和 bk 指针。fd 指向 fake chunk 自身减去 12(32位)或 24(64位),bk 指向 fake chunk 自身减去 8(32位)或 16(64位)。
- 触发 unlink: 通过溢出修改下一个 chunk 的 size 字段,清除 PREV_INUSE 标志,让 glibc 认为前一个 chunk 是空闲的。然后 free 这个 chunk,触发 unlink 操作。
- 获得任意地址写: 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,每一步都有对应的技术要点。
嗯,这张图把本章的核心内容串起来了。从左到右,从指令集基础到堆溢出,再到 JOP,每一步都是环环相扣的。你在实际做题或做项目时,也可以按这个思路来拆解问题。
再次提醒: RISC-V 的生态还在快速发展,不同厂商的实现可能有差异。比如,有些芯片没有硬件乘法器,有些则没有浮点单元。这些差异会影响 gadget 的可用性。我建议你在开始利用之前,先确认目标平台的指令集扩展情况。