第2章:ARM汇编基础——指令格式、数据处理、加载存储、分支与Thumb
ARM汇编,是逆向工程绕不开的坎。不管是CTF里的Pwn题,还是固件分析,你总会撞上它。
我个人习惯把ARM指令集分成三块来记:数据处理、加载存储、分支控制。再加上Thumb这个“缩水版”指令集,基本就覆盖了九成以上的场景。
嗯,咱们直接开干。
2.1 ARM指令格式——先看懂“骨架”
ARM指令固定32位,这是硬性规定。你想想看,每条指令占4字节,在IDA里一眼就能认出来。
标准格式长这样:
<opcode> {<cond>} {S} <Rd>, <Rn>, <Operand2>
拆开来看:
- opcode:操作码,比如ADD、SUB、MOV。
- cond:条件码,可选。ARM每条指令都能条件执行,这是它跟x86最大的区别。
- S:后缀,加S就更新CPSR标志位。
- Rd:目标寄存器。
- Rn:第一操作数寄存器。
- Operand2:第二操作数,可以是立即数、寄存器、或者移位后的寄存器。
关键点:ARM的条件码有16种,但常用的就几个——EQ(相等)、NE(不等)、GT(大于)、LT(小于)。逆向时看到指令带后缀,比如ADDEQ R0, R1, #1,说明这条指令只在特定条件下执行。
我曾经在分析一个IoT设备固件时,看到满屏的条件执行指令,一开始以为是编译器优化,后来才发现是手工汇编写的状态机。那叫一个酸爽。
2.2 数据处理指令——算数逻辑一把抓
数据处理指令不访问内存,只在寄存器之间倒腾数据。这是ARM的RISC特性——算数和访存分开。
常用的有这么几类:
| 类别 | 指令 | 说明 |
|---|---|---|
| 算术 | ADD / SUB / RSB | 加、减、反向减 |
| 逻辑 | AND / ORR / EOR / BIC | 与、或、异或、位清零 |
| 比较 | CMP / CMN / TST / TEQ | 比较、负比较、位测试、相等测试 |
| 移动 | MOV / MVN | 赋值、按位取反赋值 |
举个例子,你逆向时经常看到这种代码:
MOV R0, #0x100 ; R0 = 0x100
ADD R1, R0, R2 ; R1 = R0 + R2
CMP R1, #0x200 ; 比较R1和0x200
BGT label ; 如果R1 > 0x200,跳转
这里有个坑:立即数不是随便写的。ARM的立即数只有8位有效值,通过循环移位得到32位数。比如0x100能直接写,但0x101就不行,得拆成两步。逆向时看到奇怪的立即数,别慌,那是编译器在凑合法格式。
小技巧:在IDA里,如果看到MOV R0, #0xFC000003这种奇怪的立即数,八成是编译器用MOV加移位拼出来的。手动算一下:0xFC000003 = 0xFF << 26 | 0x03,其实就是MVN R0, #0x3FFFFFF的另一种写法。
2.3 加载存储指令——跟内存打交道的核心
ARM是加载存储架构,只有LDR和STR能访问内存。这一点跟x86完全不同,x86可以直接在内存上做算术。
基本格式:
LDR Rd, [Rn, #offset] ; 从Rn+offset地址加载到Rd
STR Rd, [Rn, #offset] ; 将Rd存储到Rn+offset地址
变种很多,我列几个逆向时高频出现的:
- LDRB / STRB:加载/存储1字节
- LDRH / STRH:加载/存储2字节
- LDM / STM:批量加载/存储,一次操作多个寄存器
- LDR伪指令:
LDR R0, =0x12345678,编译器会自动处理成PC相对加载
注意:ARM的地址对齐要求很严格。LDR要求地址4字节对齐,LDRH要求2字节对齐。如果地址不对齐,会触发异常。逆向时看到LDR R0, [R1, #1]这种,基本可以确定R1是4字节对齐的,否则程序早崩了。
我记得有一次逆向一个路由器固件,函数开头全是STMDB SP!, {R4-R11, LR},这是典型的函数序言——把要用到的寄存器压栈。看到这个,基本就能确定函数边界了。
2.4 分支指令——控制流的命脉
ARM的分支指令不多,但每个都很关键:
| 指令 | 说明 | 跳转范围 |
|---|---|---|
| B | 无条件跳转 | ±32MB |
| BL | 跳转并保存返回地址到LR | ±32MB |
| BX | 跳转到寄存器中的地址,可切换ARM/Thumb | 任意地址 |
| BLX | BL + BX,跳转并保存返回地址,可切换模式 | 任意地址 |
这里有个非常重要的概念:LR寄存器。BL指令会把下一条指令的地址存入LR(R14),函数返回时用BX LR跳回去。这是ARM函数调用的标准套路。
逆向时,看到BL func,后面跟着MOV PC, LR或BX LR,那就是函数返回。但要注意——如果函数内部又调了别的函数,LR会被覆盖,所以函数序言里通常会把LR压栈:
func:
PUSH {LR} ; 保存返回地址
BL sub_func ; 调用子函数,LR被覆盖
POP {PC} ; 恢复LR并返回(等价于MOV PC, LR)
CTF考点:ROP攻击里经常用到POP {PC}或BX LR来劫持控制流。你只要控制了栈上的数据,就能让PC指向任意地址。ARM的ROP gadget比x86少,但更灵活——因为每条指令都能条件执行。
2.5 Thumb/Thumb-2指令集——16位的“瘦身版”
ARM指令虽然强大,但32位太占空间。嵌入式设备里,代码密度很重要,所以ARM推出了Thumb指令集。
Thumb的特点:
- 指令长度16位,代码密度提升约30%
- 功能是ARM指令的子集,很多操作受限
- 只能访问R0-R7(低8个寄存器)
- 没有条件执行(除了条件分支)
Thumb-2的改进:
- 混合16位和32位指令
- 恢复了ARM指令的大部分功能
- 不需要在ARM和Thumb之间切换
怎么区分当前是ARM还是Thumb?看PC的最低位:如果PC最低位是1,表示Thumb模式;是0,表示ARM模式。BX和BLX跳转时,就是通过最低位来切换模式的。
实战经验:在IDA里,如果反汇编出来的代码全是乱码,八成是模式搞错了。按Alt+G手动切换ARM/Thumb模式,或者检查函数入口地址的最低位。我遇到过好几次,固件里ARM和Thumb混着用,跳来跳去,不仔细看根本发现不了。
举个例子,Thumb下的函数调用:
; Thumb模式
PUSH {LR} ; 16位指令
BL sub_func ; 32位指令(Thumb-2)
POP {PC} ; 16位指令
看着跟ARM差不多,但指令编码完全不同。逆向时,Thumb的指令密度高,但每条指令能干的事少,所以函数体通常更长。
2.6 本章知识体系
下面这张图,把ARM汇编的核心脉络串起来了。我建议你把它存下来,以后逆向时当速查表用。
总结一下:ARM汇编的核心就三件事——数据处理(算数逻辑)、加载存储(访存)、分支(控制流)。Thumb是ARM的压缩版,逆向时注意模式切换。把这套东西吃透了,ARM逆向就入门了。
嗯,这一章的内容就到这儿。记住,看汇编别死记硬背,多动手反汇编几个固件,自然就熟了。