第二章:ARM64基础——寄存器、调用约定与栈帧
好,咱们正式开始ARM64的底层基础。说实话,很多搞x86堆利用的老手,第一次看ARM64的反汇编都会懵——寄存器名字不一样,参数传递靠寄存器而不是栈,连指令都短了一截。但别怕,底层逻辑是相通的。这一章我把ARM64最核心的几个概念掰开揉碎讲清楚。
2.1 ARM64寄存器一览
ARM64有31个通用寄存器,从x0到x30,每个都是64位宽。你可能会问:为什么是31个而不是32个?因为x31是零寄存器XZR,专门用来读零或丢弃结果。我个人习惯把寄存器分成几类来记:
| 类别 | 寄存器 | 用途 |
|---|---|---|
| 参数/返回值 | x0 - x7 | 传递前8个参数,x0也存返回值 |
| 临时变量 | x9 - x15 | 调用者保存,子程序可随意改 |
| 被调用者保存 | x19 - x29 | 子程序必须恢复原值 |
| 栈帧指针 | x29 (FP) | 指向当前栈帧底部 |
| 链接寄存器 | x30 (LR) | 保存函数返回地址 |
| 栈指针 | SP | 独立于通用寄存器,指向栈顶 |
| 程序计数器 | PC | 指向当前指令地址 |
我的小技巧:调试堆利用时,我习惯先看x0(函数参数/返回值)和x30(返回地址)。这两个寄存器往往藏着漏洞利用的关键线索。
2.2 调用约定——参数怎么传?
ARM64的调用约定和x86_64有点像,但更简洁。参数传递规则如下:
- 整数/指针参数:依次放入x0到x7,第9个起压栈
- 浮点参数:使用v0到v7
- 返回值:整数放x0,浮点放v0
- 栈对齐:调用前SP必须16字节对齐
举个例子,一个C函数调用:
// C代码
void func(int a, int b, int c, int d, int e, int f, int g, int h, int i) {
// ...
}
// 对应的ARM64汇编
// a->x0, b->x1, c->x2, d->x3, e->x4, f->x5, g->x6, h->x7
// i 压栈:[sp] = i
// 注意:调用前SP必须16字节对齐
踩坑提醒:我曾经在分析一个堆溢出漏洞时,因为没注意SP对齐导致GDB单步调试时栈布局和实际运行不一致,浪费了整整一个下午。记住:ARM64的SP必须16字节对齐,否则某些指令(如LDP/STP)会触发异常。
2.3 栈帧结构——函数调用的骨架
ARM64的栈帧和x86_64有本质区别。x86_64用RBP/RSP管理栈帧,而ARM64用x29(FP)和SP。一个典型的栈帧长这样:
高地址
+------------------+
| 调用者栈帧 |
+------------------+
| 返回地址 (LR) | <- 当前函数保存的x30
| 上一帧FP (x29) | <- 当前函数保存的x29
| 局部变量区 |
| 被调用者保存寄存器| <- x19-x28
| 参数溢出区 | <- 超过8个的参数
+------------------+ <- SP (16字节对齐)
低地址
函数入口和出口的典型代码:
// 函数入口
func:
stp x29, x30, [sp, #-16]! // 保存FP和LR,SP减16
mov x29, sp // 设置新的FP
sub sp, sp, #32 // 分配局部变量空间
// ... 函数体 ...
// 函数出口
ldp x29, x30, [sp], #16 // 恢复FP和LR,SP加16
ret // 返回
这里有个关键点:stp(Store Pair)和ldp(Load Pair)是ARM64的特色指令,一次操作两个64位寄存器。我在做堆利用时,经常通过观察stp x29, x30的位置来定位栈帧边界。
2.4 系统调用方式——进入内核的通道
ARM64的系统调用和x86_64完全不同。x86_64用syscall指令,ARM64用SVC(Supervisor Call)。调用约定如下:
| 参数 | 寄存器 | 说明 |
|---|---|---|
| 系统调用号 | x8 | 例如:0x40表示write |
| 参数1-6 | x0 - x5 | 和函数调用一致 |
| 返回值 | x0 | 成功返回0或正数,失败返回负错误码 |
一个简单的write(1, buf, 8)系统调用:
mov x0, #1 // fd = stdout
ldr x1, =buf // buf地址
mov x2, #8 // 长度
mov x8, #0x40 // write系统调用号
svc #0 // 触发系统调用
实战经验:我在分析ARM64的堆利用题目时,经常需要构造ROP链来调用
execve。记住:execve的系统调用号是0xDD(221),参数x0=路径指针,x1=argv指针,x2=envp指针。x8设好调用号后,用SVC #0触发。
2.5 常见指令集——LDR/STR/STP/LDP
这几个指令是ARM64堆利用的必修课。我按使用频率排序:
- LDR:从内存加载一个64位值到寄存器。例如:
ldr x0, [x1]把x1指向的8字节加载到x0。 - STR:把寄存器值存到内存。例如:
str x0, [x1]把x0存到x1指向的地址。 - STP:一次存两个寄存器。例如:
stp x29, x30, [sp, #-16]!把x29和x30压栈,SP减16。 - LDP:一次加载两个寄存器。例如:
ldp x29, x30, [sp], #16从栈加载x29和x30,SP加16。
寻址模式也很重要,我总结成三种:
| 模式 | 示例 | 效果 |
|---|---|---|
| 基址寻址 | ldr x0, [x1] |
从x1指向的地址加载 |
| 前变址 | stp x29, x30, [sp, #-16]! |
先减SP再存,!表示写回 |
| 后变址 | ldp x29, x30, [sp], #16 |
先加载,再加SP |
避坑指南:我曾经在写ARM64的exploit时,把
ldp x29, x30, [sp], #16写成了ldp x29, x30, [sp, #16],结果栈指针没更新,后续的ret指令直接跳到了错误地址。记住:后变址的]后面没有!,但SP会自动加偏移。
2.6 知识体系结构图
下面这张图把ARM64的核心知识点串起来了,方便你建立整体认知:
2.7 本章小结
ARM64的基础知识是堆利用的基石。你想想看,如果连寄存器怎么传参、栈帧怎么布局都搞不清楚,后面分析堆溢出、构造ROP链根本无从下手。我个人建议你把这一章的表格和代码示例都亲手敲一遍,在GDB里单步跟踪看看寄存器和栈的变化。嗯,这样印象会深很多。