☰
Soot静态分析实战:调用图与ICFG构建全解析
2026/11/6 4:07:21 网站建设 项目流程

简介:SootTest 面向具备一定 Java 基础、希望入门静态程序分析的开发者,围绕开源分析平台 Soot 演示如何生成调用图与过程间控制流程图(ICFG),帮助读者理解方法间调用关系与跨方法控制流,为代码优化、并发问题排查和缺陷检测打下实践基础。压缩包共 23 个文件,约 12.27MB,以 class 与 java 源码为主体,辅以 dot 图形输出、jar 依赖包、Eclipse 工程配置及结果截图,可直接导入 IDE 运行调试。已有 1447 人学习下载,说明该示例在同类入门资料中具有一定参考价值。项目按源码、测试用例与输出目录组织,读者可对照示例代码走通加载输入、配置 Soot、构建场景、生成控制流图与调用图等关键环节,并借助 dot 文件与截图核对生成结果,快速建立对 Soot API 的整体认识。

1. 静态分析的第一道坎:为什么调用图决定了后面所有分析的上限

很多人第一次接触 Soot,是从一段Scene.v().getCallGraph()开始的。代码跑通了,图也出来了,但真正拿去用的时候才发现:边数不对、虚调用全丢了、库方法一个都没进来。问题不在 API,而在于调用图本身就是整个静态分析里最难、也最容易被低估的一环。SootTest 这个资源做的事情,就是把「用 Soot 生成调用图(Call Graph)」和「过程间控制流程图(ICFG)」这条链路完整走一遍,让你看到从字节码到图结构之间到底发生了什么。搞懂它,你才能判断一次分析的结果是可信的,还是只是看起来跑通了。调用图决定了可达性、决定了指针分析的范围、决定了 ICFG 的边怎么连,它是所有过程间分析的地基,地基歪了,上面盖什么都是玄学。

2. 调用图是怎么从字节码里长出来的:Soot 的 Scene 与解析链路

要理解 Soot 生成调用图的过程,得先接受一个事实:Java 字节码里根本没有「调用图」这个东西。字节码里只有invokevirtual、invokeinterface、invokespecial、invokestatic这几条指令,每条指令带一个方法引用常量。调用图是分析器根据这些指令、结合类型信息,自己「推」出来的。Soot 的整个推演过程围绕Scene这个全局单例展开,理解 Scene 的生命周期,就理解了调用图的生成时机。

2.1 Scene 的三段式生命周期:加载、解析、构建

Soot 的运行可以粗暴切成三段。第一段是加载(loading),把 class 文件或 jar 读进来,变成SootClass/SootMethod/SootField这套对象模型,此时方法体(Body)还没解析,方法里是空的。第二段是解析(resolving),当你真正访问某个方法体时,Soot 才去把字节码翻译成 Jimple 三地址码。第三段才是构建调用图,此时所有被引用到的方法体都已经是 Jimple 了。

这个顺序非常关键,因为调用图的构建依赖方法体里的 invoke 指令,而 invoke 指令只有在解析之后才可见。常见的实现思路是:先设置Options.v().set_whole_program(true)打开全程序模式,再配置entryPoints或processDir,然后调用Scene.v().loadNecessaryClasses(),最后交给PackManager跑一遍cgpack。SootTest 里体现的正是这条链路,它没有绕开 Soot 的 pack 机制去手工拼图,而是顺着 Soot 自己的阶段划分走,这样得到的图才和后续分析阶段一致。

// 打开全程序模式,这是过程间分析的前提 Options.v().set_whole_program(true); // 保留所有方法体,否则调用图会缺边 Options.v().set_keep_line_number(true); Options.v().set_prepend_classpath(true); // 指定待分析目录与入口 Options.v().set_process_dir(Collections.singletonList(targetDir)); Scene.v().loadNecessaryClasses(); // 走 Soot 的 pack 流程,cg pack 负责构建调用图 PackManager.v().runPacks(); CallGraph cg = Scene.v().getCallGraph();

