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 • 32个通用寄存器 • $a0-$a3:参数传递 • $v0-$v1:返回值 • $ra:返回地址 • $sp:栈指针 • 调用者/被调用者保存规则 延迟槽 • 分支/跳转后指令必执行 • 流水线架构产物 • 延迟槽放nop或有用指令 • 影响ROP gadget布局 • 调试时PC跳转需注意 • 条件指令不能放延迟槽 缓存与内存一致性 • I-cache与D-cache分离 • 写内存≠CPU读到新指令 • cache指令手动刷缓存 • sync内存屏障 • 优先ROP避免shellcode • mprotect可刷缓存 系统调用(syscall) • $v0 = 系统调用号(如sys_write=4004) • $a0-$a3 = 参数1-4 • syscall指令触发,后面跟延迟槽 • 常用:open/read/write/mmap/mprotect/execve • ROP链中通过gadget设置寄存器后调用syscall 堆利用中:ROP链构造需考虑延迟槽,shellcode注入需处理缓存一致性

这四个知识点是MIPS堆利用的基石。寄存器ABI决定了你怎么控制函数调用,延迟槽影响ROP gadget的选择,缓存一致性决定了shellcode能不能跑起来,系统调用是你最终获取权限的通道。把这些搞明白,后面做MIPS堆利用题目时就不会一头雾水了。