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型指令只有两条:jjal。它们用于无条件跳转。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指令集基础结构,你可以把它当作一个快速参考。每次逆向前扫一眼,思路就清晰了。

MIPS指令集基础结构 R型指令 I型指令 J型指令 字段:opcode(6) | rs(5) | rt(5) | rd(5) | shamt(5) | funct(6) opcode = 0 示例:add, sub, and, or, sll, srl 用途:寄存器间运算 字段:opcode(6) | rs(5) | rt(5) | immediate(16) opcode ≠ 0 示例:lw, sw, beq, bne, addiu 用途:访存、分支、立即数运算 字段:opcode(6) | target(26) opcode = 2 或 3 示例:j, jal 用途:无条件跳转 寄存器约定:$zero | $ra | $sp | $gp | $a0-$a3 | $v0-$v1 | $s0-$s7 | $t0-$t9 寻址模式:寄存器寻址 | 立即数寻址 | 基址寻址 | PC相对寻址 | 伪直接寻址 MIPS指令集基础 — 三大指令格式 + 寄存器约定 + 寻址模式

2.5 实战小技巧

最后分享几个我在逆向MIPS固件时积累的小技巧:

  • 识别函数边界:看到addiu $sp, $sp, -Nsw $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汇编代码了。剩下的就是多练,多逆向几个固件,自然就熟练了。