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 图展示了上面代码对应的控制流图结构:
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
我的签名恢复流程:
- 先看函数入口和出口,确定栈帧大小和保存的寄存器
- 扫描所有对 X0-X7 的读取指令,标记每个寄存器的首次使用
- 检查是否有浮点寄存器操作(B0-D7)
- 看返回路径上 X0 的赋值方式
- 结合调用点的上下文验证——谁调了这个函数,传了什么值
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 和签名只能信七分,剩下三分得靠你自己读指令确认。