4、MIPS汇编基础(下):函数调用与栈帧

好,咱们接着往下聊。上一节我们把MIPS的寄存器、指令集和寻址方式过了一遍,这一节要啃的,是真正跟漏洞利用血肉相关的东西——函数调用约定、栈帧管理,还有那个让无数新手头疼的延迟槽。

说实话,我早期做MIPS漏洞利用时,最常翻车的地方就是栈帧没算对,或者忽略了延迟槽的影响。你想想看,一个ROP链辛辛苦苦搭好了,结果因为延迟槽把下一条指令提前执行了,整个链就崩了。所以这一节的内容,我建议你反复看,最好能动手写几个小例子跑一跑。

4.1 MIPS函数调用约定

MIPS的函数调用,说白了就是一套约定——谁负责传参,谁负责保存现场,谁负责返回。这套约定跟x86很不一样,咱们先看核心的几个寄存器。

寄存器 别名 用途 谁保存
$ra 返回地址寄存器 保存函数返回地址 调用者(必要时)
$sp 栈指针 指向当前栈顶 被调用者
$fp 帧指针 指向当前栈帧基址 被调用者
$a0-$a3 参数寄存器 传递前4个参数 调用者
$v0-$v1 返回值寄存器 返回函数结果 被调用者

这里有个关键点:$ra 是MIPS里最特殊的寄存器之一。每次你用 jal 指令调用函数时,硬件会自动把当前PC+8(注意不是+4)写入 $ra。为什么会是+8?嗯,这跟延迟槽有关,咱们后面细说。

核心区别: x86用call指令把返回地址压栈,MIPS用jal指令把返回地址存到$ra。这意味着MIPS的函数嵌套调用,必须手动保存$ra,否则内层函数一调用,$ra就被覆盖了。

4.2 栈帧的建立与销毁

栈帧,就是函数在栈上分配的那块"自留地"。MIPS的栈帧布局,我画个图你一看就明白。

MIPS 栈帧结构图 高地址 调用者栈帧(高地址方向) ... 局部变量、临时数据 ... 参数区(保存 $a0-$a3 或额外参数) 通常由调用者分配 保存的 $ra(返回地址) 保存的 $fp(旧帧指针) 保存的 $s0-$s7 等被调用者保存寄存器 局部变量区 数组、结构体、临时缓冲区 低地址 $sp 指向这里 $fp 指向这里 说明:栈从高地址向低地址增长,$sp 指向栈顶(最低地址),$fp 指向当前帧基址 $fp 通常指向保存的 $ra 位置,便于快速定位返回地址

栈帧的建立,通常由函数的开头几条指令完成。我拿一个典型的例子给你看:

# 函数 prologue(栈帧建立)
func:
    addiu   $sp, $sp, -32      # 分配 32 字节栈空间
    sw      $ra, 28($sp)       # 保存返回地址
    sw      $fp, 24($sp)       # 保存旧帧指针
    move    $fp, $sp           # 设置新帧指针

    # ... 函数体 ...

    # 函数 epilogue(栈帧销毁)
    move    $sp, $fp           # 恢复栈指针
    lw      $fp, 24($sp)       # 恢复旧帧指针
    lw      $ra, 28($sp)       # 恢复返回地址
    addiu   $sp, $sp, 32       # 释放栈空间
    jr      $ra                # 返回
    nop                        # 延迟槽

个人经验: 我在逆向分析固件时,经常靠识别 addiu $sp, $sp, -N 和 sw $ra, N($sp) 这对组合来定位函数边界。只要看到这种模式,基本就能确定这是一个函数的开头。你调试的时候也可以这么找。

销毁栈帧时,顺序跟建立时完全相反。注意最后那个 jr $ra 后面的 nop,这就是延迟槽——咱们接下来重点讲。

4.3 延迟槽(Delay Slot)机制

延迟槽是MIPS架构最"反直觉"的设计之一。说白了就是:在分支或跳转指令后面紧跟着的那条指令,无论跳转是否发生,都会先被执行。

为什么会这样?早期MIPS处理器为了简化硬件设计,采用了流水线架构。当遇到跳转指令时,下一条指令已经进入流水线了,干脆就让它执行完。这个"被顺带执行"的指令位置,就是延迟槽。

避坑指南: 我曾经在写一个MIPS的ROP链时,把gadget的地址直接放在跳转指令后面,结果那个地址被当成指令执行了,程序直接崩溃。后来才意识到——延迟槽里的指令是无条件执行的,不管跳转成不成功。

来看个具体例子:

# 示例:延迟槽的行为
    addiu   $t0, $zero, 1      # $t0 = 1
    beq     $zero, $zero, skip # 无条件跳转到 skip
    addiu   $t0, $t0, 100      # 延迟槽:这条指令先执行!
skip:
    # 到这里时,$t0 的值是多少?
    # 答案是 101,不是 1!

你看,即使 beq 跳转了,延迟槽里的 addiu $t0, $t0, 100 还是被执行了。这就是延迟槽的"霸道"之处。

4.4 延迟槽对漏洞利用的影响

延迟槽对漏洞利用的影响,主要体现在两个方面:

  1. ROP gadget 的选取:你不能随便选一个 jr $ra 结尾的 gadget,必须看它后面延迟槽里是什么指令。
  2. 栈溢出覆盖返回地址:如果你覆盖了 $ra,但跳转指令的延迟槽里是一条破坏性指令(比如 sw $ra, 0($sp)),那你的利用链可能还没开始就结束了。

我整理了一个表格,列出常见的延迟槽指令及其对利用的影响:

延迟槽指令 对利用的影响 风险等级
nop 无影响,最安全 低
move $t0, $t1 可能改变寄存器值,需分析 中
addiu $sp, $sp, N 改变栈指针,可能破坏栈布局 高
lw/sw 访存指令 可能读写非法地址导致崩溃 极高
jalr/jr 嵌套跳转 改变控制流,极难预测 极高

关键技巧: 在MIPS上做ROP,我个人的习惯是优先找以 jr $ra 结尾、且延迟槽是 nop 或无害指令的gadget。如果找不到,那就得仔细分析延迟槽指令的副作用,确保它不会破坏你的利用链状态。

还有一个常见的坑:延迟槽里的指令如果触发了异常,异常处理完成后会回到延迟槽指令,而不是跳转目标。这意味着如果你在延迟槽里访问了非法地址,程序会陷入死循环——因为每次异常返回都重新执行那条非法指令。

嗯,这一节的内容确实有点多,但都是硬核知识。栈帧的结构决定了你能覆盖什么、不能覆盖什么;延迟槽的机制决定了你的ROP链能不能稳定运行。这两块搞明白了,后面讲栈溢出和ROP利用时,你会觉得顺风顺水。