4. 逆向分析工具链实战

ARM64 的条件执行机制,说白了就是让 CPU 自己「做决定」的能力。你写 C 语言里的 if-else,到了汇编层,全靠这套机制撑起来。我刚开始逆向 ARM64 二进制时,最头疼的就是看到一堆 B.EQ、CSEL 之类的指令,脑子里得反复过一遍标志位的状态才能看懂流程。后来摸透了规律,发现这东西其实很直观。

4.1 条件标志位:CPU 的「状态寄存器」

ARM64 的 PSTATE(Process State)里有一组条件标志位,它们记录着最近一次算术或逻辑运算的结果特征。你不需要记住所有位,但下面这四个是核心:

标志位 全称 含义
N Negative 结果为负数(最高位为 1)
Z Zero 结果为零
C Carry 无符号溢出(进位或借位)
V Overflow 有符号溢出

这些标志位不是随便变的。只有特定指令会更新它们——比如 ADDS、SUBS、CMP、CMN。普通的 ADD、SUB 不会动标志位。嗯,这里要注意:ARM64 默认不更新标志位,需要你主动加 S 后缀。

我的习惯:在逆向分析时,我会先定位 CMP 或 ADDS 指令,然后看它后面紧跟的条件分支或条件选择指令。这就像在代码里找到了「决策点」,顺着标志位的变化就能还原出高级语言的 if 逻辑。

4.2 比较指令:CMP 与 CMN

比较指令的本质就是做减法,但不保存结果,只更新标志位。

  • CMP Xn, Xm:计算 Xn - Xm,更新 N、Z、C、V。
  • CMN Xn, Xm:计算 Xn + Xm,更新 N、Z、C、V。

举个例子:

CMP W0, W1      // 计算 W0 - W1,只改标志位
B.EQ label       // 如果 W0 == W1,跳转

你可能会问:为什么要有 CMN?它其实就是 CMP 的变种,用来比较 Xn 和 -Xm。我在逆向一个加密算法时遇到过 CMN,当时没反应过来,以为是加法,结果分析错了分支逻辑。后来才意识到,CMN 常用于判断两个数是否互为相反数。

我曾经踩过的坑:在调试一个网络协议栈时,我看到 CMP X0, #0 后面跟了 B.LT,以为是有符号比较。结果发现 X0 存的是无符号长度值,B.LT 永远不触发,导致一个边界检查形同虚设。所以,一定要搞清楚操作数是有符号还是无符号,否则条件分支可能完全跑偏。

4.3 条件分支指令:B.cond 家族

条件分支指令根据标志位的组合决定是否跳转。常见的条件码如下:

指令 条件 标志位检查
B.EQ 相等 Z == 1
B.NE 不相等 Z == 0
B.LT 有符号小于 N != V
B.LE 有符号小于等于 N != V 或 Z == 1
B.GT 有符号大于 Z == 0 且 N == V
B.GE 有符号大于等于 N == V
B.HI 无符号高于 C == 1 且 Z == 0
B.LS 无符号低于或相同 C == 0 或 Z == 1

你想想看,B.LT 和 B.HI 的区别是什么?前者看 N 和 V 的异或,后者看 C 和 Z。这就是有符号和无符号比较的根本差异。我在逆向一个数值计算库时,经常需要根据上下文判断用的是哪一套条件码,否则还原出的 C 代码会完全错误。

实战技巧:在 IDA Pro 或 Ghidra 中,看到 B.cond 指令时,我会立即查看它前面的比较指令。如果前面是 CMP,那大概率是普通比较;如果前面是 TST 或 ANDS,那可能是位测试。条件码的选择直接暴露了程序员的原始意图。

4.4 条件选择指令:CSEL / CSINC / CSINV / CSNEG

ARM64 有个很优雅的设计——条件选择指令。它们可以在不改变控制流的情况下实现条件赋值。这比用分支跳转高效得多,因为避免了流水线冲刷。

  • CSEL Xd, Xn, Xm, cond:如果条件成立,Xd = Xn;否则 Xd = Xm。
  • CSINC Xd, Xn, Xm, cond:如果条件成立,Xd = Xn;否则 Xd = Xm + 1。
  • CSINV Xd, Xn, Xm, cond:如果条件成立,Xd = Xn;否则 Xd = ~Xm。
  • CSNEG Xd, Xn, Xm, cond:如果条件成立,Xd = Xn;否则 Xd = -Xm。

举个例子,C 语言里的 a = (b > c) ? b : c,在 ARM64 里可以写成:

CMP W0, W1       // 比较 b 和 c
CSEL W2, W0, W1, GT  // 如果 b > c,W2 = W0;否则 W2 = W1

没有分支,没有跳转,一气呵成。我在逆向一个图像处理算法时,看到大量 CSEL 指令,当时就意识到这代码对性能有极致追求——因为每一行都避免了分支预测失败的开销。

我个人习惯:在写性能敏感的汇编代码时,我会优先用 CSEL 替代 B.cond + MOV 的组合。尤其是在循环内部,少一个分支就意味着少一次可能的流水线停顿。逆向分析时,看到 CSEL 也基本能断定这段代码是经过手工优化的。

4.5 知识结构总览

下面这张图总结了本章的核心脉络,从标志位到条件分支,再到条件选择,层层递进:

ARM64 条件执行知识结构图 ARM64 条件执行知识结构 条件标志位 (PSTATE) N (Negative) Z (Zero) C (Carry) V (Overflow) 比较指令 (CMP / CMN) 条件分支 (B.EQ / B.NE / ...) 条件选择 (CSEL / CSINC / ...) 应用:if-else / 循环 / 边界检查 / 最小值最大值 核心思路:比较指令更新标志位 → 条件分支或条件选择根据标志位做出决策

4.6 综合实战:逆向一个简单的 if-else

假设你遇到了下面这段 ARM64 汇编:

LDR W0, [X0]       // 加载变量 a
LDR W1, [X1]       // 加载变量 b
CMP W0, W1         // 比较 a 和 b
B.LE else_branch   // 如果 a <= b,跳转到 else
ADD W2, W0, #10    // a > b 时:c = a + 10
B end
else_branch:
ADD W2, W1, #20    // a <= b 时:c = b + 20
end:
STR W2, [X2]       // 存储 c

这段代码对应的 C 语言逻辑是:

if (a > b) {
    c = a + 10;
} else {
    c = b + 20;
}

你看,B.LE 跳转的是 else 分支,而主分支是 a > b 的情况。逆向时,我习惯把条件码「反过来」理解——B.LE 跳转意味着条件不满足时走主分支。这个思维转换,刚开始可能会绕,但多练几次就顺了。

我曾经犯过的错:有一次逆向一个加密函数,看到 B.GE 后跟了一个 RET,我以为是「大于等于就返回」,结果分析错了整个函数的退出条件。后来才发现,B.GE 跳转的是错误处理路径,正常路径在它下面。所以,不要只看条件码,要看跳转的目标地址在做什么。

好了,ARM64 的条件执行机制就讲到这里。从标志位到比较指令,再到条件分支和条件选择,这套体系是 ARM64 指令集的精髓之一。你在逆向或调试时,只要抓住「比较 → 决策」这条主线,就能快速还原出高级语言的逻辑结构。