第二章: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的核心知识点串起来了,方便你建立整体认知:

ARM64基础核心知识结构 ARM64基础核心知识结构 ARM64基础 寄存器体系 调用约定 栈帧结构 系统调用 x0-x30 SP/PC/LR 参数传递规则 返回值约定 FP/LR保存 局部变量区 SVC指令 x8系统调用号 核心指令:LDR/STR/STP/LDP 堆利用中重点关注x0、x30、SP的布局

2.7 本章小结

ARM64的基础知识是堆利用的基石。你想想看,如果连寄存器怎么传参、栈帧怎么布局都搞不清楚,后面分析堆溢出、构造ROP链根本无从下手。我个人建议你把这一章的表格和代码示例都亲手敲一遍,在GDB里单步跟踪看看寄存器和栈的变化。嗯,这样印象会深很多。