这段代码里每一行的含义都值得掰开。set_whole_program(true)打开的是全程序分析模式,它会让 Soot 在构建调用图时考虑整个程序而不是单个类,代价是内存和时间的上升。set_process_dir指定的是被分析代码的根目录,Soot 会递归扫描。loadNecessaryClasses()只加载「必要」的类,也就是入口可达的类加上它们的依赖,这一步决定了调用图的初始节点集合。runPacks()触发的是 Soot 内部一系列 transformer,cgpack 里挂的就是调用图构建器。如果你跳过runPacks直接getCallGraph(),拿到的往往是空图或者不完整的图,这是新手最容易翻车的地方之一。

2.2 调用图构建器的选择:CHA、RTA、SPARK 到底差在哪

Soot 内置了多种调用图算法,最常被提到的是 CHA(Class Hierarchy Analysis)、RTA(Rapid Type Analysis)和 SPARK(基于指针分析)。它们不是「精度高低」这么简单的排序关系,而是各自建立在不同的假设上。

CHA 只看类的继承关系。遇到一个invokevirtual,它把所有可能被覆写该方法的具体类都连上边。它不需要看方法体,速度最快,但精度最低,虚调用会连出一大片实际根本不会发生的边。RTA 在 CHA 基础上加了一步:只保留那些在程序里真正被实例化的类型。它需要扫描new指令,比 CHA 精确,代价是要多走一遍方法体。SPARK 则更进一步,做的是上下文无关的指针分析,能处理字段、数组、参数传递带来的类型流,精度最高,但构建时间和内存开销也最大。

SootTest 的价值在于它把这几条路径都摆出来了,让你能对比同一份代码在不同算法下生成的图差多少。我一般会这么理解:CHA 适合快速摸清「可能有哪些调用」,RTA 适合在精度和成本之间取平衡,SPARK 适合你确实需要精确的虚调用解析、并且愿意付出代价的场景。选错算法,后面 ICFG 的边就会多出一堆幽灵调用,或者漏掉真实调用,两种错误都很难在结果层面察觉。

// 使用 SPARK 构建调用图,需要显式注册 Options.v().setPhaseOption("cg.spark", "on"); Options.v().setPhaseOption("cg.spark", "verbose:true"); // 打开 RTA 则换成 cg.rta,CHA 是 cg.cha // 注意:同一时刻只应启用一种调用图构建器

这里有个细节:cg.spark的on只是启用,verbose:true会打印指针分析的中间统计,方便你判断分析是否收敛。切换算法时一定要把上一个关掉,否则 Soot 的行为会变得难以预测,这是血泪经验。

2.3 入口点与可达性:为什么你的图总是缺一块

调用图不是全程序的完整快照,它是从入口点出发的可达子图。Soot 默认的入口点是main方法,如果你的分析目标是某个库、某个回调、某个框架里的组件,默认入口点根本到不了它们,图自然缺一大块。

SootTest 里对入口点的处理是显式的:要么通过set_main_class指定主类,要么手工构造SootMethod加入Scene.v().setEntryPoints(...)。后者更灵活,适合分析没有 main 的代码,比如一个被外部调用的 SDK。这里的关键认知是:调用图的完整性是相对于入口点而言的,没有「绝对完整」的调用图,只有「相对于某组入口点可达」的调用图。你分析的目标是什么,入口点就该覆盖什么,否则后面 ICFG 连出来的路径全是断头路。

3. 从调用图到 ICFG:过程间控制流是怎么被缝合起来的

有了调用图,只解决了「谁调用谁」。ICFG 要回答的是更细的问题:从方法 A 的第 12 行出发,经过一次调用进入方法 B,再从 B 返回 A 的第 13 行,这条跨过程的路径在图上怎么表示。Soot 本身并不直接提供一个开箱即用的 ICFG 对象,它提供的是每个方法内部的 CFG(通过BriefBlockGraph或ExceptionalBlockGraph),ICFG 需要你在调用图的基础上把各方法的 CFG 用调用边和返回边缝合起来。SootTest 做的正是这个缝合工作。

