4、ARM汇编基础(下):分支指令、内存访问指令、栈操作指令

好,咱们接着往下聊。上一节我们把ARM的寄存器模型和基础数据处理指令过了一遍,那些是拼积木的零件。这一节要讲的,是真正让程序“活”起来的东西——怎么跳转、怎么读写内存、怎么用栈。说白了,没有这些,你的shellcode就是一坨死数据。

我个人习惯把这三类指令称为“控制流三件套”。你在写ARM架构的exploit时,90%的调试时间都会耗在这上面。尤其是栈操作,我当年第一次写ARM64的shellcode时,就因为没搞懂STP的压栈顺序,把返回地址给覆盖错了,直接panic在目标机器上。嗯,那种感觉,记忆犹新。

4.1 分支指令:让程序学会“拐弯”

分支指令,说白了就是GOTO。但ARM的分支比x86要讲究得多,因为它有条件执行这个祖传特性。

4.1.1 无条件分支:B 和 BL

B label 就是直接跳,不保存返回地址。这玩意儿在shellcode里用得少,因为你跳走了就回不来了。

BL label 才是核心。它会把当前PC+4(下一条指令的地址)存入LR寄存器(R14),然后跳转到目标地址。为什么重要?因为你要调用函数,就得靠BL。函数执行完,用BX LR就能回来。

关键区别:

  • B:跳过去,不回头。适合死循环或绝对跳转。
  • BL:跳过去,还能回来。函数调用的基石。
  • BX / BLX:可以切换指令集(ARM ↔ Thumb)。写shellcode时,经常用BX Rm来跳转到寄存器里的地址,实现动态跳转。
// 一个简单的函数调用示例
_start:
    BL  my_function    // 跳转到my_function,LR = 0x8004
    // 回来继续执行
    MOV R0, #0

my_function:
    ADD R0, R1, R2
    BX  LR             // 返回调用处

4.1.2 条件分支:ARM的祖传手艺

ARM指令集最骚的地方在于——几乎每条指令都可以带条件执行。x86里你得先CMP,再JE/JNE,ARM里可以直接写ADDEQ R0, R1, R2,意思是“如果上次比较结果相等,才执行这条加法”。

为什么会这样?因为ARM指令的高4位(bit[31:28])就是条件码字段。每条指令都自带一个“if门”。

条件码 助记符 含义 标志位条件
0000 EQ 相等 Z=1
0001 NE 不相等 Z=0
1010 GE 有符号大于等于 N=V
1011 LT 有符号小于 N!=V
1100 GT 有符号大于 Z=0且N=V
1101 LE 有符号小于等于 Z=1或N!=V
1110 AL 无条件(默认) —

避坑指南:我曾经在写ARM Thumb shellcode时,忘了Thumb模式下条件执行只对分支指令有效。结果写了个ADDEQ,编译出来直接报错。Thumb为了压缩指令长度,砍掉了大部分指令的条件执行能力。如果你在Thumb模式下看到奇怪的编译错误,先检查是不是用了条件执行。

4.2 内存访问指令:读写数据的关键

ARM是加载/存储架构。什么意思?就是你不能像x86那样直接ADD [addr], R0。你必须先LDR把数据从内存读到寄存器,算完了再用STR写回去。麻烦吗?习惯了就好。

4.2.1 单数据加载/存储:LDR 和 STR

这是最基础的。格式很简单:

LDR R0, [R1]        // 从R1指向的地址读4字节到R0
STR R0, [R1]        // 把R0的值写入R1指向的地址

