简介基于 Soot 框架的 Java 静态分析实战资源面向希望系统学习调用图与过程间控制流图生成的 Java 开发者、研究生及工具链研究人员。SootTest 项目以 Eclipse 工程形式组织完整演示了从源码或字节码加载、Soot 配置、Scene 构建到 Call Graph 与 ICFG 生成的全流程并附有 main-call-graph.dot、output.png 等可视化输出特别适合作为从入门到进阶的参考范例。资源包共 23 个文件压缩后约 12.27MB其中包含 3 个 Java 源文件、10 个编译后的 class 文件、2 个 dot 图结构文件、1 个带依赖的 soot 工具 jar 包以及必要的工程配置文件代码可直接导入 IDE 运行与二次扩展。目前已有 1440 人学习使用涵盖项目源码、可执行输出和依赖库读者既能对照源码理解 Soot 内部 API 的调用方式也能通过实际产物掌握方法间分析原理为后续开发自定义静态分析工具提供了直接可用的模板。1. 静态分析的第一张图调用图和过程间控制流图到底解决什么问题接到一个「摸清某个 Java 服务的完整调用链」的需求时第一反应往往是写 AST 遍历把方法之间的调用关系扫出来。跑通之后才发现递归调用、接口分派、异常路径这些情况手写的遍历根本收不住缺的还不是一两条边而是整段断链。这个项目的价值就是把这套底层能力直接给出来用 Soot 把 class 文件转成中间表示在此基础上产出两张分析绕不开的基础图——调用图Call Graph和过程间控制流图ICFG。适合的人群很明确做 Java 静态分析、安全扫描、测试影响分析但不想从零造「字节码 - IR - 方法内 CFG - 方法间串联」这条管线的开发者。这个东西能省掉的不是一两百行代码而是踩在 Soot 的类加载、方法解析、类型层级这些黑匣子上的调试时间。下面从环境搭建开始把每个环节的参数、代码、翻车点全部铺开。2. Soot 环境立起来JDK 版本、依赖注入与第一个 Jimple 输出2.1 为什么选 Soot而不是手写 AST 或直接用字节码栈从零开始做 Java 静态分析摆在面前的有三条路源代码 AST、字节码栈、中间表示。源代码 AST 看似直观但 Java 的语法糖太多自动装箱、字符串拼接转 StringBuilder、泛型擦除这些在 AST 里各有各的形态写遍历时每条都要单独处理工作量瞬间失控。直接读字节码则要面对操作数栈指令是iload、invokevirtual这种底层语义一条a b被拆成好几条字节码读起来费劲做数据流分析更费劲。Soot 的落点刚好在中间它把 class 文件统一转换成 Jimple 这种三地址码形式的中间表示。每条语句只做一件事操作数最多三个调用、赋值、返回都是独立语句分析起来非常规整。更重要的是Soot 把类加载、方法解析、继承体系、调用点识别这些脏活都包好了我只需要告诉它要分析哪几个类、classpath 怎么拼后面就能在统一的抽象上做图构建。2.2 实操Gradle 工程引入 Soot并加载目标类工程搭建我用 GradleJava 版本 11 或 17 都行Soot 依赖走 Maven Central在当前使用的稳定版本范围里 API 基本一致。先把依赖写进build.gradledependencies { implementation org.soot-oss:soot:4.6.0 }初始化代码单独放一个类避免每次分析都重复写一坨配置。下面是一段最简可跑的初始化逻辑// SootRunner.java —— 初始化 Soot 并加载待分析的目标类 public class SootRunner { public static void init() { G.reset(); // 每次运行前清空上一次的全局状态避免残留配置互相干扰 Options.v().set_whole_program(true); // 开启全程序分析后续调用图才能构建 Options.v().set_prepend_classpath(true); // 把系统 classpath 前置减少缺类报错 Options.v().set_keep_line_number(true); // 保留行号后面做 publish 回归定位有用 Options.v().set_process_dir(List.of(build/classes/java/main)); // 输入编译产物目录 Options.v().set_output_format(Options.output_format_jimple); // 输出 Jimple 而非 class Scene.v().loadClassAndSupport(demo.App); // 按类名加载目标类及其依赖 Scene.v().loadNecessaryClasses(); // 把 java.* 里的依赖类按需拉起来 PackManager.v().runPacks(); // 跑 body 级别的转换管道 PackManager.v().writeOutput(); // 把结果写到 sootOutput 目录 } }这里有几个关键参数说明。set_whole_program(true)是全程序分析的总开关不打开的话Scene里不会构建完整的类层级关系调用图无从谈起。set_process_dir接收的是编译后的 class 目录不是源码目录Soot 4.x 不吃 .java 文件这点和常见静态分析工具不太一样容易在第一步就错位。set_prepend_classpath(true)的作用是把 JRE 的 classpath 自动拼到最前面避免后续 resolve 时因为缺java.lang.Object这类基础类直接崩掉。2.3 验证输出从 Jimple 看中间表示的形态跑完上面的代码sootOutput目录下会出现demo/App.jimple。打开看一眼就能理解为什么要分析 Jimplepublic class demo.App extends java.lang.Object { public void check(java.lang.String) { demo.App r0; java.lang.String r1; r0 : this: demo.App; r1 : parameter0: java.lang.String; if r1 ! null goto label0; return; label0: virtualinvoke r0.demo.App: void say(java.lang.String)(r1); return; } }注意virtualinvoke这个关键字它表示虚调用是动态分派调用。Soot 把调用区分成virtualinvoke、specialinvoke构造器与私有方法、interfaceinvoke接口调用、staticinvoke静态调用四类这个区分直接决定了后面调用图构建时边的类型。我在分析代码里拿到InvokeStmt时第一件事就是判断它的调用类型因为不同类型的边在过程间分析里语义不一样。2.4 初始化参数速查表初始化阶段最常用的配置项就这么一张表就够配置项作用建议set_process_dir指定待分析的 class 目录传编译产物目录支持多个路径set_soot_classpath手动指定 Soot 的 classpath有第三方依赖时必需否则报 MethodNotFoundExceptionset_prepend_classpathJRE classpath 自动前置建议一直开着set_keep_line_number保留行号信息做定位、回归分析时打开set_output_format输出 Jimple 或 class调试阶段输出 Jimple 可读性最好set_whole_program全程序分析总开关要生成调用图必须打开刚接触 Soot 的人常犯一个错误runPacks()跑完就觉得分析结束直接去取调用图结果拿回来一个空对象。这个坑在第 5 章会专门展开调用图不在runPacks()的职责范围内需要单独触发cgpack。3. 生成调用图CHA 与 Spark 的取舍附可直接跑的代码3.1 原理调用边是怎么长出来的调用图是一张有向图节点是方法边是「调用者 - 被调用者」。真正难的不是画图而是决定一条invoke语句到底指向哪个方法。Java 的虚调用在编译期只知道声明类型运行时要根据实际对象类型做动态分派静态分析必须靠算法去猜测。Soot 默认的 CHAClass Hierarchy Analysis思路很直白对于一条interfaceinvoke把所有实现了该接口的类里对应方法全连上对于virtualinvoke把所有子类里可覆盖的方法都连上。这种方案快但精度低List.size()会把ArrayList、LinkedList、Vector的实现全部拉进来产生大量无关边。Spark 则更精细它沿着调用图做类型传播只追踪可能被实例化的类型边数少很多代价是分析时间上升。实际工程里的常见做法是先用 CHA 跑通流程确认类加载没问题后再切到 Spark 收紧精度。反射调用两种算法都处理不了因为反射的调用目标运行时才确定这部分需要单独打补丁后面第 5 章会讲。3.2 实操生成全程序调用图并打印每条调用边初始化完成之后调用图的构建需要单独触发。下面这段代码把调用图构建跑起来并打印所有应用类内方法间的调用边// CallGraphDump.java —— 生成调用图并输出调用边明细 import soot.*; import soot.jimple.toolkits.callgraph.CallGraph; import soot.jimple.toolkits.callgraph.Edge; import java.util.Iterator; public class CallGraphDump { public static void dump() { PhaseOptions.v().setPhaseOption(cg, all-reachable:true); // 把所有可达方法纳入图 PackManager.v().getPack(cg).apply(); // 显式执行 cg pack 构造调用图 CallGraph cg Scene.v().getCallGraph(); for (SootClass clazz : Scene.v().getApplicationClasses()) { for (SootMethod method : clazz.getMethods()) { if (!method.hasActiveBody()) continue; // 没有 body 说明没有可分析实现 IteratorEdge it cg.edgesOutOf(method); while (it.hasNext()) { Edge e it.next(); SootMethod caller e.src().method(); // 调用方方法 SootMethod callee e.tgt().method(); // 被调用方方法 Unit callsite e.srcUnit(); // 调用发生的那条 Unit 语句 System.out.printf(%s - %s %s%n, caller.getSignature(), callee.getSignature(), callsite); } } } } }这段代码有两点值得说明。第一PackManager.v().getPack(cg).apply()不是可选项调用图的构造发生在它里面。Soot 的runPacks()只管方法体级别的转换cg属于独立 pack不显式调用的话Scene.v().getCallGraph()只会返回空图。第二e.src().method()和e.tgt().method()的返回值类型是SootMethod而e.src()本身返回的是MethodOrMethodContext接口SootMethod 实现了该接口所以直接调method()拿到的就是方法本体。拿到srcUnit()之后可以定位到具体是哪一行、哪条语句发起了调用这对后续做过程间分析很重要。3.3 参数CHA 和 Spark 怎么切库方法怎么过滤调用图构建相关的参数集中在cgpack 下最常用的几个配置如下参数写法作用setPhaseOption(cg, all-reachable:true)忽略入口局限性把所有可达方法都纳入图setPhaseOption(cg.cha, on)显式开启 CHA 算法默认setPhaseOption(cg.spark, on)用 Spark 代替 CHA精度更高setPhaseOption(cg.spark, on-fly:true)Spark 运行过程中动态扩充类型信息适合大型程序Options.v().set_exclude(List.of(java., javax., sun.))排除 JDK 库方法减少图规模Options.v().set_no_bodies_for_excluded(true)被排除的类不加载方法体内存占用明显下降精度校准的做法我一般是这样第一轮用默认 CHA 跑通把全部调用边导出统计一下规模记下边数第二轮开 Spark再对比边数差异。如果两者边界差距超过 20%大概率是问题出在接口调用或者泛型相关的分派上需要检查有没有不该连接的方法被连了进来。set_exclude里排除包名时要带上点号后缀写成java.而不是java否则前缀匹配会把javaagent之类的应用类也误伤。4. 拼出过程间控制流图UnitGraph 结合 CallGraph 的组装方案4.1 原理ICFG 不是新图是两张图的粘合过程间控制流图ICFG放在调用图之后讲是因为它的构造依赖调用图。方法内的控制流图CFG描述单个方法内部语句的执行顺序节点是Unit每一条 Jimple 语句就是一个 Unit边是控制流转移。单独看一个方法的 CFG 不难困难在于把方法之间的控制流串起来。ICFG 的定义包含四类边方法内的普通控制流边、调用边从调用点进入被调方法的入口、返回边从被调方法的出口回到调用点之后的语句、调用-返回边跳过被调方法内部直接从调用点连到下一条语句模拟不深入分析函数的场景。Soot 标准 API 没有直接提供一个现成的 ICFG 类常规做法是自己组装方法内的 CFG 用ExceptionalUnitGraph方法间的调用边用CallGraph两者结合就得到了完整的 ICFG。选ExceptionalUnitGraph而不是BriefUnitGraph的原因是异常处理路径不能丢BriefUnitGraph只保留正常控制流遇到try-catch结构会直接断边分析结果会缺一截。4.2 实操把方法内 CFG 与调用边组装成 ICFG组装逻辑分三步先遍历应用类的方法拿到Body对每个Body构造ExceptionalUnitGraph再遍历图里的每个Unit遇到InvokeStmt就把它当作潜在调用点最后到CallGraph里捞所有从当前方法出发的边按srcUnit()匹配到具体调用点记录被调方法集合。代码如下// InterproceduralCFG.java —— 组装方法内 CFG 与调用边构建 ICFG import soot.*; import soot.toolkits.graph.ExceptionalUnitGraph; import soot.jimple.toolkits.callgraph.Edge; import soot.jimple.*; import java.util.*; public class InterproceduralCFG { private final MapSootMethod, ExceptionalUnitGraph cfgCache new HashMap(); private final MapUnit, SetSootMethod callSites new HashMap(); public ExceptionalUnitGraph cfgOf(SootMethod method) { // 缓存同一个方法体的 CFG避免重复构建消耗时间 return cfgCache.computeIfAbsent(method, m - new ExceptionalUnitGraph(m.retrieveActiveBody())); } public void build(Scene scene) { CallGraph cg scene.getCallGraph(); for (SootClass clazz : scene.getApplicationClasses()) { for (SootMethod method : clazz.getMethods()) { if (!method.hasActiveBody()) continue; // 抽象方法、native 方法没有 body跳过 ExceptionalUnitGraph cfg cfgOf(method); for (Unit unit : cfg) { if (!(unit instanceof InvokeStmt)) continue; // 只处理调用语句 IteratorEdge it cg.edgesOutOf(method); while (it.hasNext()) { Edge edge it.next(); if (edge.srcUnit() unit) { // srcUnit 精确匹配到当前调用点 callSites.computeIfAbsent(unit, k - new HashSet()) .add(edge.tgt().method()); } } } } } } public SetSootMethod calleesOf(Unit callsite) { return callSites.getOrDefault(callsite, Set.of()); } }edge.srcUnit() unit这个等值判断是整段代码的核心它把方法级调用图降维到语句级调用图的边本来是「方法 A - 方法 B」通过调用点 Unit 精确匹配就能知道方法 A 具体是哪一条语句调用了方法 B。retrieveActiveBody()在方法没有 body 时会抛异常所以前面必须用hasActiveBody()做守卫这个顺序不能反。得到calleesOf之后整个 ICFG 的边集合就清晰了方法内走cfg的边方法间走callSites的调用边返回边则从被调方法的getTails()连回调用点的后继语句。4.3 落盘把 ICFG 导出成 dot 文件可视化图构建出来之后一定要可视化一次否则节点数、边数对不对全靠猜。导出 dot 文件的代码如下// ICFGDotExport.java —— 将 ICFG 导出为 Graphviz dot 文件 import soot.*; import soot.toolkits.graph.ExceptionalUnitGraph; import soot.jimple.toolkits.callgraph.Edge; import java.io.PrintWriter; import java.util.*; public class ICFGDotExport { private static String escape(String label) { // dot 标签里 会被解释成 HTML必须转义 return label.replace(\\, \\\\) .replace(\, \\\) .replace(, lt;) .replace(, gt;); } public static void export(InterproceduralCFG icfg, Scene scene, String path) throws Exception { MapUnit, String nodeIds new HashMap(); int id 0; try (PrintWriter pw new PrintWriter(path)) { pw.println(digraph ICFG {); // 先给所有 Unit 分配稳定编号避免方法签名里的特殊字符破坏语法 for (SootClass clazz : scene.getApplicationClasses()) { for (SootMethod method : clazz.getMethods()) { if (!method.hasActiveBody()) continue; ExceptionalUnitGraph cfg icfg.cfgOf(method); for (Unit unit : cfg) { String node n (id); nodeIds.put(unit, node); pw.printf( %s [label\%s\];%n, node, escape(unit.toString())); } } } // 输出方法内控制流边 for (SootClass clazz : scene.getApplicationClasses()) { for (SootMethod method : clazz.getMethods()) { if (!method.hasActiveBody()) continue; ExceptionalUnitGraph cfg icfg.cfgOf(method); for (Unit unit : cfg) { for (Unit succ : cfg.getSuccsOf(unit)) { pw.printf( %s - %s;%n, nodeIds.get(unit), nodeIds.get(succ)); } } } } // 输出调用边用颜色区分 CallGraph cg scene.getCallGraph(); for (Unit callsite : icfg.getAllCallSites()) { SootMethod caller icfg.ownerOf(callsite); IteratorEdge it cg.edgesOutOf(caller); while (it.hasNext()) { Edge edge it.next(); if (edge.srcUnit() callsite icfg.calleesOf(callsite).contains(edge.tgt().method())) { pw.printf( %s - %s [colorred];%n, nodeIds.get(callsite), nodeIds.get(edge.tgt().method().retrieveActiveBody().getUnits().getFirst())); } } } pw.println(}); } } }渲染命令很简单dot -Tpng icfg.dot -o icfg.pngescape方法解决的问题是方法签名里void init()的尖括号在 dot 里会被当成 HTML 标签解析导致整个文件渲染失败或者节点显示成非预期效果。给每个 Unit 单独分配n0、n1这种纯数字 id再从 label 里展示可读内容是规避这类问题的通用做法。5. 高频避坑调用图为空、缺类、反射漏边和 dot 乱码5.1 启动与分析流程的坑调用图跑出来是空的现象初始化流程跑完PackManager.v().runPacks()执行没有报错但拿Scene.v().getCallGraph()一数边数是 0图上什么都没有。原因调用图的构造在cgpack 里而runPacks()只执行 body 级别的转换管道。Soot 的 pack 体系里jtp管方法体转换wjtp管全程序方法体转换cg管调用图构建三者彼此独立。只调runPacks()等价于把cg漏掉了。解决在拿getCallGraph()之前显式执行PackManager.v().getPack(cg).apply()或者直接调PhaseOptions.v().setPhaseOption(cg, all-reachable:true)后重新跑。我习惯在初始化代码里把 cg pack 的执行放在Scene加载完成后立即调用避免后续代码到处找原因。5.2 classpath 与依赖的坑MethodNotFoundException 和 phantom class现象set_process_dir指向自己项目的编译输出目录后Soot 报MethodNotFoundException: Method demo.App: void check(java.lang.String) not found或者更常见的引用第三方库时直接抛ClassNotFoundException。原因Soot 的类解析依赖它自己的 classpath而不是 JVM 的 classpath。项目里通过 Gradle 拉来的依赖 jar如果没拼进 Soot 的 classpathSoot 压根看不见那些类。set_prepend_classpath(true)只是把 JRE 的类提前不会自动引入业务依赖。解决初始化时把依赖 jar 的路径手工拼到 Soot classpath 里。常见做法是先拿到 Gradle 的 runtimeClasspath 路径集合再set_soot_classpath(String.join(File.pathSeparator, paths))。如果某些类确实拿不到又不想让解析中断可以开Options.v().set_allow_phantom_refs(true)Soot 会把解析不到的类当成 phantom class 占位分析不中断但精度会下降。注意 phantom class 的方法没有 body遍历getMethods()时要加hasActiveBody()判断。5.3 精度与输出的坑反射漏边、exclude 误伤、dot 乱码现象一代码里有Class.forName(com.demo.RealService).getDeclaredConstructor().newInstance()下游的分析任务发现从这个点开始的调用链全断了。原因CHA 和 Spark 都基于声明类型做解析反射的目标类名在字符串里分析器看不到。Class.forName后面那串调用在调用图里没有任何边。解决这类反射调用必须手工兜底。常见做法是分析前先扫描代码里的反射字符串再用Scene.v().loadClassAndSupport(com.demo.RealService)把对应类强制加载进来必要时手工往调用图里补边。Soot 没有内置反射字符串解析这块需要自己写扫描器别指望参数配置能直接解决。现象二配置了set_exclude(List.of(java., javax.))之后应用包里的com.javaanalyze类也被排除掉了调用图规模异常缩小。原因exclude按类名字符串前缀匹配java.这个前缀不只匹配 JDK 的java.*包任何类名以java.开头的类都会被一刀切。解决exclude 前缀写精确尽量写到包路径的完整层级。同时配合set_no_bodies_for_excluded(true)让被排除的类只注册不加载 body降低内存压力。如果分析目标本身就跑在java.*包下那这组参数要改用allowJDK的替代方案不要和 JDK 库硬解耦。现象三导出 dot 文件后用 Graphviz 渲染发现一些节点被渲染成了 HTML 表格样式或者整个文件直接报语法错误。原因dot 语法里...会被解释为 HTML 标签。Java 方法签名里大量存在void init()、java.lang.String[]这种尖括号和方括号不做转义就会破坏输出结构。解决导出代码里保留之前写的escape方法对所有标签内容做替换转成lt;转成gt;引号转义。节点 id 全部用自增编号不要用签名当 id一劳永逸。6. 进阶一点用可达性断言验证生成图的可信度图生成出来不等于图是对的尤其调用图这种依赖类层级解析的结果配置漏一个参数就可能导致整层方法缺失。我后来养成了一个习惯给调用图加一层显式的可达性断言从入口方法出发验证目标方法是否真的在图上可达。代码很简短// ReachabilityCheck.java —— 从入口 BFS 调用边验证目标方法是否可达 import soot.*; import soot.jimple.toolkits.callgraph.CallGraph; import soot.jimple.toolkits.callgraph.Edge; import java.util.*; public class ReachabilityCheck { public static boolean isReachable(SootMethod entry, SootMethod target, CallGraph cg) { SetSootMethod visited new HashSet(); DequeSootMethod stack new ArrayDeque(); stack.push(entry); while (!stack.isEmpty()) { SootMethod current stack.pop(); if (current.equals(target)) return true; if (!visited.add(current)) continue; IteratorEdge it cg.edgesOutOf(current); while (it.hasNext()) { SootMethod callee it.next().tgt().method(); if (callee ! null) stack.push(callee); // 防止 phantom 类方法调用空指针 } } return false; } }这个断言的价值不止于「调对了没有」。我在项目里用模拟数据验证过把入口设成demo.App.main目标设成一个只被反射调用的业务方法跑完断言返回false——不是因为 Soot 挂了而是这个方法确实没有静态调用边。这个结果反过来帮助定位到反射场景的漏报提醒需要补手工边。后来我把它做成一个回归工具每次生成 CG 和 ICFG 后先跑一遍入口到关键业务方法的可达性检查再把 ICFG 的边数记录到基线文件里。版本迭代后如果边数骤降不用查就知道是配置或依赖出了变化。从那以后我每次在项目里用 Soot 生成调用图和过程间控制流图都会强制走一遍这个流程初始化完先跑 cg pack导出调用边统计组装 ICFG 后导 dot 可视化最后跑可达性断言加边数回归。三步下来图「看起来对」变成「验证过对」。这套方法帮我挡掉过不少次配置回退导致的静默断链也希望帮到你。本文还有配套的精品资源点击获取