异架构漏洞利用基础: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 看一眼。
调试流程:从编译到断点
我习惯的调试流程是这样的:
- 编译测试程序:用交叉编译器编译一个简单的 C 程序,比如一个栈溢出示例。
- 启动 QEMU:
qemu-arm -g 1234 ./vuln,-g参数让 QEMU 在端口 1234 等待 GDB 连接。 - 连接 GDB:
gdb-multiarch -q ./vuln,然后在 GDB 里target remote localhost:1234。 - 设置断点:
b *0x104c0或者b main,然后c继续执行。 - 单步调试:
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 图展示了三种架构在调试时的核心差异:
常见问题与避坑
最后,分享几个我这些年踩过的坑:
- 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 上,后面会省很多时间。