MIPS架构基础:寄存器约定、延迟槽、栈布局与调用约定

好,咱们进入MIPS的世界。说实话,我第一次接触MIPS是在分析一个路由器固件的时候——那会儿我还在用x86的思维去读反汇编,结果被延迟槽坑得死去活来。后来我明白了,MIPS和x86虽然都是CPU,但它们的哲学完全不同。

这一章,我会带你从Pwn实战的角度,把MIPS的几个核心概念掰开揉碎。你不需要背下所有寄存器编号,但必须理解它们的约定——因为漏洞利用时,你就是在跟这些约定打交道。

3.1 MIPS寄存器约定

MIPS有32个通用寄存器,编号从$0到$31。每个寄存器都有硬件名字和约定用途。我习惯把寄存器分成几类来记:

寄存器 硬件名 约定用途 Pwn视角
$0 $zero 恒为0,写入无效 清零利器,ROP里常用
$1 $at 汇编器临时寄存器 一般别碰,编译器专用
$2-$3 $v0-$v1 函数返回值 漏洞利用时关注返回值
$4-$7 $a0-$a3 函数参数(前4个) ROP里控制参数就靠它们
$8-$15 $t0-$t7 临时寄存器(调用者保存) 随便用,函数调用后可能被改
$16-$23 $s0-$s7 保存寄存器(被调用者保存) 函数返回时必须恢复原值
$24-$25 $t8-$t9 临时寄存器 常用于间接跳转
$26-$27 $k0-$k1 内核保留 用户态别碰
$28 $gp 全局指针 用于访问全局变量/静态数据
$29 $sp 栈指针 栈溢出利用的核心目标
$30 $fp 帧指针(也可作$s8) 调试用,有些编译器不用
$31 $ra 返回地址 ROP的命门,劫持它就赢了

Pwn核心关注点:$ra(返回地址)和$sp(栈指针)是漏洞利用的命脉。栈溢出时,你覆盖的就是$ra;ROP链的每一步,你都在控制$sp的走向。

3.2 延迟槽——MIPS最坑的特性

延迟槽(Delay Slot)是MIPS架构最反直觉的设计。说白了就是:分支指令后面的那条指令,无论分支是否跳转,都会先执行完。

为什么会这样?因为MIPS是经典的五级流水线架构。当CPU遇到分支指令时,下一条指令已经取指了。为了不浪费这个时钟周期,干脆让它执行完。

看个例子:

0x4000:  bne    $a0, $zero, target    # 如果$a0 != 0,跳转到target
0x4004:  addiu  $v0, $zero, 1         # 延迟槽指令——一定会执行!
0x4008:  ...
target:
0x4010:  sw     $v0, 0($sp)           # 跳转到这里

注意:0x4004的addiu不管分支是否成立,都会执行。这意味着:

  • 如果你在调试时单步到0x4000,下一步会跳到0x4004,而不是target
  • 如果你在ROP里用jr $ra跳转,$ra指向的指令后面那条也会被执行

我曾经在分析一个MIPS路由器漏洞时,ROP链怎么都跑不对。后来发现是延迟槽搞的鬼——我跳转到system函数时,延迟槽里是一条nop,但那个nop的位置恰好覆盖了关键参数。嗯,从那以后我每次看MIPS反汇编,都会先标出延迟槽指令。

延迟槽在漏洞利用中的影响:

  • ROP gadget搜索:你找到的jr $ra后面那条指令,必须是无害的(比如nop或addiu $sp, $sp, 0x20)
  • 栈布局:如果你覆盖了$ra,CPU会先执行$ra指向的地址,然后执行$ra+4处的指令(延迟槽)
  • 调试:GDB单步时,延迟槽指令会显示为单独的一步,别被它骗了

3.3 栈布局与调用约定

MIPS的栈布局和x86有本质区别。x86用call/ret自动压栈弹栈,MIPS则全靠$ra和$sp手动管理。

3.3.1 函数调用流程

一个典型的MIPS函数调用是这样的:

# 调用者(caller)
jal    func              # 跳转到func,同时把返回地址存入$ra
nop                      # 延迟槽,通常放nop

