第四章 异架构基础:ARM与MIPS栈布局差异

做CTF做久了,你会发现一个规律——x86下的漏洞利用,说白了就是一套成熟的流水线作业。但一旦切换到ARM或者MIPS,很多老套路就失灵了。为什么?因为CPU的设计哲学不一样,栈的布局、参数的传递方式、甚至指令的执行顺序都完全不同。

我个人习惯把异架构的Pwn题比作「方言」。你学会了普通话(x86),不代表能听懂粤语(ARM)和闽南语(MIPS)。今天我们就来拆解这两种「方言」的核心差异。

4.1 ARM调用约定:参数去哪儿了?

先看ARM。ARM的调用约定和x86最大的区别在于——参数不优先走栈。

在x86里,函数调用时参数一股脑压到栈上,然后用ret指令跳转。ARM不一样,它优先用寄存器传参。具体规则是这样的:

参数位置 ARM (AAPCS) x86 (cdecl)
第1个参数 R0 [esp+4]
第2个参数 R1 [esp+8]
第3个参数 R2 [esp+12]
第4个参数 R3 [esp+16]
第5个参数及以上 压栈 压栈

这意味着什么?你想想看,在ARM上做ROP,你不需要像x86那样在栈上摆一串参数。你只需要控制R0到R3这四个寄存器,就能搞定大部分函数调用。

实战要点:ARM的ROP gadget通常以 POP {R0, PC} 或 MOV R0, R1; BX LR 结尾。找gadget时优先找能控制R0-R3的片段。

我记得有一次打一个ARM架构的IoT设备漏洞,目标函数需要三个参数。我一开始按x86的思路在栈上摆参数,结果怎么都调不通。后来才发现——ARM根本不会从栈上读前三个参数!它们全在R0-R2里。改用了 POP {R0-R2, PC} 的gadget,一次搞定。

4.2 ARM栈布局:LR寄存器的陷阱

ARM的栈帧和x86还有一个关键区别——返回地址的保存方式。

x86用 call 指令把返回地址压栈,然后用 ret 弹出。ARM用 BL 指令把返回地址存到LR寄存器(R14),然后在函数开头用 PUSH {LR} 保存到栈上。

这个差异在ROP里会带来一个很坑的问题:

  • x86:覆盖返回地址后,ret直接跳转,干净利落。
  • ARM:覆盖栈上的LR保存位置后,函数末尾的 POP {PC} 或 BX LR 才会触发跳转。

说白了,ARM的ROP链触发点不在函数入口,而在函数出口。你需要先让函数正常执行到末尾,才能劫持控制流。

避坑指南:我曾经在ARM的ROP里漏掉了LR寄存器的初始值。函数执行到末尾时,LR里还残留着上一个函数的返回地址,结果 BX LR 跳到了一个莫名其妙的地方。调试了两小时才发现——ARM的LR不是自动清零的!

4.3 MIPS延迟槽:指令执行的「时间差」

接下来聊MIPS。MIPS的栈布局和ARM类似,也是用寄存器传参(A0-A3),但MIPS有一个让所有Pwn手头疼的特性——延迟槽。

什么是延迟槽?简单说就是:分支指令后面的那条指令,无论分支是否跳转,都会先执行完。

举个例子:

beq $t0, $t1, target   # 如果相等就跳转
addiu $v0, $zero, 1    # 延迟槽指令——这条一定会执行
target:
  nop

即使 beq 条件成立跳转到target,addiu 也会先执行完。这个机制在ROP里会造成什么影响?

  • 你找到的gadget如果以分支指令结尾,后面那条指令也会被执行。
  • ROP链里的跳转不能直接用 jr $ra 后紧跟下一个gadget地址——因为延迟槽会吃掉一个指令。
  • 你需要手动填充NOP或者利用延迟槽指令做有用的事。

个人经验:MIPS的ROP里,我最常用的技巧是把延迟槽指令利用起来。比如 jr $ra 后面跟一条 addiu $sp, $sp, 16,既能满足延迟槽要求,又能调整栈指针。一举两得。

4.4 MIPS栈布局:返回地址的「藏身之处」

MIPS的栈帧布局和ARM类似,但有一个细节不同——MIPS的返回地址存在 $ra 寄存器,函数开头用 sw $ra, offset($sp) 保存到栈上。

典型的MIPS函数序言和结语:

# 函数序言
addiu $sp, $sp, -32    # 分配栈空间
sw $ra, 28($sp)        # 保存返回地址
sw $fp, 24($sp)        # 保存帧指针

