3. MIPS汇编基础:指令格式与延迟槽
MIPS架构在CTF的Pwn题里不算最常见,但一旦碰上,往往就是那种让你头疼的题目。我早年打比赛时第一次遇到MIPS的栈溢出,看着反汇编窗口里那些奇怪的指令格式,说实话懵了好一会儿。后来啃了几份文档,又在嵌入式设备上调试过几次,才算真正摸透了它的脾气。
这一章咱们就聊聊MIPS汇编最核心的几个东西:指令格式、加载存储、分支跳转,还有那个让无数人踩坑的延迟槽。你把这些搞明白了,逆向MIPS二进制文件时至少不会两眼一抹黑。
3.1 MIPS指令格式:三种基本类型
MIPS指令长度固定,都是32位。这一点和x86那种变长指令完全不同。我个人觉得,固定长度指令在逆向分析时反而更友好——你一眼就能看出每条指令的边界。
MIPS把指令分成三类:R型、I型、J型。说白了就是根据操作数的来源和用途做了区分。
| 类型 | 格式 | 用途 |
|---|---|---|
| R型 | op(6) | rs(5) | rt(5) | rd(5) | shamt(5) | funct(6) | 寄存器到寄存器的运算 |
| I型 | op(6) | rs(5) | rt(5) | immediate(16) | 带立即数的运算、加载/存储、分支 |
| J型 | op(6) | address(26) | 无条件跳转 |
你看这个表格,每种类型的高6位都是opcode(操作码)。R型比较特殊,它靠低6位的funct来区分具体操作。比如 add 和 sub 的opcode都是0,但funct不同。
关键点:逆向MIPS时,先看opcode,再看funct。opcode决定指令大类,funct决定具体操作。我习惯在IDA里把这两列同时显示出来,分析效率会高很多。
3.2 R型指令:寄存器运算的核心
R型指令的操作数全部来自寄存器,结果也写回寄存器。格式是:op rs rt rd shamt funct。其中 shamt 是移位量,只有移位指令才用,其他指令这一位是0。
举个例子,add $t0, $s1, $s2 这条指令的编码是这样的:
opcode = 0 (6 bits)
rs = 17 ($s1, 5 bits)
rt = 18 ($s2, 5 bits)
rd = 8 ($t0, 5 bits)
shamt = 0 (5 bits)
funct = 32 (6 bits, 对应 add)
二进制:000000 10001 10010 01000 00000 100000
十六进制:0x02324020
我在逆向一个MIPS路由器固件时,经常看到大量R型指令构成的运算序列。你只要记住 rd = rs op rt 这个模式,读起来就顺了。
3.3 I型指令:立即数与访存
I型指令带一个16位的立即数。这个立即数可以是有符号的,也可以是无符号的,取决于具体指令。格式是:op rs rt immediate。
常见的I型指令包括:
- addi:加立即数,
rt = rs + imm - lw/sw:加载/存储字,
rt = MEM[rs + imm] - beq/bne:条件分支,比较rs和rt
这里有个坑要注意:lw $t0, 0x100($sp) 这种写法,0x100是偏移量,不是地址。我见过不少新手把这个当成绝对地址,结果分析栈布局时全算错了。
个人经验:逆向MIPS的栈操作时,重点关注 lw 和 sw 的偏移量。我曾经在分析一个堆溢出漏洞时,就是靠追踪 sw 的写入偏移找到了溢出点。
3.4 J型指令:跳转目标
J型指令最简单,只有opcode和26位地址。格式:op address。它用于无条件跳转,比如 j target 和 jal target。
注意,这26位地址不是完整地址。MIPS把PC的高4位和这26位拼接,再左移2位,才得到真正的目标地址。为什么左移2位?因为MIPS指令都是4字节对齐的,最低2位永远是0,没必要存。
实际目标地址 = (PC & 0xF0000000) | (address << 2)
这个机制在逆向时有个小技巧:如果你看到 j 指令的目标地址和当前PC不在同一个256MB区域,那说明程序可能用了某种地址映射技巧。我在分析一个MIPS的shellcode时遇到过这种情况,当时差点被绕晕。
3.5 加载存储指令:MIPS的访存哲学
MIPS是典型的RISC架构,访存操作只有 lw、sw、lb、sb 等少数几条指令。其他所有运算都在寄存器之间完成。这一点和x86完全不同——x86可以在内存上直接做加法,MIPS不行。
常用的加载存储指令:
| 指令 | 含义 | 示例 |
|---|---|---|
lw |
加载字(4字节) | lw $t0, 0($sp) |
sw |
存储字(4字节) | sw $t0, 4($sp) |
lb |
加载字节 | lb $t0, 0($a0) |
sb |
存储字节 | sb $t0, 0($a0) |
lui |
加载高16位 | lui $t0, 0x1234 |
lui 这条指令比较特殊,它把16位立即数加载到寄存器的高16位,低16位补0。配合 ori 就可以构造任意32位立即数。这是MIPS里加载大常数的标准套路。
避坑指南:我曾经在逆向一个MIPS的CTF题目时,看到 lui 和 ori 的组合,以为是在加载地址,结果发现是在构造一个magic number用于校验。所以看到 lui 时,别急着当成地址,先看看后面有没有 lw 或 sw 在用这个值。
3.6 分支跳转指令与延迟槽
MIPS的分支指令包括 beq、bne、blez、bgtz 等。跳转指令包括 j、jal、jr。这些指令的用法和ARM类似,但有一个MIPS独有的特性——延迟槽。
延迟槽是什么?简单说,MIPS在分支或跳转指令后面,总是会执行一条指令,不管分支是否成立。这条指令就是延迟槽指令。为什么会这样?因为MIPS的流水线设计导致分支指令在取指阶段就已经把下一条指令取进来了,为了不浪费这个周期,干脆让它执行掉。
举个例子:
0x4000: beq $t0, $zero, target
0x4004: addi $t1, $t1, 1 ; 延迟槽指令,一定会执行
0x4008: ...
target:
0x4100: sub $t2, $t2, $t3
不管 $t0 是不是0,addi 这条指令都会执行。然后才跳转到 target。这个特性在逆向时特别容易让人困惑。
核心要点:逆向MIPS时,看到分支指令,一定要看它后面的那条指令。那条指令是延迟槽,一定会执行。我刚开始分析MIPS时,经常把延迟槽指令当成分支不执行时的路径,结果逻辑全乱了。
延迟槽在CTF的Pwn题里也有利用价值。比如ROP时,你可以把gadget放在延迟槽位置,利用分支指令跳转到下一个gadget。不过这个技巧比较进阶,咱们后面再细聊。
3.7 知识结构总览
下面这张图把MIPS指令的核心关系梳理了一下,你看完应该能有个整体印象。
3.8 实战中的注意事项
最后聊几个我在实战中总结的点:
- 延迟槽里的异常:如果延迟槽指令触发了异常(比如除零),那异常处理完后会回到分支目标地址,而不是延迟槽的下一条。这个细节在分析异常处理流程时很重要。
- 分支延迟槽的NOP:编译器经常在延迟槽里放
nop(即sll $zero, $zero, 0)。如果你看到分支指令后面跟着一堆nop,别觉得奇怪,那是编译器偷懒了。 - 手动恢复控制流:逆向时,我习惯把延迟槽指令和它前面的分支指令看作一个整体。在IDA里,我会给这个组合加注释,避免后续分析时搞混。
一个小技巧:MIPS的 jal 指令会把返回地址保存在 $ra 寄存器中。如果你在逆向时看到 jal 后面跟着 nop,那基本上就是标准的函数调用。但如果延迟槽里是其他指令,比如 addiu $sp, $sp, -32,那说明编译器做了优化,把函数序言的一部分塞进了延迟槽。
嗯,MIPS指令的基础就这些。你把这些搞熟了,再去看MIPS的二进制文件,至少能读懂每条指令在干什么。下一层就是把这些指令组合起来,理解函数的调用约定和栈布局了。那个咱们后面再聊。