# 被调用者(callee)
func:
  addiu  $sp, $sp, -32   # 分配栈帧(32字节)
  sw     $ra, 28($sp)    # 保存返回地址
  sw     $fp, 24($sp)    # 保存帧指针(可选)
  sw     $s0, 20($sp)    # 保存被调用者保存寄存器
  ...
  # 函数体
  ...
  lw     $ra, 28($sp)    # 恢复返回地址
  lw     $fp, 24($sp)    # 恢复帧指针
  lw     $s0, 20($sp)    # 恢复保存寄存器
  addiu  $sp, $sp, 32    # 释放栈帧
  jr     $ra             # 返回
  nop                    # 延迟槽

你想想看,这里没有push/pop指令,全是手动sw/lw。这意味着:

  • 栈帧大小是固定的,编译器在编译时就确定了
  • 如果函数没有调用其他函数(叶子函数),可以不保存$ra
  • 参数传递:前4个参数用$a0-$a3,多余参数压栈

3.3.2 典型栈帧布局

一个MIPS函数的栈帧(从高地址到低地址)通常长这样:

高地址
+------------------+
| 调用者的栈帧      |
+------------------+  <- 调用者的$sp(当前函数的$fp)
| 参数溢出区        |  (如果参数超过4个)
+------------------+
| 保存的$ra        |  (4字节)
+------------------+
| 保存的$fp        |  (4字节,可选)
+------------------+
| 保存的$s0-$s7    |  (每个4字节,按需保存)
+------------------+
| 局部变量          |  (大小由编译器决定)
+------------------+
| 临时空间/对齐填充 |  (保证16字节对齐)
+------------------+  <- 当前$sp
低地址

我个人习惯:在分析MIPS二进制时,先找到函数的开头和结尾。看addiu $sp, $sp, -N就知道栈帧大小,看sw $ra, offset($sp)就知道$ra在栈上的偏移。这个偏移量,就是栈溢出时你需要覆盖的位置。

3.3.3 参数传递规则

MIPS的调用约定(o32 ABI)参数传递规则:

  • 整数/指针参数:$a0到$a3,多余参数从栈上传递
  • 返回值:$v0(32位),$v0:$v1(64位)
  • 结构体:如果结构体太大,调用者分配空间,把指针作为参数传递
  • 可变参数:$a0必须是参数个数(用于va_start)

举个例子,调用func(1, 2, 3, 4, 5):

li    $a0, 1          # 第一个参数
li    $a1, 2          # 第二个参数
li    $a2, 3          # 第三个参数
li    $a3, 4          # 第四个参数
li    $t0, 5
sw    $t0, 16($sp)    # 第五个参数压栈(注意偏移是16,不是0)
jal   func
nop

为什么偏移是16?因为$sp下方有16字节的"参数构造区",这是调用者预留的。即使参数少于4个,这个区域也存在——这是ABI规定的。

3.4 知识结构总览

下面这张图把MIPS架构的核心知识点串起来了。我建议你把它当作一个检查清单:

MIPS架构核心知识结构 MIPS架构基础 寄存器约定 延迟槽 栈布局与调用约定 $v0-$v1 返回值 $a0-$a3 参数 $t0-$t7 临时 $s0-$s7 保存 $ra 返回地址(ROP目标) 分支指令后一条指令必执行 影响ROP gadget选择 调试时单步行为不同 手动管理$ra和$sp 栈帧大小编译时固定 参数溢出区在$sp+16 Pwn实战视角 栈溢出 → 覆盖$ra → 控制$sp → ROP链

3.5 实战中的坑与技巧

我曾经在分析一个MIPSEL(小端)的IoT设备漏洞时,发现栈溢出后$ra被覆盖了,但程序并没有按我预期跳转。排查了半天,原来是延迟槽里有一条sw $ra, 0($sp)——它把新的$ra又写回了栈上,覆盖了我的ROP地址。所以记住:延迟槽指令可能修改寄存器或内存,别想当然地认为它是无害的。

调试MIPS时的实用技巧:

  • 用info registers查看所有寄存器,重点关注$ra、$sp、$gp
  • 用disassemble /r显示原始字节,确认延迟槽位置
  • 在GDB中设置set mipsfpu off避免浮点寄存器干扰
  • 用stepi单步指令,而不是next——后者会跳过函数调用

嗯,MIPS的基础就这些。你可能会觉得寄存器多、延迟槽反直觉,但说白了,Pwn的核心就是那几样东西:$ra、$sp、栈布局。把这些搞清楚了,剩下的就是熟练度的问题。