2、MIPS指令集基础:指令格式、寄存器约定与寻址模式
好,咱们正式开始啃MIPS指令集这块硬骨头。说实话,我刚开始接触MIPS逆向时,第一反应是——这玩意儿跟x86差别也太大了。没有变长指令,没有那么多花里胡哨的寻址方式,一切都规规矩矩的。但正是这种「规矩」,反而让逆向分析变得有迹可循。
你想想看,MIPS是个典型的RISC架构。什么叫RISC?就是指令精简,每条指令干一件事,长度固定。这对我们做固件逆向的人来说,其实是好事。因为指令格式一旦固定,反汇编出来的结果就非常规整,不容易被混淆。
2.1 指令格式:三种基本类型
MIPS指令统一32位,不多不少。这32位被划分成几个字段,根据功能不同,分为R型、I型和J型。我习惯把这三种类型叫做「三兄弟」——R型负责运算,I型负责访存和立即数,J型负责跳转。
2.1.1 R型指令(Register)
R型指令,说白了就是操作都在寄存器之间完成。格式如下:
| 31..26 | 25..21 | 20..16 | 15..11 | 10..6 | 5..0 |
| opcode | rs | rt | rd | shamt | funct |
| 6位 | 5位 | 5位 | 5位 | 5位 | 6位 |
字段含义:
- opcode:操作码,R型指令这里全为0
- rs, rt:源操作数寄存器
- rd:目标寄存器
- shamt:移位量,移位指令用
- funct:功能码,真正决定做什么操作
举个例子,add $t0, $t1, $t2 这条指令,opcode=0,rs=$t1,rt=$t2,rd=$t0,shamt=0,funct=0x20。我在逆向一个路由器固件时,经常看到大量R型指令堆叠在一起,一眼扫过去全是0开头的opcode,就知道这段代码在密集做运算。
关键点:R型指令的opcode永远是0。反汇编器看到opcode=0,就会去查funct字段。所以逆向时,你看到opcode=0,心里要有数——这是R型。
2.1.2 I型指令(Immediate)
I型指令带一个16位的立即数。格式如下:
| 31..26 | 25..21 | 20..16 | 15..0 |
| opcode | rs | rt | immediate |
| 6位 | 5位 | 5位 | 16位 |
字段含义:
- opcode:操作码,非零
- rs:源寄存器
- rt:目标寄存器(或源寄存器,取决于指令)
- immediate:16位立即数,有符号扩展
I型指令覆盖了大部分访存操作(lw, sw)和条件分支(beq, bne)。我记得有一次逆向一个MIPS的IoT设备,发现一个缓冲区溢出漏洞,就是通过分析lw指令的立即数字段找到的——那个偏移量明显超出了分配的缓冲区范围。
个人经验:I型指令的立即数只有16位,范围有限。如果你在逆向时看到某个地址访问用了很大的偏移量,那很可能是通过多条指令拼接出来的。我习惯在IDA里搜索lui + ori的组合,这俩经常一起出现,用来构造32位立即数。
2.1.3 J型指令(Jump)
J型指令最简单,就一个操作码加一个26位的地址。格式如下:
| 31..26 | 25..0 |
| opcode | target |
| 6位 | 26位 |
J型指令只有两条:j 和 jal。它们用于无条件跳转。26位的target左移2位后,与PC的高4位拼接,形成32位目标地址。
嗯,这里要注意:J型指令的跳转范围有限,只能跳到当前PC所在的256MB区域内。如果你逆向时发现一个跳转目标超出了这个范围,那肯定不是J型指令,而是通过寄存器跳转(jr)实现的。
2.2 寄存器约定:谁该干什么
MIPS有32个通用寄存器,每个都有约定好的用途。我刚开始学的时候觉得这32个名字记起来很烦,但后来发现,记住这些约定对逆向分析帮助极大——你看到某个寄存器被使用,就能猜到这段代码在干什么。
| 寄存器 | 别名 | 用途 | 是否被调用者保存 |
|---|---|---|---|
| $0 | $zero | 恒为0,写入无效 | — |
| $1 | $at | 汇编器临时寄存器 | 否 |
| $2-$3 | $v0-$v1 | 函数返回值 | 否 |
| $4-$7 | $a0-$a3 | 函数参数 | 否 |
| $8-$15 | $t0-$t7 | 临时变量 | 否 |
| $16-$23 | $s0-$s7 | 保存变量 | 是 |
| $24-$25 | $t8-$t9 | 临时变量 | 否 |
| $26-$27 | $k0-$k1 | 内核保留 | 否 |
| $28 | $gp | 全局指针 | 是 |
| $29 | $sp | 栈指针 | 是 |
| $30 | $fp | 帧指针 | 是 |
| $31 | $ra | 返回地址 | 是 |
说几个我常用的技巧:
- $zero:看到
addiu $sp, $sp, -32这种指令,其实就是在用$zero做减法。因为MIPS没有subiu指令,所以用addiu加负数来实现。 - $ra:函数调用时,
jal会自动把返回地址存入$ra。逆向时,看到函数开头保存$ra,结尾恢复$ra并用jr $ra返回,这就是标准的函数调用约定。 - $sp:栈指针,指向栈顶。我习惯在逆向时先找到$sp的变化量,就能算出栈帧大小,进而定位局部变量。
- $gp:全局指针,用于访问全局变量。在固件逆向中,$gp的值通常是个固定地址,通过它加偏移量就能找到全局数据区。
避坑指南:我曾经在一个MIPS固件里看到大量使用$at寄存器的代码,一开始以为是编译器生成的临时变量,后来才发现是汇编器为了合成伪指令而插入的。比如li $t0, 0x12345678这条伪指令,会被展开成lui $at, 0x1234 + ori $t0, $at, 0x5678。所以逆向时看到$at频繁出现,别慌,那是汇编器在干活。
2.3 寻址模式:MIPS的几种玩法
MIPS的寻址模式比x86少得多,但够用。我总结了一下,实际逆向中常见的有这么几种:
2.3.1 寄存器寻址
操作数在寄存器里。比如add $t0, $t1, $t2,两个源操作数都在寄存器中。这是最常见的寻址方式。
2.3.2 立即数寻址
操作数直接写在指令里。比如addiu $t0, $t0, 100,立即数100直接参与运算。注意,MIPS的立即数都是16位有符号数,范围-32768到32767。
2.3.3 基址寻址
这是访存指令的标配。比如lw $t0, 0x10($sp),地址 = $sp + 0x10。我在逆向时,看到这种指令就知道是在访问栈上的局部变量或参数。
2.3.4 PC相对寻址
用于分支指令。比如beq $t0, $t1, label,目标地址 = PC + 4 + (立即数 << 2)。注意,MIPS的分支延迟槽是个坑——分支指令后面的那条指令无论如何都会执行。我刚开始逆向时,经常被延迟槽搞晕,后来习惯了就好。
2.3.5 伪直接寻址
用于J型指令。目标地址 = (PC & 0xF0000000) | (target << 2)。说白了,就是高4位取当前PC的,低28位由指令提供。
2.4 知识体系结构图
下面这张图是我自己整理的MIPS指令集基础结构,你可以把它当作一个快速参考。每次逆向前扫一眼,思路就清晰了。
2.5 实战小技巧
最后分享几个我在逆向MIPS固件时积累的小技巧:
- 识别函数边界:看到
addiu $sp, $sp, -N和sw $ra, N($sp)的组合,基本就是函数开头。函数结尾则是lw $ra, N($sp)+addiu $sp, $sp, N+jr $ra。 - 快速定位全局变量:找到
lui $gp, 高位+addiu $gp, $gp, 低位,这就是$gp的初始化代码。之后所有通过$gp访存的指令,都是在访问全局变量。 - 处理延迟槽:逆向时,分支指令后面的那条指令一定要看。我曾经因为忽略延迟槽,把一个条件判断的逻辑完全分析反了。
我的习惯:每次拿到一个新的MIPS固件,我会先扫一遍.text段,看看有没有大量的nop指令(0x00000000)。如果有,说明这个固件可能被编译器优化过,或者有人手动插入了对齐填充。另外,nop在MIPS里其实就是sll $zero, $zero, 0,一条R型指令。
好了,MIPS指令集的基础就这些。说白了,记住三种指令格式、32个寄存器的约定、五种寻址模式,你就能看懂大部分MIPS汇编代码了。剩下的就是多练,多逆向几个固件,自然就熟练了。