但ARM的寻址模式比x86丰富得多。我个人最常用的是这三种:

  • 前变址:LDR R0, [R1, #4] — 地址是R1+4,R1不变。
  • 后变址:LDR R0, [R1], #4 — 地址是R1,然后R1自增4。这玩意儿在遍历数组时特别好用。
  • 前变址并回写:LDR R0, [R1, #4]! — 地址是R1+4,然后R1也变成R1+4。注意那个感叹号,它表示“写完地址后更新基址寄存器”。

注意:后变址和回写模式会修改基址寄存器。如果你不小心把栈指针SP给改了,程序立马崩溃。我调试过一个bug,就是有人在LDR SP, [SP, #-4]!里写错了偏移量,结果SP指向了无效内存,整个调用栈全乱了。

4.2.2 多数据加载/存储:LDM 和 STM

这组指令可以一次加载/存储多个寄存器。格式是:

LDMIA R0!, {R1-R4, R7}   // 从R0指向的地址连续加载,存入R1,R2,R3,R4,R7
STMDB SP!, {R0-R3, LR}   // 把R0-R3和LR压入栈

后缀的含义决定了地址的增长方向:

后缀 含义 地址变化
IA 先传后增 地址递增
IB 先增后传 地址递增
DA 先传后减 地址递减
DB 先减后传 地址递减

你想想看,STMDB SP!, {R0-R3, LR} 其实就是函数入口处的压栈操作。DB表示“先减地址再存”,正好符合栈向下生长的特性。

4.3 栈操作指令:函数调用的基石

ARM没有专门的PUSH/POP指令(至少在ARM模式下没有)。栈操作是通过STM和LDM配合SP寄存器完成的。Thumb模式下倒是有PUSH/POP,但那只是语法糖。

4.3.1 压栈与出栈的标准模式

函数入口的标准压栈:

PUSH {R4-R7, LR}    // Thumb模式,等价于STMDB SP!, {R4-R7, LR}
// 或者ARM模式:
STMDB SP!, {R4-R7, LR}

函数出口的标准出栈:

POP  {R4-R7, PC}    // Thumb模式,等价于LDMIA SP!, {R4-R7, PC}
// 或者ARM模式:
LDMIA SP!, {R4-R7, PC}

注意看,出栈时把LR的值弹给了PC。这就是函数返回的本质——把之前保存的返回地址直接塞进PC,CPU就跳回去了。

核心要点:

  • ARM模式下,栈操作就是STMDB(压栈)和LDMIA(出栈)。
  • Thumb模式下,直接用PUSH/POP更直观。
  • 寄存器列表的书写顺序不影响压栈顺序——ARM规定编号小的寄存器在低地址。
  • 出栈时把LR弹给PC,是实现ROP gadget的常见手法。

4.3.2 栈帧布局

一个典型的ARM函数栈帧长这样:

高地址
+-----------------+
| 调用者栈帧       |
+-----------------+
| 返回地址 (LR)    |  ← SP指向这里(刚压完栈)
+-----------------+
| R7 (帧指针)      |
+-----------------+
| R4-R6 (被保存的) |
+-----------------+
| 局部变量         |  ← 向下生长
| ...              |
+-----------------+
低地址

写shellcode时,我经常手动构造这个布局。比如要伪造一个返回地址,就在栈上摆好数据,然后执行POP {PC}。嗯,这就是最朴素的ROP。

4.4 本章知识体系

下面这张图把三类指令的关系画清楚了。你写shellcode时,脑子里要有这张图:

ARM分支、内存、栈指令关系图 ARM汇编核心指令体系(第4章) 分支指令 内存访问指令 栈操作指令 B / BL / BX 条件分支 LDR / STR LDM / STM PUSH / POP 栈帧布局 写shellcode时的核心原则 ① 用BL调用函数,用BX LR返回 ② 内存访问必须通过LDR/STR,不能直接操作 ③ 栈操作要保证PUSH和POP数量一致,否则返回地址会乱

4.5 实战中的常见陷阱

写ARM shellcode时,这几个坑我几乎每次都遇到:

  1. LR被覆盖:如果你在函数里又调了别的函数,LR会被第二次BL覆盖。记得先PUSH LR保存。
  2. SP对齐:ARM要求栈指针8字节对齐(ARM64是16字节)。不对齐的话,某些LDRD/STRD指令会触发异常。
  3. Thumb vs ARM混用:用BX切换模式时,目标地址的最低位必须是1(Thumb)或0(ARM)。我见过有人忘了置位,直接跳进未定义指令异常。
  4. LDM/STM的寄存器顺序:虽然ARM规定编号小的在低地址,但不同汇编器的语法可能有细微差别。GNU as和Keil的写法就不完全一样。

我曾经踩过的坑:有一次写ARM64的shellcode,我用STP X29, X30, [SP, #-16]!压栈,结果忘了X29是帧指针,X30是LR。后来在ROP链里想伪造返回地址,怎么都跳不对。折腾了两小时才发现——我压栈时把X29和X30的顺序写反了。ARM64的STP要求“先存的目标寄存器在低地址”,我写成了STP X30, X29,结果X29跑到了高地址,整个栈帧全乱了。