28、高级逆向技术:符号执行与约束求解、污点分析、二进制重写与插桩
各位好,我是老周。今天咱们聊点硬核的——高级逆向技术。说实话,干逆向这行,光靠静态分析和动态调试,很多时候是远远不够的。遇到混淆、加壳、或者逻辑极其复杂的代码,传统手段就像用勺子挖隧道,效率低得让人抓狂。
这时候,就得请出我们今天的主角:符号执行、污点分析、还有二进制重写与插桩。这三板斧,是我个人在分析恶意软件和漏洞挖掘时最常用的杀手锏。你想想看,如果能用数学的方式去“计算”程序的路径,用“染色”的方式追踪数据流,再直接修改二进制代码来观察行为,那逆向的效率和深度,完全不是一个量级。
核心观点: 高级逆向技术不是替代传统手段,而是将逆向从“经验驱动”升级为“逻辑驱动”和“数据驱动”。
28.1 符号执行与约束求解:让程序自己“交代”路径
符号执行,说白了,就是把程序的输入变量当成“符号”,而不是具体的数值。程序跑起来,每条分支都会产生一个关于这些符号的约束条件。然后,我们用约束求解器(比如 Z3)去解这些条件,看看哪些路径是可达的,以及需要什么样的输入才能触发这些路径。
我刚开始接触这玩意儿时,觉得挺玄乎。后来在分析一个加壳的勒索软件时,它的解密逻辑藏在一堆复杂的条件跳转里。我手动跟了一整天,头都大了。最后用符号执行工具(比如 angr)跑了一遍,几分钟就把所有可能的解密路径和对应的输入条件列出来了。那一刻,我真是服了。
28.1.1 核心流程
- 符号化输入: 将程序输入(如文件内容、网络数据包)定义为符号变量。
- 符号执行: 模拟执行程序,维护一个“符号状态”(寄存器、内存的符号表达式)和“路径约束”。
- 约束求解: 遇到分支时,将分支条件加入路径约束,调用 SMT 求解器(如 Z3)检查可满足性。
- 路径探索: 如果约束可满足,则生成具体输入,继续探索该路径;否则,放弃该路径。
避坑指南: 我曾经在一个大型二进制文件上跑符号执行,结果跑了几个小时都没出结果。后来发现是路径爆炸了——循环太多,分支呈指数级增长。我的建议是:先用静态分析缩小目标函数范围,或者使用“选择性符号执行”,只符号化关键变量。
28.1.2 代码示例:用 Z3 求解简单路径
假设我们有这么一段伪代码:
int f(int x, int y) {
if (x * 2 + y > 10) {
if (x - y < 5) {
return 1; // 目标路径
}
}
return 0;
}
我们想知道,什么样的 x 和 y 能走到 return 1 那条路。用 Z3 来解:
from z3 import *
x = Int('x')
y = Int('y')
solver = Solver()
# 添加路径约束
solver.add(x * 2 + y > 10)
solver.add(x - y < 5)
# 检查并输出模型
if solver.check() == sat:
model = solver.model()
print(f"找到解: x = {model[x]}, y = {model[y]}")
else:
print("无解")
输出结果可能是 x = 4, y = 3。你看,数学的力量就这么简单直接。
28.2 污点分析:追踪数据流的“染色技术”
污点分析,本质上是一种数据流追踪技术。我们把来自不可信来源的数据(比如用户输入、网络数据包)标记为“污点”,然后监控这些污点数据在程序中的传播过程。最终,我们关注污点数据是否到达了“危险”的汇聚点(比如系统调用、内存写入指令)。
我个人习惯把污点分析比作“警察跟踪嫌疑人”。你给嫌疑人(污点源)身上装个追踪器,然后看他去了哪里,见了谁(传播过程),最后有没有实施犯罪(到达危险汇聚点)。
28.2.1 关键概念
| 概念 | 说明 | 我的经验 |
|---|---|---|
| 污点源 | 数据进入程序的起点,如 recv()、read() | 记得把环境变量也考虑进去,有些漏洞就是通过环境变量注入的 |
| 污点传播 | 数据在赋值、运算、函数调用中的传递规则 | 要小心隐式流,比如通过控制流间接影响数据,这很难追踪 |
| 污点汇聚点 | 程序的关键操作,如 system()、memcpy() 的目标地址 | 我一般会重点关注格式化字符串和栈缓冲区操作 |
28.2.2 实战场景:检测格式化字符串漏洞
假设我们有一个程序,它把用户输入直接传给了 printf。用污点分析,我们标记用户输入为污点,然后看它是否流向了 printf 的第一个参数。如果流到了,那基本可以断定存在格式化字符串漏洞。
注意: 污点分析有“误报”和“漏报”的问题。我曾经遇到过一个案例,污点数据通过一个复杂的结构体指针传递,我的分析工具没跟上,漏报了一个严重的堆溢出漏洞。所以,工具只是辅助,最终判断还得靠人。
28.3 二进制重写与插桩:直接修改代码的艺术
二进制重写,就是在不重新编译的情况下,直接修改可执行文件的机器码。插桩,则是在程序运行过程中,插入额外的代码来收集信息或改变行为。这两者经常结合使用。
你想想看,有时候我们想给一个闭源软件加个日志功能,或者想绕过某个校验逻辑,怎么办?反编译改源码再编译?不现实。直接改二进制,才是正道。
28.3.1 静态二进制重写
直接修改磁盘上的二进制文件。比如,把一条 je(相等则跳转)指令改成 jmp(无条件跳转)。
- 优点: 一次修改,永久生效。
- 缺点: 需要处理指令对齐、重定位表等问题,搞不好就改崩了。
我记得有一次,我想破解一个老软件的试用期限制。它在启动时检查一个注册表项。我直接用十六进制编辑器找到那条 cmp 指令,把 jne(不相等则跳转到失败)改成了 je。结果程序直接崩溃了。后来才发现,我改错了偏移,把后面的数据当指令改了。嗯,这是个教训——改之前一定要用反汇编器确认上下文。
28.3.2 动态二进制插桩
在程序运行时,动态地插入代码。最著名的框架是 Intel Pin 和 DynamoRIO。它们允许你在每条指令、每个基本块、每个函数调用前后插入你自己的分析代码。
我个人特别喜欢用 DynamoRIO 做指令级追踪。比如,我想统计一个加密函数执行了多少次循环,或者想记录所有内存访问的地址。用插桩框架,几行代码就能搞定,完全不需要修改原始二进制。
// DynamoRIO 示例:在每条指令前插入回调
static void event_bb_analysis(void *drcontext, void *tag, instrlist_t *bb, ...) {
for (instr_t *inst = instrlist_first(bb); inst; inst = instr_get_next(inst)) {
// 在每条指令前插入一个空函数调用,用于计数
dr_insert_call(drcontext, bb, inst, (void *)my_count_func, 0);
}
}
技巧: 动态插桩的性能开销比较大。我建议在分析热点函数时,只对特定模块或特定函数进行插桩,而不是全程序插桩。否则,一个原本跑 1 秒的程序,插桩后可能跑 10 分钟。
28.4 知识体系总览
下面这张图,是我自己总结的这三项技术的关系图。你可以看到,它们并不是孤立的,而是可以相互配合的。比如,污点分析可以指导符号执行只关注关键路径,而二进制重写可以为插桩创造更好的分析环境。
好了,关于符号执行、污点分析和二进制重写插桩,我就先聊这么多。这三项技术,每一个单独拿出来都能讲一整天。但核心思想你记住了:用逻辑代替猜测,用数据流代替肉眼追踪,用代码修改代替环境模拟。这才是高级逆向的思维精髓。
在实际项目中,我建议你从简单的工具开始,比如先用 angr 跑一个简单的 CrackMe,再用 DynamoRIO 写一个指令计数的小工具。慢慢来,别急着一步登天。毕竟,逆向工程这行,经验和直觉,往往比工具本身更重要。