第4章:ARM汇编入门:数据处理、加载存储与分支指令

ARM架构在移动设备、嵌入式系统和IoT设备中占据统治地位。做Pwn题时,你迟早会遇到ARM架构的题目——可能是路由器固件、手机内核驱动,或者某个IoT设备的漏洞利用。我刚开始接触ARM时,最大的感受是:这玩意儿和x86完全不是一个思路。

ARM是典型的RISC(精简指令集计算机)架构。指令长度固定,大部分指令单周期执行。说白了,每条指令干的事都很纯粹,不像x86那样一条指令能搞出一堆副作用。这种设计让ARM在功耗和性能之间取得了很好的平衡,但也意味着写Shellcode时,你需要更精细地控制每一步。

本章核心目标:掌握ARM数据处理指令、加载存储指令和分支指令的用法,为后续编写ARM Shellcode打下基础。

4.1 ARM指令格式速览

ARM指令是固定32位长度。格式大致如下:

指令 条件码 目标寄存器, 源操作数1, 源操作数2

举个例子:

ADD R0, R1, R2    ; R0 = R1 + R2
SUB R3, R4, #5    ; R3 = R4 - 5

注意,ARM的指令可以带条件执行后缀。比如 ADDEQ R0, R1, R2 表示只有在上次比较结果为相等(EQ)时才执行加法。这个特性在x86里是没有的,我当年第一次看到时还愣了一下。

条件码 含义 标志位条件
EQ 相等 Z=1
NE 不相等 Z=0
GT 有符号大于 Z=0且N=V
LT 有符号小于 N!=V
AL 无条件(默认) 总是执行
个人习惯:写ARM Shellcode时,我几乎只用AL条件。条件执行在正常代码里很有用,但在Shellcode中容易引入不必要的复杂性。保持简单,别炫技。

4.2 数据处理指令

数据处理指令是ARM的核心。它们对寄存器中的数据进行算术或逻辑运算,结果写回目标寄存器。注意,ARM的数据处理指令不访问内存——这是RISC的基本哲学。

4.2.1 算术运算

ADD R0, R1, R2    ; R0 = R1 + R2
ADC R0, R1, R2    ; R0 = R1 + R2 + 进位标志
SUB R0, R1, R2    ; R0 = R1 - R2
SBC R0, R1, R2    ; R0 = R1 - R2 - 借位标志
RSB R0, R1, R2    ; R0 = R2 - R1(反向减法)

这里有个坑:RSB 是ARM特有的反向减法。我在写一个ARM Shellcode时,需要计算 0x1000 - R1,一开始写成了 SUB R0, #0x1000, R1,结果编译报错。ARM的SUB不支持立即数作为第一操作数。正确写法是 RSB R0, R1, #0x1000。

4.2.2 逻辑运算

AND R0, R1, R2    ; R0 = R1 & R2
ORR R0, R1, R2    ; R0 = R1 | R2
EOR R0, R1, R2    ; R0 = R1 ^ R2
BIC R0, R1, R2    ; R0 = R1 & (~R2) 位清除

BIC 指令在Shellcode中很实用。比如你想把R1的低8位清零,可以写 BIC R0, R1, #0xFF。这比先取反再AND要简洁得多。

4.2.3 移位操作

ARM的移位操作不是独立指令,而是作为第二个操作数的修饰符:

MOV R0, R1, LSL #2    ; R0 = R1 << 2
MOV R0, R1, LSR #3    ; R0 = R1 >> 3(逻辑右移)
MOV R0, R1, ASR #1    ; R0 = R1 >> 1(算术右移)
MOV R0, R1, ROR #4    ; R0 = R1 循环右移4位

这种设计让ARM可以在一条指令里同时完成移位和运算。比如 ADD R0, R1, R2, LSL #4 等价于 R0 = R1 + (R2 << 4)。我在构造Shellcode地址时经常用这个技巧,省指令就是省字节。

4.3 加载存储指令

ARM不能直接在内存和内存之间传数据,也不能在指令里直接操作内存。所有内存访问必须通过加载存储指令完成。这是RISC的另一个核心原则。

4.3.1 单数据加载/存储

LDR R0, [R1]       ; R0 = *R1(从R1指向的地址加载4字节)
STR R0, [R1]       ; *R1 = R0(将R0存储到R1指向的地址)
LDRB R0, [R1]      ; R0 = *R1(加载1字节)
STRB R0, [R1]      ; *R1 = R0(存储1字节)

