第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时算偏移算到吐血 |
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
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世界的函数调用规矩。说白了,就是规定“谁负责保存哪些寄存器、参数怎么传、返回值放哪”。
我把它拆成四条核心规则:
- 参数传递: R0-R3传前4个参数,多余的压栈。返回值放R0。
- 寄存器归属: R4-R11是“被调用者保存”(callee-saved),函数里用了就得恢复原样。R0-R3是“调用者保存”(caller-saved),函数可以随便改。
- 栈帧布局: FP(R11)可选,SP(R13)必须指向当前栈帧底部。LR(R14)存返回地址。
- 指令集切换: 通过BX/BLX的最低bit决定ARM还是Thumb。
🎯 核心考点: 写shellcode时,如果你调用了某个函数,必须假设R0-R3和R12都被破坏了。R4-R11可以放心用,但函数返回前要恢复原值。这个规则在构造ROP链时尤其重要——我曾经因为没保存R4,导致ROP链执行到一半栈帧全乱,shellcode直接崩成渣。
2.4 知识体系总览
下面这张图把本章的核心脉络串起来了。我建议你把它存下来,写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。
-d in_asm看指令流。这样能快速发现PC偏移算错、指令集模式不对、系统调用号放错位置这类低级错误。等QEMU上跑通了,再上真机或靶机测试。