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 vs BFS 路径探索策略对比 入口 A B C D E F G H I J DFS: 入口 → A → C → G → H → D → I → J → B → E → F BFS: 入口 → A → B → C → D → E → F → G → H → I → J

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)、及时修剪、适当合并。掌握了这些,大部分恶意代码的路径问题你都能搞定。


专注资料整理