第二章 ARM架构Pwn入门:ARM32/64函数调用约定、栈帧布局与ret2libc实战
ARM架构的漏洞利用,说白了就是跟它的函数调用约定和栈帧布局打交道。很多搞x86的朋友刚转过来时,第一反应是「这寄存器也太多了吧?」——嗯,确实多,但用熟了你会发现,ARM的规则其实比x86更规整。
我个人习惯把ARM的调用约定比作「快递分拣系统」:参数怎么放、返回值怎么取、栈帧怎么搭,都有固定流程。搞懂这套流程,ret2libc就是个顺水推舟的事。
2.1 ARM32与ARM64的函数调用约定
先看最核心的区别。ARM32用ATPCS(ARM Thumb Procedure Call Standard),ARM64用AAPCS64。名字不重要,记住参数传递规则就行。
| 架构 | 参数传递寄存器 | 返回值寄存器 | 栈帧寄存器 | 链接寄存器 |
|---|---|---|---|---|
| ARM32 | r0-r3(前4个参数) | r0 | r11(FP) | r14(LR) |
| ARM64 | x0-x7(前8个参数) | x0 | x29(FP) | x30(LR) |
注意看,ARM64的参数寄存器翻了一倍。这意味着什么?你想想看,在ARM64上构造ROP时,gadget的选择面更宽,但同时也意味着你需要控制的寄存器更多。
关键点:ARM32的r4-r11是callee-saved寄存器,调用者必须保证它们被恢复。ARM64的x19-x28同理。做ret2libc时,这些寄存器往往就是你的「跳板」。
2.2 栈帧布局:从入口到出口
ARM的栈帧布局,我建议你画个图记在脑子里。它不像x86那样用ebp/rbp做固定基址,ARM的FP(帧指针)是可选的——但GCC默认会生成。
一个典型的ARM32函数入口是这样的:
push {r11, lr} ; 保存FP和LR
mov r11, sp ; 设置新的FP
sub sp, sp, #16 ; 分配局部变量空间
出口则是:
mov sp, r11 ; 恢复SP
pop {r11, pc} ; 恢复FP,同时跳转到LR指向的地址
这里有个坑——pop {r11, pc}这条指令,直接把LR的值弹给了PC。这意味着什么?如果你能控制栈上的LR值,就能控制程序流。我在CTF里见过太多新手盯着x86的ret指令找,却忽略了ARM的这条「隐式跳转」。
避坑指南:我曾经在分析一个IoT固件时,花了两个小时没找到ret指令,后来才发现ARM用的是pop {pc}或bx lr。ARM32的返回机制不是单一的ret,而是通过加载PC寄存器实现。这一点和x86完全不同。
ARM64的栈帧更规整一些:
stp x29, x30, [sp, #-16]! ; 保存FP和LR,同时调整SP
mov x29, sp ; 设置FP
sub sp, sp, #32 ; 分配局部变量
出口:
ldp x29, x30, [sp], #16 ; 恢复FP和LR
ret ; 跳转到x30
ARM64的ret指令终于出现了,但它跳转的目标是x30(LR),不是栈。所以ARM64的利用思路和ARM32略有不同——你需要控制x30,而不是直接控制栈上的返回地址。
2.3 ret2libc实战:从原理到利用
ret2libc的核心思路很简单:程序没有system("/bin/sh"),但libc里有。我们只需要把栈布局成「调用system,参数指向"/bin/sh"」的样子。
但在ARM上,这个「样子」和x86完全不同。x86是栈上放返回地址+参数,ARM是寄存器传参。所以ARM的ret2libc,本质上是ROP——你需要gadget来设置r0(或x0)。
来看一个ARM32的典型利用布局:
栈布局(从低地址到高地址):
[gadget: pop {r0, pc}] ; 设置r0并跳转
[addr of "/bin/sh"] ; 被pop到r0
[addr of system] ; 被pop到pc
为什么这样布局?因为pop {r0, pc}这条gadget会从栈上连续弹出两个值:第一个给r0,第二个给pc。pc被设置为system的地址后,r0已经指向了"/bin/sh",system执行时自然就拿到了shell。
实战技巧:找ARM32的pop {r0, pc} gadget,可以用ROPgadget --binary libc.so --only "pop"。我个人习惯先搜pop {r0, pc},如果找不到,再放宽到pop {r3, pc}配合mov r0, r3的组合。
ARM64的ret2libc稍微复杂一点,因为参数寄存器更多,但思路一样:
栈布局:
[gadget: ldp x0, x30, [sp], #16] ; 设置x0和x30
[addr of "/bin/sh"] ; 被ldp到x0
[addr of system] ; 被ldp到x30
[任意填充] ; 对齐栈
ARM64的ldp x0, x30, [sp], #16会从栈上加载两个64位值到x0和x30,然后SP增加16。x30被设置为system地址后,下一个ret指令就会跳转到system。
2.4 知识体系总览
下面这张图把ARM32/64的调用约定、栈帧布局和ret2libc的利用路径串在了一起。我建议你把它当作「作战地图」——每次做题前先看一遍,心里就有底了。
2.5 实战步骤:从分析到利用
好了,理论说完了,咱们来走一遍实战流程。假设你拿到了一个ARM32的pwn题,二进制是静态链接的,或者你知道libc版本。
- 确定溢出偏移:用cyclic生成模式串,看程序崩溃时r11(FP)或LR被覆盖成了什么值。ARM32的溢出偏移通常比x86小,因为栈帧更紧凑。
- 泄露libc基址:如果程序有输出函数(puts、printf等),通过ROP调用它来泄露某个GOT表项的内容。计算偏移得到libc基址。
- 找gadget:在libc中搜索
pop {r0, pc}或等效gadget。注意ARM的指令长度——ARM模式是4字节,Thumb模式是2字节。gadget地址的最低位决定了模式切换。 - 构造ROP链:栈布局为 [pop_r0_pc] + [addr_binsh] + [addr_system]。
- 触发:发送payload,拿到shell。
避坑指南:我曾经在Thumb模式下找gadget,忘了把地址最低位置1,结果程序一直崩溃。ARM的Thumb模式要求地址最低位为1,ARM模式为0。这个细节能卡你半小时。
2.6 ARM64的特殊之处
ARM64的ret2libc和ARM32有几点关键区别,我列个表方便你对比:
| 对比项 | ARM32 | ARM64 |
|---|---|---|
| 返回机制 | pop {pc} 或 bx lr | ret(跳转x30) |
| 参数设置gadget | pop {r0, pc} | ldp x0, x30, [sp], #16 |
| 栈对齐要求 | 4字节对齐 | 16字节对齐 |
| 指令长度 | 4字节(ARM)/ 2字节(Thumb) | 4字节固定 |
| 地址空间 | 32位 | 64位(注意高地址问题) |
ARM64的栈对齐要求是个大坑。如果SP不是16字节对齐,调用system时会直接SIGBUS。我建议你在构造payload时,先计算一下栈偏移,确保最终SP是16的倍数。
个人经验:ARM64的ROP链调试,我习惯用QEMU的user模式加GDB。命令是qemu-aarch64 -g 1234 ./vuln,然后另开终端aarch64-linux-gnu-gdb ./vuln,连上后target remote :1234。这样能单步看寄存器变化,比盲猜高效得多。
2.7 本章小结
ARM的ret2libc,说白了就是三件事:搞懂参数怎么传、找到控制参数寄存器的gadget、把栈布局摆对。ARM32和ARM64的差异主要在寄存器数量和指令格式上,核心思路完全一致。
嗯,这里要注意一点:不要死记硬背gadget的地址。每次libc版本不同,gadget偏移都会变。学会用工具(ROPgadget、ropper)动态搜索,才是正道。