这里有个重要的变体——索引寻址:

LDR R0, [R1, #4]    ; R0 = *(R1 + 4) 前索引
LDR R0, [R1, #4]!   ; R0 = *(R1 + 4),然后R1 += 4 前索引并更新
LDR R0, [R1], #4    ; R0 = *R1,然后R1 += 4 后索引

后索引模式在遍历数组时特别方便。我记得有一次写ARM Shellcode需要逐字节拷贝数据,用 LDRB R0, [R1], #1 配合 STRB R0, [R2], #1,两行指令就搞定了循环体。

4.3.2 多数据加载/存储

ARM支持一条指令加载或存储多个寄存器:

LDMIA R0!, {R1-R4}    ; 从R0指向的地址加载R1,R2,R3,R4,R0自动增加
STMIA R0!, {R1-R4}    ; 将R1-R4存储到R0指向的地址,R0自动增加

后缀IA表示Increment After(先存储,后增加地址)。其他后缀包括DB(Decrement Before)、IB(Increment Before)等。多数据加载指令在保存和恢复上下文时非常高效——一条指令就能搞定寄存器组的入栈出栈。

我曾经踩过的坑:LDM/STM指令的寄存器列表顺序不影响实际加载顺序。ARM规定:编号最小的寄存器总是加载到最低地址。如果你以为列表顺序就是内存顺序,调试时会疯掉。

4.4 分支指令

分支指令控制程序的执行流。ARM的分支指令相对简单,但有一些细节需要注意。

4.4.1 无条件分支

B label       ; 跳转到label(相对跳转,范围±32MB)
BL label      ; 跳转并保存返回地址到LR(链接寄存器)
BX R0         ; 跳转到R0指向的地址(绝对跳转)
BLX R0        ; 跳转到R0指向的地址并保存返回地址到LR

BL 是调用子程序的标准方式。执行 BL func 后,LR(R14)会保存返回地址,然后PC跳转到func。子程序返回时用 MOV PC, LR 或 BX LR。

这里有个关键区别:B 和 BL 是相对跳转,而 BX 和 BLX 是绝对跳转。写Shellcode时,我几乎只用 BX 和 BLX,因为Shellcode的地址在运行时才能确定,相对跳转容易算错偏移。

4.4.2 条件分支

BEQ label     ; 如果相等则跳转
BNE label     ; 如果不相等则跳转
BGT label     ; 如果有符号大于则跳转
BLT label     ; 如果有符号小于则跳转

条件分支依赖CPSR(当前程序状态寄存器)中的标志位。标志位由数据处理指令设置:

CMP R0, R1    ; 比较R0和R1,设置标志位
TST R0, R1    ; 测试R0 & R1,设置标志位
TEQ R0, R1    ; 测试R0 ^ R1,设置标志位

CMP 本质上就是 SUBS(减法并设置标志),只是不保存结果。我习惯在条件分支前先用 CMP 比较,然后用对应的条件分支指令。

4.5 本章知识体系

下面这张图展示了ARM指令三大类之间的关系,以及它们在Shellcode编写中的角色:

ARM指令三大类与Shellcode关系 ARM指令集 数据处理指令 加载存储指令 分支指令 算术/逻辑 移位/比较 单数据L/S 多数据L/S 无条件/条件 Shellcode编写应用 构造地址 → 加载数据 → 运算处理 → 系统调用

4.6 实战小练习

光看不练等于白学。这里给一个简单的ARM汇编练习,你可以用QEMU模拟器跑一下:

.text
.global _start
_start:
    @ 加载两个数到寄存器
    LDR R0, =0x12345678
    LDR R1, =0x87654321
    
    @ 加法运算
    ADD R2, R0, R1
    
    @ 比较并条件跳转
    CMP R2, #0x99999999
    BGT greater
    
    @ 如果小于等于,加载0xFF
    MOV R3, #0xFF
    B end
    
greater:
    @ 如果大于,加载0xAA
    MOV R3, #0xAA
    
end:
    @ 无限循环(模拟程序结束)
    B end

这个例子涵盖了本章讲的大部分指令:LDR加载立即数、ADD加法、CMP比较、BGT条件分支、B无条件分支。你可以单步调试,观察寄存器的变化。

调试建议:用 qemu-arm -g 1234 a.out 启动调试服务器,然后用GDB远程连接。设置断点在每条指令后,用 info registers 查看寄存器状态。亲眼看到PC跳转、LR保存返回地址,比看十遍文档都管用。