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 代码会完全错误。
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 知识结构总览
下面这张图总结了本章的核心脉络,从标志位到条件分支,再到条件选择,层层递进:
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 指令集的精髓之一。你在逆向或调试时,只要抓住「比较 → 决策」这条主线,就能快速还原出高级语言的逻辑结构。