4. 静态逆向分析基础:识别函数入口、分析控制流图、恢复函数签名

静态逆向分析,说白了就是对着二进制文件“猜”它的逻辑。你拿到的可能是一段没有符号表的 .text 段,一堆十六进制机器码。怎么下手?我个人的习惯是:先找函数入口,再画控制流图,最后把函数签名还原出来。这三步走通了,一个函数的基本面貌就清楚了。

4.1 识别函数入口:从哪开始读代码?

ARM64 的函数入口其实很有规律。你想想看,编译器生成代码时,函数开头通常会做几件事:保存返回地址、分配栈帧、保存被调用者保存寄存器。这些操作对应的指令模式很固定。

常见的函数入口指令序列:

// 典型函数入口(有栈帧)
stp     x29, x30, [sp, #-0x20]!   // 保存 FP(x29) 和 LR(x30),分配 0x20 字节栈
mov     x29, sp                    // 设置帧指针 FP = SP
sub     sp, sp, #0x40              // 再分配局部变量空间

// 叶子函数入口(不调用其他函数)
sub     sp, sp, #0x10              // 直接分配栈空间
stp     x19, x20, [sp, #0x0]      // 保存被调用者保存寄存器

// 无栈帧的简单函数
ret                                // 直接返回,啥也不干

关键识别点:

  • stp x29, x30, [sp, #-N]! — 这是最明显的函数入口标志,前索引寻址同时分配栈空间
  • mov x29, sp — 设置帧指针,通常紧跟在 stp 之后
  • 函数入口地址通常是 4 字节对齐的(ARM64 指令固定 4 字节)

我在项目中遇到过一种情况:有些混淆过的代码会故意破坏入口模式,比如插入无意义的指令或者用 b 指令跳转到中间位置。这时候就不能光靠模式匹配了,得结合调用引用来定位——谁调用了这个地址,那它大概率就是函数入口。

4.2 分析控制流图:把指令串变成流程图

控制流图(CFG)是逆向分析的骨架。它把函数拆成一个个基本块(Basic Block),基本块之间用跳转指令连接。我一般会先画出 CFG,再逐块分析,这样不容易迷失在指令海洋里。

4.2.1 基本块的划分规则

规则 说明 示例指令
顺序执行结束 遇到无条件跳转,当前块结束 b label
条件分支 条件跳转指令分叉,产生两个后继块 b.eq label
函数调用 bl 或 blr 通常不结束块(视为顺序执行) bl func
返回指令 ret 结束当前块,无后继 ret
跳转表 间接跳转(如 switch-case),需分析跳转表 br x8

举个例子,下面这段代码可以划分成 4 个基本块:

// 基本块 A(入口)
stp     x29, x30, [sp, #-0x20]!
mov     x29, sp
cmp     w0, #0
b.le    .L_exit          // 条件跳转到块 D

// 基本块 B(循环体)
.L_loop:
ldr     w2, [x1, w0, sxtw #2]
add     w3, w3, w2
sub     w0, w0, #1
cbnz    w0, .L_loop      // 条件跳转回块 B

// 基本块 C(循环结束)
mov     w0, w3
b       .L_return

// 基本块 D(退出路径)
.L_exit:
mov     w0, #0

// 基本块 E(统一返回)
.L_return:
ldp     x29, x30, [sp], #0x20
ret

我的分析习惯:先用反汇编工具(比如 Ghidra 或 IDA)自动生成 CFG,然后手动检查边界。特别是间接跳转和异常处理路径,工具经常漏掉。我曾经在一个网络协议栈的逆向中,工具漏掉了 br x8 的跳转表,导致我少分析了 3 个处理分支,排查了两天才发现。

4.2.2 控制流图的可视化

下面这张 SVG 图展示了上面代码对应的控制流图结构:

函数控制流图示例 基本块 A 入口:stp/mov/cmp/b.le 基本块 B 循环体:ldr/add/sub/cbnz 基本块 C 循环结束:mov/b 基本块 D 退出路径:mov 基本块 E 统一返回:ldp/ret w0 > 0 w0 <= 0 w0 != 0 w0 == 0 说明:实线箭头表示控制流转移,循环回边用曲线表示

4.3 恢复函数签名:参数、返回值、调用约定

函数签名恢复是静态逆向的最终目标。ARM64 的调用约定(AAPCS64)很清晰:参数通过 X0-X7 传递,返回值放在 X0(或 X0+X1 对于 128 位返回值)。但实际逆向时,你看到的往往是寄存器使用痕迹,需要反推签名。

4.3.1 参数个数推断

我一般通过以下线索推断参数个数:

  • 读取 X0-X7 的指令:函数体内如果从 X0 加载数据,那 X0 很可能是参数指针
  • 对 X0-X7 的依赖:如果函数开头直接使用 X0 的值而不赋值,那 X0 就是第一个参数
  • 栈上传参:如果函数访问了 [sp, #0x8] 或更高偏移,说明可能有超过 8 个参数,部分在栈上

注意陷阱:有些优化后的代码会复用参数寄存器做临时变量。比如函数开头把 X0 保存到栈上,然后 X0 被用作循环计数器。这时候 X0 的原始参数值已经被覆盖了,不能简单认为后续使用 X0 的指令还在访问参数。我曾经在分析一个加密库时,被这种复用坑了整整一天。

4.3.2 参数类型推断

寄存器使用模式 可能的参数类型 示例指令
X0 作为基址 + 偏移寻址 指针(结构体/数组) ldr w2, [x0, #0x10]
W0 参与算术运算 32 位整数 add w0, w0, #1
X0 参与算术运算 64 位整数或指针 add x0, x0, x1
B0/H0/S0/D0 被使用 浮点类型(float/double) fadd d0, d0, d1
X0 被 str 写入内存 输出参数(指针指向的缓冲区) str w3, [x0]

4.3.3 返回值推断

返回值相对好判断。函数返回前,如果 X0 被赋值且没有被后续指令修改,那 X0 的值就是返回值。常见的模式:

// 返回整数 0
mov     w0, #0
ldp     x29, x30, [sp], #0x20
ret

// 返回指针(X0 指向某个结构体)
adrp    x0, .global_var
add     x0, x0, :lo12:.global_var
ldp     x29, x30, [sp], #0x20
ret

// 返回浮点数
fmov    d0, #1.0
ldp     x29, x30, [sp], #0x20
ret

我的签名恢复流程:

  1. 先看函数入口和出口,确定栈帧大小和保存的寄存器
  2. 扫描所有对 X0-X7 的读取指令,标记每个寄存器的首次使用
  3. 检查是否有浮点寄存器操作(B0-D7)
  4. 看返回路径上 X0 的赋值方式
  5. 结合调用点的上下文验证——谁调了这个函数,传了什么值

4.4 实战案例:恢复一个简单函数的签名

来看一段实际的反汇编代码,我们一步步恢复它的签名:

// 地址 0x1000
0x1000:  stp     x29, x30, [sp, #-0x20]!
0x1004:  mov     x29, sp
0x1008:  ldr     w2, [x0]           // 从 X0 指向的地址加载 4 字节到 w2
0x100c:  ldr     w3, [x1]           // 从 X1 指向的地址加载 4 字节到 w3
0x1010:  add     w0, w2, w3         // w0 = w2 + w3
0x1014:  str     w0, [x1]           // 把结果写回 X1 指向的地址
0x1018:  ldp     x29, x30, [sp], #0x20
0x101c:  ret

分析过程:

  • X0 被用作指针(ldr w2, [x0]),第一个参数是 int*
  • X1 也被用作指针(ldr w3, [x1] 和 str w0, [x1]),第二个参数是 int*
  • 函数把 X0 指向的值和 X1 指向的值相加,结果写回 X1
  • 返回值在 X0(add w0, w2, w3),是 int 类型
  • 函数没有调用其他函数(叶子函数),但保存了 FP/LR

恢复后的签名:

int add_and_store(int *a, int *b);
// 功能:*b = *a + *b,返回 *a + *b 的值

核心要点总结:

  • 函数入口识别:stp x29, x30, [sp, #-N]! 是最强信号
  • CFG 分析:先分基本块,再连跳转边,注意间接跳转和循环回边
  • 签名恢复:参数看 X0-X7 的读取模式,返回值看出路前的 X0 赋值
  • 别忘了浮点参数(V0-V7)和栈上传参(超过 8 个参数时)

嗯,静态逆向分析说白了就是这三板斧。但每板斧都有不少细节,尤其是遇到编译器优化或者混淆代码时,模式会被打破。我个人的建议是:先用工具自动分析,再手动验证关键路径。工具给出的 CFG 和签名只能信七分,剩下三分得靠你自己读指令确认。