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时,脑子里要有这张图:
4.5 实战中的常见陷阱
写ARM shellcode时,这几个坑我几乎每次都遇到:
- LR被覆盖:如果你在函数里又调了别的函数,LR会被第二次BL覆盖。记得先PUSH LR保存。
- SP对齐:ARM要求栈指针8字节对齐(ARM64是16字节)。不对齐的话,某些LDRD/STRD指令会触发异常。
- Thumb vs ARM混用:用BX切换模式时,目标地址的最低位必须是1(Thumb)或0(ARM)。我见过有人忘了置位,直接跳进未定义指令异常。
- 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跑到了高地址,整个栈帧全乱了。