异架构漏洞利用基础:ARM、MIPS、RISC-V 对比与调试环境搭建

各位同学,欢迎来到这门课的第一章。说实话,我做了这么多年二进制安全,最深的感触就是:只懂 x86 的漏洞研究员,就像只会开自动挡的司机——遇到手动挡、电车、甚至拖拉机,直接就懵了。

CTF 赛场上,ARM、MIPS、RISC-V 的题目越来越多。我见过太多选手,x86 漏洞一把好手,换到 ARM 就不知道栈帧怎么看了。今天我们就来把这层窗户纸捅破。

为什么是这三种架构?

先看一张对比表,心里有个底:

特性 ARM (32位) MIPS RISC-V (32位)
指令长度 4字节 (ARM) / 2字节 (Thumb) 4字节 (固定) 4字节 / 2字节 (压缩)
通用寄存器 16个 (r0-r15) 32个 (r0-r31) 32个 (x0-x31)
返回地址存储 LR (r14) 寄存器 $ra (r31) 寄存器 ra (x1) 寄存器
栈帧惯例 FP (r11) 可选 $fp (r30) 可选 s0/fp (x8) 可选
延迟槽 无 有 (分支延迟槽) 无
条件执行 几乎所有指令 无 无
常见调用约定 前4个参数在 r0-r3 前4个参数在 a0-a3 前8个参数在 a0-a7

这张表我建议你存下来。每次做题前扫一眼,能少踩一半的坑。

ARM:最像 x86 的异架构

ARM 是我个人接触最早的异架构。2015 年我第一次在 CTF 里遇到 ARM 的栈溢出,当时心想:这不就是 x86 换了个寄存器名字吗?结果被延迟槽和 Thumb 模式狠狠教育了一顿。

关键差异点:

  • PC 可读可写:ARM 里你可以直接读 PC 寄存器,这在 x86 里是做不到的。ROP 时经常用这个特性。
  • LR 寄存器:函数返回地址存在 LR 里,不是压栈。这意味着你覆盖栈帧时,不一定能直接控制返回地址——得先看 LR 有没有被 spill 到栈上。
  • Thumb/Thumb-2:2 字节指令,ROP gadget 密度更高。我习惯先看目标是不是 Thumb 模式,再决定 gadget 搜索策略。

实战经验:ARM 的栈布局和 x86 最大的区别是——参数寄存器。前 4 个参数走 r0-r3,第 5 个才开始压栈。写 exploit 时,如果函数有 6 个参数,你只控制了栈上第 5、6 个,前 4 个还是得靠 gadget 来设。

MIPS:延迟槽是噩梦

MIPS 是我最头疼的架构,没有之一。为什么?延迟槽。

什么叫延迟槽?就是分支指令后面那条指令一定会被执行,不管分支跳不跳。举个例子:

beq $a0, $zero, target   # 如果 a0==0 就跳转
addiu $v0, $zero, 1       # 这条指令无论如何都会执行!
target:
  # 跳转到这里

我第一次写 MIPS ROP 时,死活找不到能用的 gadget,后来发现是延迟槽把 gadget 的语义搞乱了。你找到一条 jr $ra,但它的延迟槽里可能有一条 addiu $sp, $sp, 16——这俩组合在一起才是一个完整的 gadget。

避坑指南:我曾经在 MIPS 上调试一个栈溢出,明明控制了 $ra,但程序就是不按我的地址跳。查了两小时才发现,延迟槽里有一条指令修改了 $ra。记住:MIPS 的 gadget 必须成对看,分支指令和它的延迟槽是一条不可分割的原子操作。

MIPS 其他特点:

  • GP 寄存器:全局指针,类似 x86 的 GOT 表基址。很多 MIPS 二进制用 GP 相对寻址访问全局变量。
  • 无条件执行:所有指令都是无条件执行的,没有 ARM 那种条件后缀。
  • 字节序:MIPS 既有大端也有小端,CTF 里小端居多,但偶尔会碰到大端题,注意看 ELF header。

RISC-V:新贵,但坑也不少

RISC-V 是最近几年 CTF 的新宠。说实话,它比 ARM 和 MIPS 都干净——没有延迟槽,没有条件执行,指令格式规整。但干净不代表简单。

RISC-V 的独特之处:

  • x0 永远是 0:写入 x0 的数据会被丢弃,读 x0 永远得到 0。这个特性在构造 gadget 时很有用——你可以用 x0 来清零寄存器。
  • 参数寄存器多:前 8 个参数走 a0-a7,这意味着很多函数调用不需要压栈。但反过来,你控制栈的机会也变少了。
  • 压缩指令集 (RVC):2 字节指令,和 ARM Thumb 类似。但 RVC 的指令编码更规整,ROP gadget 搜索起来反而比 Thumb 容易。

我的建议:如果你刚开始学异架构,从 RISC-V 入手最友好。它没有历史包袱,指令集干净,调试工具也成熟。ARM 和 MIPS 的历史遗留问题太多,容易劝退。

调试环境搭建:QEMU + GDB-multiarch

环境搭建是异架构漏洞利用的第一步,也是最容易出问题的一步。我见过太多人卡在「QEMU 起不来」或者「GDB 连不上」上。

