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的栈帧布局,我画个图你一看就明白。
栈帧的建立,通常由函数的开头几条指令完成。我拿一个典型的例子给你看:
# 函数 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 延迟槽对漏洞利用的影响
延迟槽对漏洞利用的影响,主要体现在两个方面:
- ROP gadget 的选取:你不能随便选一个
jr $ra结尾的 gadget,必须看它后面延迟槽里是什么指令。 - 栈溢出覆盖返回地址:如果你覆盖了
$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利用时,你会觉得顺风顺水。