Soot 静态分析实战:调用图与 ICFG 生成机制及算法选型

发布时间:2026/10/21 8:32:23
Soot 静态分析实战:调用图与 ICFG 生成机制及算法选型 简介SootTest 是一份面向 Java 静态分析与编译优化学习者的实战示例资源基于开源分析框架 Soot演示如何生成调用图与过程间控制流程图ICFG。适合已掌握 Java 基础、希望深入字节码分析与程序结构理解的开发者用于理解方法调用关系、跨方法数据流与控制流分析等场景。压缩包共 23 个文件约 12.27MB包含 java 源码、class 字节码、dot 图形输出、jar 依赖及 Eclipse 工程配置等覆盖从输入加载、Soot 配置、场景构建到调用图与 ICFG 生成的完整链路。资源中附带示例测试用例与图形化输出结果可帮助读者对照代码理解 Soot 的 API 调用方式与中间表示处理流程并借助 dot 文件直观查看生成的控制流结构。目前已有 1440 人学习下载适合作为 Java 静态分析入门与 Soot 工具实践的参考案例。1. 静态分析的地基为什么调用图和 ICFG 是绕不开的一步很多人第一次接触静态分析是从我要检测一个漏洞或者我要做代码审计开始的结果写了几行规则就发现根本跑不起来——因为连这个方法被谁调用都答不上来。SootTest 这个项目要解决的正是这个最底层的问题用 Soot 把 Java 字节码变成可查询的调用图Call Graph和过程间控制流图ICFG。听起来像是教科书里的名词但真正动手时你会发现调用图的精度直接决定了后续所有分析的生死。举个反直觉的例子同样是a.foo()这一行代码在 CHA 算法下可能指向 50 个目标方法在 SPARK 下可能只剩 3 个而在 RTA 下又是另一个数字。选错算法你的分析要么漏报要么爆炸。这就是为什么值得把调用图和 ICFG 的生成机制彻底搞懂——它不是某个工具的使用技巧而是整个 Java 静态分析体系的地基。地基歪了上面盖什么都是危房。2. 调用图从字节码到方法可达性的推导链条2.1 为什么 Java 的调用图天生难算Java 的调用图之所以比 C 语言难算核心在于它的动态分派机制。C 语言里一个函数调用在编译期基本就确定了目标地址而 Java 的invokevirtual和invokeinterface指令在字节码层面只记录了一个方法签名真正的目标方法要等到运行时根据接收者的实际类型才能确定。静态分析没有运行时信息只能靠类型推断来猜——而这个猜的过程就是各种调用图算法的本质区别。Soot 面对的输入是.class文件或.jar包它先把字节码解析成 Jimple 中间表示。Jimple 是一种三地址码形式每条语句最多涉及三个操作数这让后续的分析变得规整。比如a.foo()在 Jimple 里会被展开成类似$r1 virtualinvoke $r0.A: void foo()的形式其中$r0是接收者对象引用virtualinvoke表明这是一次虚方法调用。调用图构建器要做的就是为每一条virtualinvoke和interfaceinvoke语句找出所有可能的实际目标方法。这里有个容易被忽略的细节Soot 在构建调用图之前需要先确定可达方法集合。因为 Java 程序里存在大量永远不会被调用的方法比如库代码中的重载如果对每个方法都做类型推断开销会大到不可接受。所以调用图算法通常分两阶段先从一个入口点集合出发沿着调用边不断扩展可达方法在扩展过程中对每条新遇到的调用语句做目标解析。这个边解析边扩展的过程就是所谓的 on-the-fly 调用图构建。2.2 CHA、RTA、SPARK三种精度层级的取舍Soot 内置了多种调用图算法最常用的三种是 CHAClass Hierarchy Analysis、RTARapid Type Analysis和 SPARK。它们的精度和开销呈阶梯式上升理解它们的差异是选型的关键。CHA 是最粗暴的一种。对于a.foo()它不管a的实际类型是什么直接查类层次结构如果A有子类B、C、D都重写了foo()那么这四个方法全部作为调用目标。CHA 的优点是快几乎不需要额外分析缺点是精度极低一个接口方法可能解析出上百个目标导致调用图极度膨胀。RTA 在 CHA 的基础上加了一层过滤它先做一次快速的全程序扫描记录下所有被new指令实例化的类型集合称为 instantiated types。然后在解析调用目标时只保留那些实际被实例化过的类型对应的方法。这个改进看似简单但效果显著——很多接口虽然有大量实现类但程序中实际只用了其中一两个。RTA 的开销只比 CHA 多一次线性扫描精度却提升明显是很多场景下的性价比之选。SPARK 则是真正的指向分析points-to analysis。它不满足于类型级别的推断而是追踪每个变量可能指向的具体对象集合。SPARK 内部使用工作列表算法迭代求解指向关系直到不动点。对于a.foo()SPARK 会先算出a的指向集合再根据集合中每个具体类型去解析目标方法。SPARK 的精度最高但开销也最大——对于大型项目SPARK 的分析时间可能达到分钟甚至小时级别。算法精度开销适用场景CHA低极低快速原型、对精度无要求的场景RTA中低大多数常规分析任务SPARK高高安全审计、需要精确指向信息的场景选择哪种算法本质上是在分析时间和结果精度之间做权衡。我一般的经验是如果只是做方法可达性检查RTA 足够如果要做污点分析或别名分析SPARK 几乎是必须的。2.3 用 Soot 生成调用图的代码骨架下面这段代码展示了用 Soot 生成调用图的基本流程。注意其中几个关键参数的含义它们直接决定了调用图的形态。// 配置 Soot 的运行参数 String[] args { -process-dir, ./target/classes, // 待分析的字节码目录 -main-class, com.example.Main, // 入口类调用图从这里开始扩展 -output-format, jimple, // 中间表示格式 -p, cg.spark, enabled:true, // 启用 SPARK 调用图算法 -p, cg.spark, on-fly-cg:true, // 开启 on-the-fly 构建 -allow-phantom-refs // 允许缺失的库引用避免分析中断 }; // 加载场景Scene并执行 Soot 的各个 Pack soot.Main.main(args); // 获取调用图 CallGraph cg Scene.v().getCallGraph(); // 遍历所有调用边 for (EdgeUnit, SootMethod edge : cg) { Unit src edge.src(); // 调用点所在的语句 SootMethod tgt edge.tgt(); // 被调用的目标方法 SootMethod container edge.srcStmt().getMethod(); // 调用者方法 System.out.println(container.getSignature() - tgt.getSignature()); }这段代码里-main-class指定了调用图扩展的起点。Soot 会从Main.main()开始沿着调用边不断发现新方法直到没有新的可达方法为止。on-fly-cg:true表示在扩展过程中动态解析调用目标而不是先构建完整的类型层次再统一解析——这对大型项目能显著降低内存占用。-allow-phantom-refs是一个很实用的选项当分析目标依赖了某些不在 classpath 中的库时Soot 默认会报错终止开启这个选项后它会为缺失的类创建幻影占位符让分析继续进行。拿到CallGraph对象后遍历Edge就能得到所有的调用关系。每条边包含三个关键信息调用点语句src、目标方法tgt、以及调用者方法通过srcStmt().getMethod()获取。这个三元组是后续所有过程间分析的基础数据。3. ICFG把分散的 CFG 缝合成过程间控制流3.1 过程内 CFG 的构建逻辑要理解 ICFG得先理解过程内 CFG 是怎么来的。Soot 在把字节码转成 Jimple 之后每个方法体SootBody内部会被组织成UnitGraph。UnitGraph的节点是 Jimple 语句Unit边表示控制流转移。Soot 提供了几种UnitGraph实现BriefUnitGraph不包含异常边ExceptionalUnitGraph包含异常处理边CompleteUnitGraph则同时包含普通边和异常边。构建UnitGraph的核心工作是识别分支指令和跳转目标。Jimple 里的IfStmt、GotoStmt、SwitchStmt等语句会产生多条出边而ReturnStmt、ThrowStmt则是终止节点。对于try-catch块ExceptionalUnitGraph会从可能抛出异常的语句连一条边到对应的catch块入口。这个异常边的处理是很多分析翻车的地方——如果你的分析忽略了异常边就会漏掉异常路径上的状态传播。// 获取某个方法的过程内控制流图 SootMethod method Scene.v().getMethod(com.example.Main: void main(java.lang.String[])); Body body method.retrieveActiveBody(); // 使用 ExceptionalUnitGraph 保留异常控制流 UnitGraph cfg new ExceptionalUnitGraph(body); // 遍历 CFG 的节点和边 for (Unit unit : cfg) { // 获取当前节点的后继 for (Unit succ : cfg.getSuccsOf(unit)) { System.out.println(unit -- succ); } }retrieveActiveBody()触发 Soot 对该方法进行 Jimple 化——如果方法体还没被转换这一步会完成转换并缓存结果。ExceptionalUnitGraph的构造函数会分析方法体中的异常表Exception Table为每个可能抛出异常的语句建立到catch块的边。需要注意的是getSuccsOf返回的后继可能包含多个因为一条语句可能同时有正常后继和异常后继。3.2 过程间边的连接调用点与返回点的配对ICFG 的本质是在过程内 CFG 的基础上增加两类跨方法的边调用边call edge和返回边return edge。调用边从调用语句指向被调用方法的入口节点返回边从被调用方法的退出节点指向调用语句的后继节点。这个连接过程看似直白但有几个细节需要处理。第一一个调用点可能有多个目标方法多态所以一个调用语句可能对应多条调用边。第二返回边的目标不是调用语句本身而是调用语句在调用者 CFG 中的后继——因为方法返回后控制流继续执行调用点的下一条语句。第三对于没有返回值的方法调用返回边依然存在只是不携带返回值信息。Soot 本身没有直接提供一个名为 ICFG 的类但可以通过组合CallGraph和各个方法的UnitGraph来构建。常见的做法是先构建调用图然后对每个可达方法获取其UnitGraph再根据调用图中的边把不同方法的UnitGraph连接起来。连接的关键是维护一个映射从调用语句到其在调用者 CFG 中的后继节点。// 构建 ICFG 的核心逻辑示意 CallGraph cg Scene.v().getCallGraph(); MapUnit, SetUnit icfgEdges new HashMap(); for (EdgeUnit, SootMethod edge : cg) { Unit callSite edge.src(); SootMethod callee edge.tgt(); // 调用边调用点 - 被调用方法入口 Body calleeBody callee.retrieveActiveBody(); Unit entry calleeBody.getFirstUnit(); icfgEdges.computeIfAbsent(callSite, k - new HashSet()).add(entry); // 返回边被调用方法出口 - 调用点的后继 SootMethod caller callSite.getMethod(); Body callerBody caller.retrieveActiveBody(); UnitGraph callerCfg new ExceptionalUnitGraph(callerBody); for (Unit ret : calleeBody.getUnits()) { if (ret instanceof ReturnStmt || ret instanceof ReturnVoidStmt) { for (Unit succ : callerCfg.getSuccsOf(callSite)) { icfgEdges.computeIfAbsent(ret, k - new HashSet()).add(succ); } } } }这段代码展示了 ICFG 构建的核心思路调用边把调用点和被调用方法的入口连起来返回边把被调用方法的所有返回点和调用点在调用者中的后继连起来。实际实现中还需要处理递归调用避免无限展开、虚方法的多目标一个调用点连向多个入口、以及异常传播路径异常可能跨越方法边界。这些边界情况正是 ICFG 实现中最容易出 bug 的地方。3.3 调用图精度如何影响 ICFG 的形态调用图和 ICFG 不是独立的——调用图的精度直接决定了 ICFG 的形态。用 CHA 生成的调用图一个调用点可能连出几十条调用边ICFG 会变得极其稠密后续在 ICFG 上做数据流分析时状态会沿着所有可能的路径传播导致分析结果过度近似很多可能实际上永远不会发生。用 SPARK 生成的调用图调用边数量大幅减少ICFG 更稀疏分析精度更高但构建 ICFG 本身的开销也更大。这个连锁效应在实际项目中非常明显。我曾经见过一个案例某分析工具在 CHA 模式下跑一个小型项目ICFG 的边数超过了百万内存直接爆掉切换到 RTA 后边数降到十万级别分析顺利完成。这不是工具的问题而是算法选型的问题。理解调用图精度对 ICFG 的影响才能在实际项目中做出合理的取舍。4. 避坑指南Soot 调用图与 ICFG 生成中的常见问题4.1 幻影引用导致分析中断现象运行 Soot 时抛出RuntimeException: This operation requires resolving level...或Aborting: cant find class分析直接终止。原因待分析的代码依赖了某些不在 classpath 中的类比如第三方库、JDK 内部类。Soot 默认要求所有引用的类都能被解析找不到就报错。解决添加-allow-phantom-refs参数让 Soot 为缺失的类创建占位符。如果某些包不需要分析可以用-exclude排除。另外确保-process-dir指向的目录包含所有需要的 class 文件必要时用-cp补充 classpath。4.2 入口点设置不当导致调用图不完整现象调用图里只有寥寥几个方法大量明明被调用的方法没有出现在图中。原因Soot 的调用图是从入口点开始扩展的。如果-main-class设置错误或者入口方法没有被正确识别扩展过程就无法触达那些方法。解决确认-main-class指向的类确实有public static void main(String[])方法。如果是分析库代码没有 main 方法需要用-entry-points手动指定入口方法或者通过编程方式在Scene.v()中添加入口点。另一个常见原因是入口方法本身没有被加载——检查-process-dir是否包含了入口类所在的目录。4.3 SPARK 分析超时或内存溢出现象使用 SPARK 算法时分析过程卡住不动或者抛出OutOfMemoryError。原因SPARK 的指向分析需要为每个变量维护指向集合大型项目的指向关系可能非常复杂导致迭代次数过多或内存占用过大。解决首先尝试调整 JVM 堆大小-Xmx。如果仍然不够可以考虑降级到 RTA 算法或者使用 SPARK 的vta:true选项VTA 是一种更轻量的变体。另外-p cg.spark merge-stringbuffer:false可以关闭字符串缓冲区的合并优化减少分析复杂度。对于特别大的项目分模块分析再合并结果也是一种可行策略。4.4 异常边缺失导致漏报现象在 ICFG 上做数据流分析时某些异常路径上的状态传播没有被捕获导致漏报。原因使用了BriefUnitGraph而不是ExceptionalUnitGraph前者不包含异常控制流边。解决构建UnitGraph时明确使用ExceptionalUnitGraph。如果分析确实不需要异常路径也要清楚这是一个有意的取舍而不是无意中遗漏。另外注意ExceptionalUnitGraph的omitExceptingUnitEdges参数控制是否为抛出异常的语句本身保留出边根据分析需求调整。4.5 递归调用导致 ICFG 无限展开现象构建 ICFG 时程序陷入死循环或栈溢出。原因递归方法在调用图中形成环如果 ICFG 构建算法没有做去重处理会沿着环无限展开。解决在构建 ICFG 时维护一个已访问的调用点目标方法集合对已经处理过的边跳过。ICFG 本身是一个图结构递归调用形成的环在图中表现为回边不需要也不应该被展开成无限路径。这是 ICFG 构建中最基本的防护措施。5. 精度与开销的平衡调用图选型的工程思考调用图算法的选择从来不是一个纯技术问题而是一个工程决策。CHA 快但糙SPARK 精但慢RTA 居中——这个阶梯看起来清晰但实际项目中影响选择的因素远不止这些。项目的代码规模、依赖复杂度、分析目标、可接受的分析时间、甚至运行分析机器的内存大小都会影响最终决策。我的一般做法是分层推进先用 CHA 快速跑一遍看看调用图的规模量级和方法的可达性分布。如果 CHA 的结果已经足够稀疏比如项目本身接口实现类很少那直接用 CHA 就够了没必要上 SPARK。如果 CHA 的结果过于稠密再切换到 RTA 观察改善程度。只有当 RTA 仍然无法满足精度要求时才考虑 SPARK。这个渐进式的策略能在大多数场景下避免杀鸡用牛刀式的资源浪费。另一个值得注意的点是调用图的精度不是越高越好。在某些分析任务中过度精确的调用图反而会引入问题。比如做保守的漏洞检测时你希望宁可误报不可漏报这时候 CHA 的过度近似反而是一种优势——它把所有可能的目标都包含进来了虽然有些永远不会发生但至少不会漏掉真正危险的路径。相反如果做的是误报敏感的分析比如代码推荐SPARK 的高精度才能减少噪音。ICFG 的构建同样面临这个取舍。ICFG 的边数直接决定了后续数据流分析的状态空间大小。一个包含异常边、多态调用边、递归回边的完整 ICFG其复杂度可能比过程内 CFG 高出几个数量级。在实际分析中经常需要根据分析目标对 ICFG 做裁剪——比如忽略某些不可能抛出异常的语句的异常边或者对递归调用做摘要处理而不是展开。验证调用图和 ICFG 是否正确有一个简单但有效的方法选取几个已知的调用关系手动检查它们是否出现在调用图中。比如某个Service类调用了Repository类的save方法在调用图中应该能找到对应的边。对于 ICFG可以选取一个包含try-catch的方法检查异常路径是否被正确连接。这种抽样验证虽然不能保证全局正确但能快速发现明显的配置错误。从那以后我每次构建调用图都会先用 CHA 跑一遍看规模再根据分析目标决定是否升级算法并且一定会用ExceptionalUnitGraph而不是图省事用BriefUnitGraph——这个习惯帮我避开了至少三次因为异常边缺失导致的漏报事故。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询