第2章:ARM架构基础——寄存器、指令集与调用约定

ARM架构,说实话,是异架构Pwn里绕不开的一座山。x86你玩得再溜,遇到ARM靶机照样得从头捋寄存器。我当年第一次接触ARM时,看着那堆R0-R15直接懵了——怎么连个通用寄存器都这么少?后来踩的坑多了,才明白这套精简设计背后的逻辑。

这一章,咱们就把ARM的底裤扒干净。寄存器体系、指令集概览、ATPCS调用约定,三块内容吃透了,后面写shellcode才能下笔如有神。

2.1 ARM寄存器体系

ARM处理器有37个寄存器,但咱们Pwn选手真正关心的,是那16个“可见”寄存器——R0到R15。嗯,这里有个小陷阱:R15其实不是通用寄存器,它是PC(程序计数器)。

寄存器 别名 作用 我踩过的坑
R0-R3 参数/返回值 传递前4个参数,R0也存返回值 曾经以为R0只能存返回值,结果传参时数据全乱了
R4-R11 通用/变量 函数内保存局部变量,调用者需保护 写shellcode时忘了压栈R4,函数返回直接崩
R12 IP(内部调用暂存) 链接器临时用,别指望它保存数据 —
R13 SP(栈指针) 指向当前栈顶 ARM的SP是满递减栈,和x86方向相反!
R14 LR(链接寄存器) 保存函数返回地址 递归函数忘了保存LR,直接死循环
R15 PC(程序计数器) 指向当前指令+8(流水线效应) ROP时算偏移算到吐血
⚠️ 新手必看: ARM的PC指向当前指令地址+8(ARM模式)或+4(Thumb模式)。这是因为ARM用了三级流水线。写shellcode做PC相对寻址时,这个偏移量算错一步,shellcode就飞了。我曾经在CTF赛场上因为这个偏移算错,debug了整整两小时。

2.2 ARM指令集概览

ARM指令集分两大派系:ARM(32位指令)和Thumb(16位指令)。Thumb又分Thumb-1和Thumb-2。咱们写shellcode,Thumb模式用得最多——指令短,ROP gadget好找。

我个人习惯把常用指令分成四类,记牢了基本能应付90%的shellcode场景:

2.2.1 数据传送类

MOV R0, #0x42      ; R0 = 0x42
LDR R1, [R2]       ; R1 = *R2(从内存加载)
STR R1, [R2]       ; *R2 = R1(存入内存)
LDMIA R0!, {R1-R3} ; 批量加载,R0自增
STMDB SP!, {R4-R7} ; 批量存储,SP自减(压栈)

这里有个细节:LDMIA的“IA”是Increment After,STMDB的“DB”是Decrement Before。ARM的栈操作方向跟x86相反,写shellcode时千万别搞混。我刚开始写ARM shellcode时,就因为压栈顺序搞反,导致ROP链全部错位。

2.2.2 算术逻辑类

ADD R0, R1, R2     ; R0 = R1 + R2
SUB R0, R1, #1     ; R0 = R1 - 1
EOR R0, R1, R2     ; R0 = R1 ^ R2(异或,shellcode常用)
AND R0, R1, #0xFF  ; R0 = R1 & 0xFF
💡 实战技巧: 写shellcode时,EOR指令特别有用。你想清零一个寄存器?EOR R0, R0, R0比MOV R0, #0更短,而且不依赖立即数构造。我在写ARM shellcode时几乎必用这个trick。

2.2.3 分支跳转类

B label           ; 无条件跳转(±32MB范围)
BL func           ; 跳转并保存返回地址到LR
BX R0             ; 跳转到R0指向的地址(切换指令集!)
BLX R0            ; BL + BX 合体

BX和BLX有个隐藏属性:它们会根据目标地址的最低位自动切换ARM/Thumb模式。最低位为1表示Thumb,为0表示ARM。这个特性在ROP里经常被利用——你想想看,如果目标地址是0x10001,实际执行的是0x10000处的Thumb指令。我见过不少新手因为这个特性,ROP链跳转后直接SIGILL。

