第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汇编核心知识体系 ARM指令集 数据处理指令 加载存储指令 分支指令 Thumb/Thumb-2 ADD/SUB/RSB AND/ORR/EOR CMP/TST MOV/MVN LDR/STR LDRB/LDRH LDM/STM LDR伪指令 B(无条件) BL(保存LR) BX(切换模式) BLX(组合) 16位指令 Thumb-2混合 模式切换 代码密度优化 核心:每条ARM指令都可条件执行 | Thumb通过PC最低位切换模式 | 加载存储是唯一访存途径

总结一下:ARM汇编的核心就三件事——数据处理(算数逻辑)、加载存储(访存)、分支(控制流)。Thumb是ARM的压缩版,逆向时注意模式切换。把这套东西吃透了,ARM逆向就入门了。

嗯,这一章的内容就到这儿。记住,看汇编别死记硬背,多动手反汇编几个固件,自然就熟了。