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架构的核心知识点串起来了。我建议你把它当作一个检查清单:
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、栈布局。把这些搞清楚了,剩下的就是熟练度的问题。