4. 路径探索与执行引擎:SimulationManager、探索策略(DFS/BFS)、路径修剪与合并
好,咱们进入正题。前面几章我们聊了怎么用 angr 加载样本、怎么构建初始状态。但说实话,那只是热身。真正让 angr 在恶意代码分析中发力的,是它的路径探索能力。
你想想看,恶意代码最烦人的地方是什么?是它不按套路出牌。加壳、反调试、条件跳转满天飞。如果你一条路走到黑,大概率会漏掉关键行为。这时候,你就需要一个能帮你「把所有路都走一遍」的工具——SimulationManager 就是干这个的。
4.1 SimulationManager:路径探索的总指挥
SimulationManager,我习惯叫它「仿真管理器」。说白了,它就是 angr 里负责管理所有执行路径的调度器。你给它一个初始状态,它就能自动帮你分叉、探索、合并。
我个人习惯这么用:
import angr
proj = angr.Project('sample.exe', auto_load_libs=False)
state = proj.factory.entry_state()
simgr = proj.factory.simulation_manager(state)
嗯,就这么简单。simgr 创建好之后,它内部维护了一个状态池。初始只有一个 state,但随着符号执行推进,遇到条件分支,它就会自动分裂成多个 state。
核心概念:SimulationManager 内部维护了多个「状态列表」,每个列表代表不同性质的路径。最常用的有:
- active:当前活跃的路径,还在继续执行
- deadended:走到头的路径(比如执行到 exit)
- errored:执行出错的路径
- avoid:你主动想避开的路径
- found:你主动想找到的路径
我在分析一个勒索软件时遇到过这种情况:样本在解密前会检查系统语言,如果不是俄语就直接退出。如果我只跑一条路径,根本看不到解密逻辑。但用 simgr 跑一遍,所有分支都出来了,解密函数自然就暴露了。
4.2 探索策略:DFS 与 BFS
路径多了,问题就来了——先探索哪条?
angr 提供了两种经典策略:深度优先(DFS)和广度优先(BFS)。我画了张图帮你理解:
DFS 和 BFS 的区别,说白了就是「一条路走到黑」和「层层推进」的区别。
深度优先(DFS)
DFS 会沿着一条路径一直往下走,直到走不通了再回头。我分析加壳样本时特别喜欢用 DFS。为什么?因为壳的入口通常只有一条有效路径能走到 OEP,DFS 能最快找到它。
simgr.use_technique(angr.exploration_techniques.DFS())
simgr.run()
我的经验:DFS 适合「找特定目标」的场景。比如你想快速定位某个函数、某个地址,DFS 能帮你节省大量时间。但要注意,如果路径深度太大,DFS 可能会陷入死胡同。
广度优先(BFS)
BFS 则是逐层探索,先探索所有第一层分支,再探索第二层。我一般在分析恶意代码的「行为全貌」时用 BFS。比如样本有多个 C2 地址、多种解密方式,BFS 能保证你不遗漏任何一条路径。
simgr.use_technique(angr.exploration_techniques.BFS())
simgr.run()
注意:BFS 的内存消耗比 DFS 大得多。我曾经分析一个带大量循环的样本,BFS 跑了半小时内存就爆了。遇到这种情况,建议先用 DFS 探路,再针对性分析。
4.3 路径修剪:别让无用路径拖死你
路径爆炸是符号执行的老大难问题。恶意代码里一个简单的循环,可能产生成千上万条路径。如果不修剪,你的电脑会直接卡死。
我常用的修剪手段有几种:
| 修剪方法 | 说明 | 适用场景 |
|---|---|---|
| 路径限制 | 设置最大路径数,超出后丢弃多余路径 | 路径数量爆炸时兜底 |
| 循环限制 | 限制循环执行次数,避免无限循环 | 样本中有大量循环 |
| 地址过滤 | 只探索特定地址范围内的路径 | 已知关键代码区域 |
| 符号约束 | 添加额外约束,排除不可能路径 | 已知输入条件 |
代码示例:
# 限制最多保留 100 条活跃路径
simgr = proj.factory.simulation_manager(state, save_unconstrained=True)
simgr.run(n=100) # 最多执行 100 步
# 或者手动修剪
simgr.prune() # 移除所有 errored 状态的路径
避坑指南:我曾经分析一个样本,它用了一个很长的 switch-case 结构。如果不修剪,每个 case 都会产生一条路径,总共 200 多条。我一开始没注意,结果跑了一晚上没出结果。后来加了路径限制,10 分钟就搞定了。
4.4 路径合并:化繁为简的艺术
路径修剪是「做减法」,路径合并则是「做加法」——把多条相似的路径合并成一条,减少状态数量。
angr 提供了 merge 功能,可以把多个 state 合并成一个。合并后的 state 会保留所有原始 state 的约束条件。
# 手动合并两个状态
merged_state = state1.merge(state2)
# 或者在 SimulationManager 中自动合并
simgr = proj.factory.simulation_manager(state)
simgr.merge() # 合并所有可以合并的路径
什么时候该合并?我总结了几条经验:
- 循环体内部:循环每次迭代的路径结构相似,合并后能大幅减少状态数
- 函数调用前后:不同路径调用同一个函数,返回后可以合并
- 数据流汇聚点:多条路径最终汇聚到同一代码块时
我的习惯:我不会在所有场景下都用合并。因为合并本身也有开销。我一般只在路径数超过 500 时,才考虑合并。另外,合并后的 state 约束会变复杂,求解器求解时间可能变长。这个 trade-off 需要你自己权衡。
4.5 实战:用 SimulationManager 分析恶意代码
说了这么多,咱们来点实际的。假设你拿到一个样本,它先检查调试器,再解密 payload。你想绕过反调试,直接看到解密后的 payload。
import angr
proj = angr.Project('malware.exe', auto_load_libs=False)
state = proj.factory.entry_state()
# 创建 SimulationManager
simgr = proj.factory.simulation_manager(state)
# 使用 DFS 快速探索
simgr.use_technique(angr.exploration_techniques.DFS())
# 设置探索目标:找到解密函数入口
simgr.explore(find=0x401000) # 假设解密函数在 0x401000
if simgr.found:
found_state = simgr.found[0]
print("找到目标路径!")
# 在这里可以读取解密后的内存
else:
print("未找到目标路径")
这段代码的核心思路是:用 DFS 快速找到目标地址,然后读取内存中的解密数据。我在实际项目中用这个思路分析过一个勒索软件,成功提取了它的解密密钥。
注意:恶意代码经常有反符号执行的检测。比如检查执行时间、检查环境变量等。遇到这种情况,你可能需要手动 patch 掉这些检测,或者用 angr 的 hook 机制绕过。
嗯,路径探索这块内容不少,但核心就三点:选对策略(DFS/BFS)、及时修剪、适当合并。掌握了这些,大部分恶意代码的路径问题你都能搞定。