第12章:C语言逆向(二):if-else、switch、循环的反汇编模式
各位好,欢迎来到第十二讲。上一章我们聊了函数调用和栈帧,今天咱们把目光转向控制流——if-else、switch、还有那三个循环兄弟:for、while、do-while。
说实话,这些结构在源码层面看着挺简单。但到了汇编层面,编译器会玩出各种花样。我当年第一次逆向一个加了混淆的固件时,就被一个看似简单的switch给整懵了——它居然用了跳转表,还混了二分查找。嗯,从那以后我就老老实实把每种模式都摸透了。
今天的内容,说白了就是帮你建立一套“源码→汇编”的快速映射。你看到一段汇编,能立刻在脑子里还原出它对应的C结构。这是逆向的基本功,也是你从“看懂”到“看透”的关键一步。
12.1 if-else:最基础的分支
先来个最简单的例子:
if (x > 0) {
y = 1;
} else {
y = 2;
}
编译器通常会把它翻译成类似这样的模式:
; 假设 x 在 eax 中
cmp eax, 0
jle else_branch ; 如果 x <= 0,跳转到 else
mov eax, 1 ; y = 1
jmp end_if
else_branch:
mov eax, 2 ; y = 2
end_if:
核心模式就两点:
- 条件判断:用
cmp或test设置标志位 - 条件跳转:用
jcc(jle、jge、je 等)跳转到对应分支
我个人习惯在逆向时,先找到 cmp 和紧跟的 jcc,然后看跳转目标。跳转目标就是 else 分支,不跳转就是 if 分支。简单粗暴。
jmp 的位置。if-else 的末尾通常有一个无条件跳转,用来跳过 else 块。如果你看到 jmp 后面跟着另一个分支的代码,那基本就是 if-else 没跑了。
12.2 switch:跳转表与二分查找
switch 是逆向里最容易出花样的结构。编译器会根据 case 的数量和密集程度,选择不同的实现方式。
12.2.1 密集 case:跳转表
当 case 值连续且密集时(比如 0、1、2、3),编译器会生成一张跳转表:
switch (x) {
case 0: a(); break;
case 1: b(); break;
case 2: c(); break;
case 3: d(); break;
}
反汇编后大概长这样:
; 假设 x 在 eax 中
cmp eax, 3
ja default_case ; 超出范围,走 default
jmp [jump_table + eax*4] ; 直接跳转
jump_table:
dd case0_addr
dd case1_addr
dd case2_addr
dd case3_addr
识别跳转表的关键:看到 jmp [base + index*4] 这种间接跳转,后面跟着一串地址常量。我在逆向一个网络协议栈时,就靠这个模式快速还原了它的状态机——每个状态对应一个 case,跳转表一目了然。
12.2.2 稀疏 case:二分查找
如果 case 值很稀疏(比如 1、100、1000),编译器会改用二分查找。它会生成一个比较树,类似这样:
cmp eax, 100
je case_100
cmp eax, 1
je case_1
cmp eax, 1000
je case_1000
jmp default_case
有时候编译器还会用 bt(位测试)指令做优化,但比较少见。你只要记住:密集用表,稀疏用树。
12.3 for 循环:计数器驱动
for 循环是逆向里最常见的循环结构。看这个例子:
for (int i = 0; i < 10; i++) {
sum += i;
}
反汇编后:
; i 在 ecx 中,sum 在 eax 中
xor ecx, ecx ; i = 0
xor eax, eax ; sum = 0
loop_start:
cmp ecx, 10
jge loop_end ; i >= 10 时退出
add eax, ecx ; sum += i
inc ecx ; i++
jmp loop_start
loop_end:
for 循环的典型模式:
- 初始化:在循环外设置计数器
- 条件判断:在循环顶部比较
- 循环体:执行操作
- 增量:在循环体末尾更新计数器
- 跳回:无条件跳转到条件判断处
我曾经在分析一个加密算法时,看到循环里用了 dec ecx / jnz 代替 cmp / jge。这是编译器常用的优化——倒着计数,省掉一次比较。嗯,逆向时看到 dec 加 jnz,基本就是 for 或 while 循环。
12.4 while 循环:条件前置
while 循环和 for 循环在汇编层面几乎一样,只是初始化部分可能不在循环附近:
while (x > 0) {
x--;
}
反汇编:
; x 在 eax 中
jmp check_condition ; 先跳转到条件判断
loop_body:
dec eax ; x--
check_condition:
cmp eax, 0
jg loop_body ; x > 0 时继续
注意这里有个 jmp 跳到了循环底部。这是 while 循环的典型特征——条件判断在底部,但第一次执行前先检查。你想想看,如果条件一开始就不成立,循环体一次都不执行。
我个人习惯在逆向时,先找到循环底部的条件跳转,然后往上找跳转目标。如果看到 jmp 跳到条件判断处,那就是 while 循环。
12.5 do-while 循环:条件后置
do-while 循环保证循环体至少执行一次:
do {
x--;
} while (x > 0);
反汇编:
; x 在 eax 中
loop_start:
dec eax ; x--
cmp eax, 0
jg loop_start ; x > 0 时继续
do-while 的模式最简单:循环体后面直接跟条件判断和跳转。没有前置的 jmp,也没有额外的初始化。说白了,它就是“先干活,再检查”。
| 循环类型 | 汇编特征 | 执行次数 |
|---|---|---|
| for | 初始化 + 条件判断 + 增量 + 跳回 | 0~N 次 |
| while | jmp 到条件 + 循环体 + 条件判断 | 0~N 次 |
| do-while | 循环体 + 条件判断 + 跳回 | 至少 1 次 |
12.6 知识体系总览
下面这张图帮你梳理了本章的核心逻辑。我建议你把它存下来,逆向时对照着看:
12.7 实战建议
最后,给你几个实战中的小建议:
- 先找循环体:在 IDA 或 Ghidra 里,先看跳转指令的目标地址。如果目标地址比当前地址小,那基本就是循环。
- 注意优化:开启
-O2后,编译器可能会把循环展开、把 if-else 变成条件移动(cmov),甚至把 switch 变成位运算。别慌,先还原逻辑,再管细节。 - 多用交叉引用:跳转表里的地址,用交叉引用一看就知道对应哪个 case。
好了,这一章就到这里。控制流逆向是门手艺活,多练才能熟。下次你看到一个 jmp [eax*4+table],应该能会心一笑——嗯,又是跳转表。
公众号:蓝海资料掘金营,微信deep3321