第4章:ARM汇编入门:数据处理、加载存储与分支指令
ARM架构在移动设备、嵌入式系统和IoT设备中占据统治地位。做Pwn题时,你迟早会遇到ARM架构的题目——可能是路由器固件、手机内核驱动,或者某个IoT设备的漏洞利用。我刚开始接触ARM时,最大的感受是:这玩意儿和x86完全不是一个思路。
ARM是典型的RISC(精简指令集计算机)架构。指令长度固定,大部分指令单周期执行。说白了,每条指令干的事都很纯粹,不像x86那样一条指令能搞出一堆副作用。这种设计让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 | 无条件(默认) | 总是执行 |
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)等。多数据加载指令在保存和恢复上下文时非常高效——一条指令就能搞定寄存器组的入栈出栈。
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编写中的角色:
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保存返回地址,比看十遍文档都管用。