3.1 方法内 CFG:UnitGraph 的几种形态

在缝合之前,得先看清每个方法内部的图。Soot 把方法体表示成Body,里面是一串Unit(Jimple 里就是Stmt)。UnitGraph是把这些 Unit 按控制流连起来的图,常见的有BriefUnitGraph(普通控制流)、ExceptionalUnitGraph(带异常边)、CompleteUnitGraph(两者都有)。

选哪种图直接决定 ICFG 的边集。如果你只关心正常路径,BriefUnitGraph就够;如果分析涉及异常传播,必须用ExceptionalUnitGraph,否则 try-catch 里的路径全丢。SootTest 里对异常边的处理是显式开关的,这一点很关键,因为很多人在做 ICFG 时默认用 Brief,结果分析异常处理逻辑时发现图里根本没有 catch 块对应的边,排查半天才发现是图类型选错了。

// 为每个方法构建方法内 CFG for (SootMethod m : Scene.v().getApplicationClasses() .stream().flatMap(c -> c.getMethods().stream()) .collect(Collectors.toList())) { if (!m.hasActiveBody()) continue; Body body = m.getActiveBody(); // 带异常边的方法内图 UnitGraph unitGraph = new ExceptionalUnitGraph(body); // 后续把 unitGraph 的节点和边登记到全局 ICFG 结构里 }

hasActiveBody()这个判断不能省。有些方法(比如抽象方法、native 方法)没有方法体,直接getActiveBody()会抛异常。ExceptionalUnitGraph的构造会分析方法体里的 throw 语句和 catch 块,把异常边补上,代价是图会变大。

3.2 调用边与返回边的缝合:ICFG 的核心数据结构

ICFG 的本质是一个跨方法的图,节点是各方法的 Unit,边有两类:方法内的控制流边,以及跨过程的调用边和返回边。缝合的逻辑是:对方法 A 里的每个调用点(InvokeStmt或含 invoke 的DefinitionStmt),在调用图里找到它可能调用的目标方法集合,然后从调用点连一条边到每个目标方法的入口节点,再从目标方法的出口节点连一条返回边回到 A 里调用点的后继。

这里有个容易忽略的点:一个调用点可能对应多个目标(多态),所以调用边是一对多的。ICFG 的节点集合因此是所有可达方法的 Unit 的并集,边集合是方法内边加上所有调用点展开后的跨过程边。数据结构上,常见的实现是用一个Map<SootMethod, UnitGraph>存方法内图,再用一个Map<Unit, Set<Unit>>存跨过程边,查询时按需拼接。

// 跨过程边的登记:调用点 -> 目标方法入口 Map<Unit, Set<Unit>> interEdges = new HashMap<>(); for (SootMethod caller : methods) { UnitGraph g = unitGraphs.get(caller); for (Unit u : g) { if (!(u instanceof Stmt)) continue; Stmt s = (Stmt) u; if (!s.containsInvokeExpr()) continue; // 从调用图取该调用点的所有可能目标 for (Edge e : cg.edgesOutOf(s)) { SootMethod callee = e.tgt().method(); if (!callee.hasActiveBody()) continue; Unit entry = unitGraphs.get(callee).getHeads().get(0); interEdges.computeIfAbsent(u, k -> new HashSet<>()).add(entry); } } }

cg.edgesOutOf(s)返回的是调用图里从该调用点出发的边,每条边的tgt()是一个SootMethod。getHeads().get(0)取目标方法的入口 Unit。这段逻辑看起来简单,但它把调用图的精度直接传导到了 ICFG 上:调用图里多一条虚调用边,ICFG 里就多一组跨过程路径。这也是为什么第 2 章强调算法选择,因为误差会在这里被放大。

3.3 上下文敏感:为什么 ICFG 会「串味」

最朴素的 ICFG 是上下文不敏感的:同一个方法被不同调用点调用时,它的 Unit 在全局图里只有一份,所有调用点的返回边都连回各自的调用点。这会导致一个经典问题:从调用点 A 进入方法 M,却从调用点 B 的返回边出来,形成实际不存在的路径,也就是所谓的「串味」。

