第四章 异架构基础: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上的核心差异:
4.7 实战建议
如果你刚开始接触异架构Pwn,我建议按这个顺序练习:
- 先ARM,后MIPS。ARM的调试工具更成熟,QEMU模拟也稳定。MIPS的延迟槽和Cache问题会让调试体验直线下降。
- 用QEMU用户态模拟。不需要完整的开发板,一条命令就能跑ARM/MIPS的二进制文件。加上
-g参数还能用GDB远程调试。 - 准备好gadget搜索脚本。手动找gadget效率太低。我用的是
ROPgadget配合自定义过滤器,专门筛选POP {R0, PC}和jr $ra这类关键gadget。 - 多写调试笔记。异架构的坑太多了,今天踩过的明天可能还会踩。我自己的笔记里记录了二十多种ARM和MIPS的「崩溃模式」,每次遇到新问题就加一条。
一个小技巧:在ARM和MIPS的ROP里,system() 函数是最常用的目标。但这两个架构下 system() 的参数传递方式不同——ARM用R0,MIPS用A0。写exp的时候别忘了区分。
好了,异架构的基础就讲到这里。这些差异看起来琐碎,但每一条都可能在实战中让你卡上半天。记住一句话:不要用x86的思维去写ARM和MIPS的exp。架构不同,玩法就不同。