第二章 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的利用路径串在了一起。我建议你把它当作「作战地图」——每次做题前先看一遍,心里就有底了。

ARM架构Pwn知识体系:调用约定 → 栈帧 → ret2libc ARM32 调用约定 参数: r0-r3 返回: r0 LR: r14 | FP: r11 ARM64 调用约定 参数: x0-x7 返回: x0 LR: x30 | FP: x29 栈帧布局 push {FP, LR} mov FP, SP sub SP, #N 漏洞利用核心:控制LR/返回地址 ARM32: pop {pc} 或 pop {r0, pc} ARM64: ldp x0, x30, [sp], #16 + ret ret2libc 实战 找gadget → 设置参数寄存器 → 跳转到system ARM32: pop {r0, pc} + "/bin/sh" + system ARM64: ldp x0, x30, [sp], #16 + "/bin/sh" + system 💡 核心思路:无论ARM32还是ARM64,ret2libc的本质都是「控制参数寄存器 + 控制PC/x30」 区别在于:ARM32用pop系列gadget,ARM64用ldp系列gadget

2.5 实战步骤:从分析到利用

好了,理论说完了,咱们来走一遍实战流程。假设你拿到了一个ARM32的pwn题,二进制是静态链接的,或者你知道libc版本。

  1. 确定溢出偏移:用cyclic生成模式串,看程序崩溃时r11(FP)或LR被覆盖成了什么值。ARM32的溢出偏移通常比x86小,因为栈帧更紧凑。
  2. 泄露libc基址:如果程序有输出函数(puts、printf等),通过ROP调用它来泄露某个GOT表项的内容。计算偏移得到libc基址。
  3. 找gadget:在libc中搜索pop {r0, pc}或等效gadget。注意ARM的指令长度——ARM模式是4字节,Thumb模式是2字节。gadget地址的最低位决定了模式切换。
  4. 构造ROP链:栈布局为 [pop_r0_pc] + [addr_binsh] + [addr_system]。
  5. 触发:发送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)动态搜索,才是正道。