MIPS基础:寄存器、延迟槽与系统调用
做异架构Pwn,MIPS是绕不开的一关。我最早接触MIPS是在一个嵌入式路由器的漏洞分析项目里,那时候刚上手就被延迟槽坑了一把——单步调试时PC跳得跟我想的完全不一样。今天咱们就把MIPS堆利用前必须掌握的基础知识捋一遍。
MIPS寄存器与ABI调用约定
MIPS有32个通用寄存器,每个都有约定好的用途。跟x86不同,MIPS的寄存器分工非常明确,编译器会严格遵循ABI规范。我个人习惯把寄存器分成几类来记:
| 寄存器 | 别名 | 用途 | 调用者/被调用者保存 |
|---|---|---|---|
| $0 | $zero | 恒为0,写入无效 | — |
| $2-$3 | $v0-$v1 | 函数返回值 | 调用者 |
| $4-$7 | $a0-$a3 | 函数参数(前4个) | 调用者 |
| $8-$15 | $t0-$t7 | 临时寄存器 | 调用者 |
| $16-$23 | $s0-$s7 | 保存寄存器 | 被调用者 |
| $29 | $sp | 栈指针 | 被调用者 |
| $31 | $ra | 返回地址 | 被调用者 |
关键点:MIPS没有x86那种push/pop指令,栈操作全靠addiu和sw/lw手动完成。这意味着函数序言和结语必须显式管理栈帧。
举个例子,一个简单的函数调用在MIPS里长这样:
# 函数序言
func:
addiu $sp, $sp, -32 # 分配栈帧
sw $ra, 28($sp) # 保存返回地址
sw $s0, 24($sp) # 保存被调用者保存寄存器
# 函数体
move $a0, $s0 # 使用参数
jal another_func # 调用子函数
nop # 延迟槽
# 函数结语
lw $s0, 24($sp) # 恢复寄存器
lw $ra, 28($sp) # 恢复返回地址
addiu $sp, $sp, 32 # 释放栈帧
jr $ra # 返回
nop # 延迟槽
实战经验:我在分析一个MIPS路由器固件时,发现攻击者通过栈溢出覆盖了$ra。因为MIPS的返回地址在栈上,不像x86在栈顶,所以ROP gadget的布局方式完全不同。你想想看,$ra在栈帧的高地址处,溢出时得算好偏移。
延迟槽——MIPS最坑的特性
延迟槽是MIPS流水线架构的产物。简单说:分支或跳转指令后面的那条指令,无论跳转是否发生,都会先执行完。
为什么会这样?早期MIPS是5级流水线,取指、译码、执行、访存、写回。当遇到跳转指令时,下一条指令已经被取进流水线了。为了不浪费这个时钟周期,干脆让它执行完。
# 延迟槽示例
addiu $t0, $zero, 1
beq $t0, $zero, skip # 条件分支
addiu $t1, $zero, 2 # 延迟槽指令——一定会执行!
addiu $t2, $zero, 3 # 只有不跳转时才执行
skip:
# 到这里时,$t1已经是2了
上面这段代码,即使$t0是1(不等于0),beq不会跳转,但addiu $t1, $zero, 2仍然会执行。我刚开始调试时,看到$t1莫名其妙变成了2,愣了半天才反应过来是延迟槽在作怪。
避坑指南:我曾经在写MIPS shellcode时,把nop放在了延迟槽位置,结果跳转目标计算错了。后来我养成了一个习惯:所有分支和跳转指令后面,要么放nop,要么放一条确定要执行的有用指令。千万别把条件相关的指令塞进延迟槽。
延迟槽对堆利用有什么影响?嗯,主要是在ROP链构造时。MIPS的ROP gadget通常以jr $ra或jalr $s0结尾,后面跟着延迟槽指令。如果你要跳转到gadget,必须考虑延迟槽里的指令会不会破坏你的寄存器状态。
缓存与内存一致性
MIPS处理器通常有独立的指令缓存(I-cache)和数据缓存(D-cache)。这对Pwn来说是个大问题——你写进内存的shellcode,CPU可能还在读缓存里的旧指令。
我记得在一个MIPS靶机题目里,我通过堆溢出写入了shellcode,然后劫持PC跳过去。结果程序直接崩溃了。调试了半天才发现,shellcode虽然写进了内存,但I-cache里还是原来的数据。
解决办法是手动刷缓存。MIPS提供了cache指令:
# 刷指令缓存
cache 0x10, 0($a0) # Hit_Invalidate_I
cache 0x14, 0($a0) # Hit_Writeback_Inv_D
sync # 内存屏障
但在实际漏洞利用中,我们往往没有机会执行这些指令。所以常见的做法是:
- 避免自修改代码:不要写shellcode到可写页然后跳转执行
- 使用ROP链:直接利用已有的代码片段,不需要写新指令
- 如果必须写shellcode:确保目标内存是缓存一致性域(如DDR而非SRAM)
核心原则:在MIPS上做堆利用,优先考虑ROP而非shellcode注入。除非你确定目标平台没有I-cache问题(比如某些模拟器环境)。
系统调用(syscall指令)
MIPS的系统调用通过syscall指令触发。跟x86的int 0x80或syscall指令不同,MIPS的syscall使用$v0传递系统调用号,参数放在$a0-$a3里。
常用的系统调用号(以Linux/MIPS为例):
| 系统调用 | $v0值 | 参数 | 说明 |
|---|---|---|---|
| sys_read | 4003 | $a0=fd, $a1=buf, $a2=count | 读文件 |
| sys_write | 4004 | $a0=fd, $a1=buf, $a2=count | 写文件 |
| sys_open | 4005 | $a0=path, $a1=flags, $a2=mode | 打开文件 |
| sys_mmap | 4090 | $a0=addr, $a1=len, $a2=prot, $a3=flags | 内存映射 |
| sys_mprotect | 4125 | $a0=addr, $a1=len, $a2=prot | 修改内存权限 |
| sys_execve | 4011 | $a0=path, $a1=argv, $a2=envp | 执行程序 |
写一个MIPS的shellcode调用write系统调用:
# MIPS Linux sys_write(1, msg, len)
li $v0, 4004 # sys_write号
li $a0, 1 # stdout
la $a1, msg # 消息地址
li $a2, 12 # 长度
syscall
nop # syscall后的延迟槽
li $v0, 4001 # sys_exit
xor $a0, $a0, $a0 # 返回值0
syscall
nop
msg:
.asciiz "Hello MIPS!\n"
实用技巧:在构造MIPS ROP链时,我经常用sys_mprotect把堆页改成可执行,然后再跳转到堆上的shellcode。这样既绕过了I-cache问题(因为mprotect会刷缓存),又能执行任意代码。不过要注意,mprotect的参数$v0是4125,别记成x86的125了。
知识结构总览
下面这张图把MIPS基础的核心知识点串起来了,方便你对照理解:
这四个知识点是MIPS堆利用的基石。寄存器ABI决定了你怎么控制函数调用,延迟槽影响ROP gadget的选择,缓存一致性决定了shellcode能不能跑起来,系统调用是你最终获取权限的通道。把这些搞明白,后面做MIPS堆利用题目时就不会一头雾水了。