2.2.4 系统调用类

SVC #0            ; 触发系统调用(旧称SWI)

ARM Linux的系统调用号放在R7里,参数在R0-R5,通过SVC #0触发。跟x86的int 0x80一个道理,但寄存器用法完全不同。

2.3 ATPCS调用约定

ATPCS(ARM-Thumb Procedure Call Standard)是ARM世界的函数调用规矩。说白了,就是规定“谁负责保存哪些寄存器、参数怎么传、返回值放哪”。

我把它拆成四条核心规则:

  1. 参数传递: R0-R3传前4个参数,多余的压栈。返回值放R0。
  2. 寄存器归属: R4-R11是“被调用者保存”(callee-saved),函数里用了就得恢复原样。R0-R3是“调用者保存”(caller-saved),函数可以随便改。
  3. 栈帧布局: FP(R11)可选,SP(R13)必须指向当前栈帧底部。LR(R14)存返回地址。
  4. 指令集切换: 通过BX/BLX的最低bit决定ARM还是Thumb。

🎯 核心考点: 写shellcode时,如果你调用了某个函数,必须假设R0-R3和R12都被破坏了。R4-R11可以放心用,但函数返回前要恢复原值。这个规则在构造ROP链时尤其重要——我曾经因为没保存R4,导致ROP链执行到一半栈帧全乱,shellcode直接崩成渣。

2.4 知识体系总览

下面这张图把本章的核心脉络串起来了。我建议你把它存下来,写shellcode时对照着看:

ARM架构基础核心知识体系 ARM架构基础核心知识体系 寄存器体系 指令集概览 ATPCS调用约定 R0-R3 参数 R4-R11 变量 SP/LR/PC CPSR/SPSR ARM模式 Thumb模式 数据传送 算术逻辑 分支跳转 系统调用 R0-R3传参 R4-R11保存 LR返回地址 SP栈帧管理 写shellcode前必问自己: 当前是ARM还是Thumb?参数在R0-R3还是栈上?LR有没有被覆盖?SP方向对不对? 三大模块相互依赖:寄存器是指令的操作对象,指令集决定编码方式,ATPCS约束函数间协作 Pwn实战中,90%的ARM shellcode问题出在这三块的交叉点上

2.5 实战避坑清单

最后,我把自己这些年踩过的ARM坑整理成一份清单。写shellcode前过一遍,能省下大量debug时间:

  • PC偏移量: ARM模式下LDR PC, [PC, #offset]的offset要算上8字节流水线偏移。Thumb模式是4字节。我曾经因为这个偏移算错,ROP链直接跳到了指令中间。
  • 栈对齐: ARM要求SP在函数调用时8字节对齐(AAPCS标准)。不对齐的话,某些LDRD/STRD指令会崩。我见过一个CTF题目专门利用不对齐栈来卡shellcode。
  • 立即数构造: ARM的立即数不是随便写的。它由8位常数循环右移偶数位得到。想构造0x12345678?得用MOV+ORR组合,或者LDR R0, =0x12345678(汇编器会帮你处理成文字池加载)。
  • Thumb vs ARM切换: 用BX跳转时,目标地址最低位为1表示Thumb,0表示ARM。写shellcode时如果忘了置位最低位,CPU会尝试以ARM模式执行Thumb指令——结果就是非法指令异常。
  • 系统调用号: ARM Linux的系统调用号在R7,不是x86的RAX。我第一次写ARM shellcode时习惯性地把号放R0,结果调了半天都返回-ENOSYS。
🔧 我的调试习惯: 写ARM shellcode时,我习惯先用QEMU用户态模拟跑一遍,开-d in_asm看指令流。这样能快速发现PC偏移算错、指令集模式不对、系统调用号放错位置这类低级错误。等QEMU上跑通了,再上真机或靶机测试。