核心工具链:

  • QEMU:用户态模拟用 qemu-ARCH,系统态模拟用 qemu-system-ARCH。CTF 里 90% 的情况用用户态就够了。
  • GDB-multiarch:支持多架构的 GDB,一个二进制搞定 ARM、MIPS、RISC-V 的调试。
  • 交叉编译工具链:用来编译目标架构的测试程序。比如 gcc-arm-linux-gnueabihf、gcc-mipsel-linux-gnu、gcc-riscv64-linux-gnu。

安装命令(Debian/Ubuntu):

# 安装 QEMU 用户态模拟器
sudo apt install qemu-user qemu-user-static

# 安装 GDB-multiarch
sudo apt install gdb-multiarch

# 安装交叉编译工具链
sudo apt install gcc-arm-linux-gnueabihf binutils-arm-linux-gnueabihf
sudo apt install gcc-mipsel-linux-gnu binutils-mipsel-linux-gnu
sudo apt install gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu

注意:MIPS 的交叉编译工具链分大端 (mips-linux-gnu) 和小端 (mipsel-linux-gnu)。CTF 里小端居多,但别想当然——先 readelf -h 看一眼。

调试流程:从编译到断点

我习惯的调试流程是这样的:

  1. 编译测试程序:用交叉编译器编译一个简单的 C 程序,比如一个栈溢出示例。
  2. 启动 QEMU:qemu-arm -g 1234 ./vuln,-g 参数让 QEMU 在端口 1234 等待 GDB 连接。
  3. 连接 GDB:gdb-multiarch -q ./vuln,然后在 GDB 里 target remote localhost:1234。
  4. 设置断点:b *0x104c0 或者 b main,然后 c 继续执行。
  5. 单步调试:si 单步指令,info registers 查看寄存器状态。

一个完整的调试会话示例:

# 终端1:启动 QEMU
$ qemu-arm -g 1234 ./vuln

# 终端2:启动 GDB
$ gdb-multiarch -q ./vuln
(gdb) target remote localhost:1234
(gdb) b *0x104c0
(gdb) c
(gdb) info registers r0 r1 r2 r3 sp lr pc
r0             0x1      1
r1             0xfff7e3a4       -134747740
r2             0x0      0
r3             0x0      0
sp             0xfff7e380       0xfff7e380
lr             0x104b8  66744
pc             0x104c0  66624
(gdb) x/10i $pc
=> 0x104c0:     str     r0, [sp, #4]
   0x104c4:     ldr     r3, [sp, #4]
   0x104c8:     add     r3, r3, #1
   0x104cc:     str     r3, [sp, #4]
   0x104d0:     ldr     r3, [sp, #4]
   0x104d4:     mov     r0, r3
   0x104d8:     bl      0x103d0

小技巧:GDB 里 set architecture arm 可以手动指定架构,有时候 QEMU 传过来的架构信息不完整,需要手动设置才能正确反汇编。

三种架构的调试对比

下面这张 SVG 图展示了三种架构在调试时的核心差异:

三种异架构调试核心差异对比 ARM • 寄存器: r0-r15, PC 可读写 • 返回地址: LR (r14) • 指令模式: ARM/Thumb 切换 • 条件执行: 几乎所有指令 • 参数: r0-r3, 栈上第5个起 • 调试要点: 注意 Thumb 模式 MIPS • 寄存器: r0-r31, 32个通用 • 返回地址: $ra (r31) • 延迟槽: 分支后必执行一条 • 固定指令长度: 4字节 • 参数: a0-a3, 栈上第5个起 • 调试要点: gadget 成对看 RISC-V • 寄存器: x0-x31, x0恒为0 • 返回地址: ra (x1) • 无延迟槽, 无条件执行 • 压缩指令: RVC 2字节 • 参数: a0-a7, 前8个走寄存器 • 调试要点: 利用 x0 清零 调试环境统一命令 qemu-ARCH -g PORT ./binary → gdb-multiarch → target remote localhost:PORT 常见调试陷阱 1. QEMU 用户态模拟不支持所有系统调用,遇到 syscall 崩溃先检查是不是模拟限制 2. GDB 连接后反汇编不对?试试 set endian little 或 set architecture arm 3. 交叉编译时记得加 -static,否则 QEMU 找不到动态链接器 4. MIPS 的延迟槽在单步调试时容易混淆,建议用 display/5i $pc 持续观察

常见问题与避坑

最后,分享几个我这些年踩过的坑:

  • QEMU 用户态不支持所有系统调用:比如 ptrace、ioctl 在某些架构上会直接返回 -1。遇到程序崩溃先怀疑这个。
  • GDB 反汇编不对:有时候 QEMU 传过来的架构信息不完整,GDB 默认用 x86 反汇编。手动 set architecture arm 或者 set architecture mips 解决。
  • 交叉编译必须静态链接:QEMU 用户态不会加载动态链接器,除非你装了 qemu-user-static 并配置了 binfmt。我建议一律 -static,省心。
  • MIPS 的延迟槽在 GDB 里显示很奇怪:单步 si 时,你会看到 PC 先跳到分支目标,然后才执行延迟槽指令。别慌,这是正常的。

最重要的提醒:永远不要假设目标架构的栈布局和 x86 一样。ARM 的 LR 可能不在栈上,MIPS 的 $ra 可能被延迟槽修改,RISC-V 的 x0 可能让你构造 gadget 时多一个「免费清零」的操作。每次调试前,先 info registers 看一眼,确认你理解当前的寄存器状态。

好了,第一章就到这里。环境搭好了,后面我们就能真正开始写 exploit 了。记住:调试器是你最好的朋友,多花时间在 GDB 上,后面会省很多时间。