# 函数结语
lw $ra, 28($sp)        # 恢复返回地址
lw $fp, 24($sp)        # 恢复帧指针
addiu $sp, $sp, 32     # 释放栈空间
jr $ra                 # 返回(注意延迟槽!)
nop                    # 延迟槽

做MIPS的栈溢出时,你覆盖的是栈上保存 $ra 的位置。但要注意——MIPS的栈空间分配是向下增长的,而且函数序言里通常会保存多个寄存器。你需要精确计算偏移量。

架构 返回地址保存位置 ROP触发指令 延迟槽
x86 栈顶(call压入) ret 无
ARM LR寄存器→栈上保存 POP {PC} / BX LR 无
MIPS $ra寄存器→栈上保存 jr $ra 有(1条指令)

4.5 异架构下的ROP挑战

了解了基础差异,我们来看看实际ROP中会遇到哪些坑。

4.5.1 Gadget搜索策略不同

x86下gadget多如牛毛,因为指令长度不固定,随便一段数据都能解释出有用的指令。ARM和MIPS的指令长度固定(ARM是4字节,MIPS也是4字节),gadget的密度低很多。

我建议的做法:

  • ARM:优先找 POP {R0-R3, PC} 和 BLX 系列gadget。
  • MIPS:优先找 jr $ra 和 lw $ra, offset($sp) 的组合。
  • 不要指望像x86那样找到「万能gadget」,异架构下往往需要多个gadget拼接。

4.5.2 栈对齐问题

ARM要求栈指针必须8字节对齐(在调用BL指令时)。如果栈没对齐,调用库函数会直接崩溃。这个问题在ROP里特别容易踩——你手动构造的栈帧很可能破坏了对齐。

调试技巧:在ARM的ROP链里,如果调用 system() 或 execve() 时崩溃,先检查SP是不是8的倍数。我遇到过好几次,加一条 ADD SP, SP, #4 就解决了。

4.5.3 MIPS的Cache一致性

MIPS有一个很恶心的硬件特性——指令Cache和数据Cache是分离的。如果你在栈上写shellcode,然后跳转过去执行,CPU可能还在执行Cache里旧的指令。

解决办法:

  • 用 cache 指令刷Cache(但需要特权级)。
  • 或者干脆不用shellcode,纯ROP链解决。
  • 在CTF里,大部分MIPS题目会关掉Cache或者用uncached地址映射。

4.6 知识结构总览

下面这张图总结了ARM和MIPS在栈布局、调用约定和ROP上的核心差异:

ARM与MIPS异架构ROP核心差异 ARM vs MIPS:异架构ROP核心差异 ARM 架构 参数传递:R0-R3 寄存器 第5个参数起才压栈 返回地址:LR寄存器 函数开头 PUSH {LR} 保存到栈 ROP触发:POP {PC} / BX LR 函数出口处劫持控制流 栈对齐要求:8字节对齐 调用BL前SP必须是8的倍数 Gadget密度:中等 指令固定4字节,需多gadget拼接 MIPS 架构 参数传递:A0-A3 寄存器 与ARM类似,寄存器优先 返回地址:$ra寄存器 sw $ra, offset($sp) 保存到栈 ROP触发:jr $ra 注意延迟槽!后面跟一条指令 延迟槽:分支后指令必执行 ROP链需填充NOP或利用延迟槽 Cache一致性:I-Cache与D-Cache分离 写shellcode后需刷Cache

4.7 实战建议

如果你刚开始接触异架构Pwn,我建议按这个顺序练习:

  1. 先ARM,后MIPS。ARM的调试工具更成熟,QEMU模拟也稳定。MIPS的延迟槽和Cache问题会让调试体验直线下降。
  2. 用QEMU用户态模拟。不需要完整的开发板,一条命令就能跑ARM/MIPS的二进制文件。加上 -g 参数还能用GDB远程调试。
  3. 准备好gadget搜索脚本。手动找gadget效率太低。我用的是 ROPgadget 配合自定义过滤器,专门筛选 POP {R0, PC} 和 jr $ra 这类关键gadget。
  4. 多写调试笔记。异架构的坑太多了,今天踩过的明天可能还会踩。我自己的笔记里记录了二十多种ARM和MIPS的「崩溃模式」,每次遇到新问题就加一条。

一个小技巧:在ARM和MIPS的ROP里,system() 函数是最常用的目标。但这两个架构下 system() 的参数传递方式不同——ARM用R0,MIPS用A0。写exp的时候别忘了区分。

好了,异架构的基础就讲到这里。这些差异看起来琐碎,但每一条都可能在实战中让你卡上半天。记住一句话:不要用x86的思维去写ARM和MIPS的exp。架构不同,玩法就不同。