SootTest 里对这个问题有讨论,处理方式通常是给节点加调用上下文标签,或者用调用点敏感的复制策略,把同一个方法在不同上下文下复制成多份节点。代价是图规模膨胀,一个被调用 N 次的方法会变成 N 份。这是精度和规模之间的硬取舍,没有免费午餐。如果你的分析对路径可行性敏感,就必须做上下文敏感;如果只是做粗粒度的可达性,上下文不敏感反而更省资源。

4. 避坑与排查:Soot 调用图和 ICFG 的常见翻车现场

4.1 现象:调用图边数为零或远小于预期

原因通常是没开全程序模式,或者没走runPacks()。Soot 在非全程序模式下,cgpack 不会完整构建调用图,getCallGraph()拿到的是残缺结果。另一个常见原因是入口点没设对,可达方法集合太小。解决方式是确认set_whole_program(true)、确认loadNecessaryClasses()之后调用了runPacks(),并打印Scene.v().getEntryPoints()检查入口点是否符合预期。

4.2 现象:虚调用全部丢失,只剩静态调用

原因多半是调用图构建器没启用,或者启用了但被后续 phase 覆盖。Soot 默认不一定启用 SPARK,需要显式setPhaseOption("cg.spark", "on")。另外,如果方法体没解析(hasActiveBody()为 false),invoke 指令不可见,虚调用自然连不出来。排查时先统计有多少方法有 active body,再看cgpack 的日志。

4.3 现象:ICFG 里出现不可能的跨过程路径

这是上下文不敏感导致的串味,也可能是调用图精度太低,虚调用连了不该连的目标。先确认调用图算法,再考虑是否引入上下文敏感。如果只是排查用,可以临时把调用图换成 RTA 或 SPARK 对比边集差异,差异大的地方往往就是问题所在。

4.4 现象:异常路径缺失,catch 块没有入边

方法内图用了BriefUnitGraph而不是ExceptionalUnitGraph。异常边不会自动出现,必须显式选择带异常边的图类型。改完之后重新统计边数,通常会有明显增长。

4.5 现象:内存溢出或分析时间失控

SPARK 在大项目上开销很大,加上全程序模式和保留所有方法体,内存很容易打满。解决方式是限制分析范围(只分析目标包)、关掉不必要的选项、或者退回 RTA。另外,set_keep_line_number这类调试选项在生产分析里可以关掉,能省一些开销。

5. 设计取舍:精度、规模与可解释性之间的那条线

把调用图和 ICFG 串起来看,会发现整条链路都在做同一类取舍:精度换规模。CHA 到 RTA 到 SPARK,是精度递增、成本递增;上下文不敏感到上下文敏感,也是同样的方向。SootTest 这个资源真正的价值,不是给你一个「能跑」的脚本,而是把这条取舍链上的每个开关都暴露出来,让你能亲手拨动它们,看到图怎么变。

我自己的习惯是:先用 CHA 快速跑一遍,看图的规模量级;再用 RTA 对比边集差异,差异大的区域重点看;只有在确实需要精确虚调用解析时,才上 SPARK,并且严格限制分析范围。ICFG 的构建同理,先上下文不敏感跑通,确认路径结构合理,再考虑是否加敏感。这个顺序能让你在每一步都有可验证的中间结果,而不是一上来就调最重的配置,跑挂了都不知道挂在哪。

验证方法上,我一般会挑几个已知的调用关系做断言:某个接口方法在调用图里应该有几个实现目标,某个 try-catch 在 ICFG 里应该有对应的异常边。这些断言不需要覆盖全图,但能快速暴露配置错误。另一个习惯是打印每个阶段的统计量——类数、方法数、有 body 的方法数、调用图节点边数、ICFG 节点边数——数字对不上,问题往往就在相邻两个阶段之间。

从那以后我每次碰 Soot 的图分析,都会先把这几个统计量打出来,再动手写分析逻辑,因为图本身对不对,比分析结果好不好看重要得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询