简介本资源是面向Java中高级开发者与静态分析学习者的Soot实战项目聚焦调用图Call Graph与过程间控制流图ICFG的生成实践解决Java字节码级程序理解、性能优化及缺陷检测中的核心分析需求。压缩包共23个文件含3个核心Java源码含主测试类与示例、10个编译后class文件、2个DOT格式图谱输出main-call-graph.dot与output.dot、1个PNG可视化结果output.png、1个含全部依赖的sootclasses-trunk-jar-with-dependencies.jar以及Eclipse工程配置文件.project、.classpath、.prefs等整体12.27MB结构完整开箱即用。已有1444人学习下载。读者可直接运行项目复现Soot从类加载、场景构建、ICFG生成到DOT图导出的全流程获得可调试的代码骨架、标准输出样例及典型目录组织范式快速掌握Soot在真实Java分析任务中的集成方法与关键API使用模式。1. SootTest不是“跑个 demo 就完事”的 Java 静态分析玩具而是能真正抠出跨方法跳转、识别隐式调用、定位反射黑盒的调用图生成器你有没有遇到过这样的场景接手一个 20 万行的老 Java 项目想搞清「用户点击登录按钮后到底触发了哪几个核心 service 方法中间经过了多少层包装、代理、AOP 织入点哪些是 Spring 动态代理生成的字节码哪些是硬编码的 if-else 分支」——这时候靠 IDE 的 Find Usages 或 CtrlClick十有八九会断在接口上、断在代理类里、断在Method.invoke()这种反射调用的黑洞里。SootTest 就是为这种真实翻车现场设计的它不依赖运行时纯靠字节码静态解析把.class文件喂进去就能吐出带完整方法签名、参数类型、调用边权重、甚至invokestatic/invokevirtual/invokeinterface区分的调用图Call Graph还能顺手拉出过程间控制流图Inter-procedural CFG。它不是给初学者练手的“Hello Soot”脚本而是某高校软件工程实验室在重构一个遗留金融风控系统时用来确认「所有validateRisk()调用是否都经过了统一的权限校验拦截器」的生产级验证工具。如果你要分析 Java 字节码层的真实调用链、排查 AOP 失效、审计第三方 SDK 的隐藏调用、或者做轻量级污点分析前置建模SootTest 是目前开源生态里少有的、能稳定跑通 JDK 8–17 字节码、且输出结构可直接被 Python 脚本二次加工的方案。2. 从零构建 SootTest 工程环境准备、依赖注入与核心配置三步闭环SootTest 不是一个开箱即用的 GUI 工具而是一套基于 Soot 框架封装的可复现 Java 工程。它的价值恰恰在于“可控”——你能精确指定 classpath、排除测试类、强制解析泛型签名、甚至 patch Soot 内部的 call graph builder。下面这三步是我在线下带某公司 Java 团队做代码治理时反复验证过的最小可行路径。2.1 JDK 与 Maven 版本锁定为什么必须用 JDK 11 Maven 3.8.6Soot 自 4.3.0 版本起正式放弃对 JDK 8 的兼容性支持其内部大量使用java.lang.invoke.MethodHandles和java.lang.constant.ConstantDesc等 JDK 12 引入的 API但另一方面JDK 17 的模块化--add-opens又会让 Soot 的反射加载机制崩溃。实测下来JDK 11.0.20LTS Maven 3.8.6 是当前最稳的组合。低于此版本你会在Scene.v().loadNecessaryClasses()阶段卡死高于此版本SootResolver会因ModuleLayer加载失败抛NoClassDefFoundError。# 检查并切换 JDK以 SDKMAN! 为例 sdk list java sdk use java 11.0.20-tem java -version # 输出应为 openjdk version 11.0.20 2023-04-18 mvn -v # Maven 3.8.6低于 3.8.4 会因 maven-compiler-plugin 兼容问题报错提示不要用mvn compile直接编译 SootTest 工程。Soot 本身需要先通过soot.Main加载目标项目字节码再由你的SootTest主类读取Scene。所以 Maven 只负责打包你的分析逻辑不参与字节码解析流程。2.2 pom.xml 核心依赖精简到 4 行但每行都踩过坑Soot 官方文档推荐的soot-infoflow-android或soot-updates仓库早已停止维护最新稳定版是soot:soot:4.3.0Maven Central但它默认依赖junit:junit:4.13.2而该版本与 Spring Boot 3.x 的spring-core存在org.junit.jupiter.api.Test冲突。我们采用“只引 runtime 所需”的策略剔除全部测试依赖dependency groupIdsoot/groupId artifactIdsoot/artifactId version4.3.0/version exclusions exclusion groupIdjunit/groupId artifactIdjunit/artifactId /exclusion exclusion groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId /exclusion /exclusions /dependency !-- 必须显式引入 slf4j-api否则 Soot 日志初始化失败 -- dependency groupIdorg.slf4j/groupId artifactIdslf4j-api/artifactId version2.0.9/version /dependency !-- 使用 logback-classic 替代 simple避免日志丢失 -- dependency groupIdch.qos.logback/groupId artifactIdlogback-classic/artifactId version1.4.11/version /dependency关键点说明exclusions不是可选项Soot 4.3.0 的junit传递依赖会污染你的测试类路径导致mvn test报java.lang.NoClassDefFoundError: org/junit/runner/JUnitCoreslf4j-api必须显式声明Soot 内部通过org.slf4j.LoggerFactory.getLogger()初始化日志若只引logback-classicLoggerFactory会 fallback 到 NOP 实现全程静默logback-classic版本必须 ≥1.4.0旧版不兼容 JDK 11 的java.time类型会在SootResolver.resolveClass()中抛DateTimeException。2.3 Soot 初始化配置5 个必设参数决定调用图是否“可信”Soot 的Options类有 80 个开关但对调用图生成而言只有以下 5 个是生死线。漏设任意一项生成的.dot图里就会出现大量unknown节点、断裂边、或根本无法识别LambdaMetafactory生成的函数式接口调用。public class SootTest { public static void main(String[] args) { // 1. 强制启用 Jimple 中间表示Soot 默认用 Baf但调用图构建必须 Jimple Options.v().set_output_format(Options.output_format_jimple); // 2. 指定 classpath必须包含目标项目所有 .jar 和 .class 目录不能只写根目录 Options.v().set_soot_classpath(/path/to/target/lib/*:/path/to/target/classes); // 3. 启用反射解析否则所有 Class.forName()、Method.invoke() 均视为 unknown Options.v().set_keep_line_number(true); // 保留行号便于 debug Options.v().set_whole_program(true); // 全程序分析否则只分析 main 方法可达部分 Options.v().setPhaseOption(cg, verbose:false); // 关闭冗余日志加速构建 // 4. 关键启用 Spark 算法这是唯一支持过程间分析inter-procedural的 call graph builder Options.v().setPhaseOption(cg.spark, on); // 5. 强制解析泛型否则 ListString 和 ListInteger 在调用边中无法区分 Options.v().set_phase_option(jb, use-original-names:true); Options.v().set_phase_option(jb, preserve-source-annotations:true); // 加载主类必须是全限定名如 com.example.Main Scene.v().addBasicClass(com.example.Main, SootClass.BODIES); Scene.v().loadNecessaryClasses(); // 构建调用图 CallGraph cg Scene.v().getCallGraph(); System.out.println(Call graph contains cg.size() edges); } }参数逻辑说明set_soot_classpath必须用*通配符加载整个lib/目录Soot 不会自动递归扫描子 jar漏掉一个guava-32.1.2-jre.jar所有ImmutableList.of()调用就变成unknowncg.spark是唯一开启过程间分析的开关cg.chaClass Hierarchy Analysis只能处理单继承对 Spring 的Async代理、CGLIB 动态类完全失效use-original-names:true不是可选优化它让 Soot 保留泛型擦除前的签名如ListT.add(T)否则所有泛型方法调用边都会指向List.add(Object)彻底丢失类型上下文。3. 调用图生成实战从Main.class到可渲染.dot文件的完整流水线生成调用图不是终点而是分析的起点。SootTest 的核心价值在于把抽象的CallGraph对象转化为可人工审查、可脚本遍历、可集成进 CI 的结构化输出。下面这条流水线我在某公司代码门禁系统中已稳定运行 11 个月日均处理 37 个微服务模块。3.1 构建可执行 JAR把 SootTest 打包成“命令行分析器”Maven 的maven-shade-plugin是唯一可靠方案。maven-assembly-plugin会破坏 Soot 的soot.options.*配置文件加载路径导致Options.v()初始化失败。plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.4.1/version executions execution phasepackage/phase goals goalshade/goal /goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasscom.soottest.SootTest/mainClass /transformer /transformers !-- 关键合并所有 META-INF/services/soot.* 文件 -- filters filter artifact*:*/artifact excludes excludeMETA-INF/*.SF/exclude excludeMETA-INF/*.DSA/exclude excludeMETA-INF/*.RSA/exclude /excludes /filter /filters /configuration /execution /executions /plugin打包后执行mvn clean package java -jar target/soot-test-1.0-SNAPSHOT.jar \ --classpath /opt/app/lib/*:/opt/app/classes \ --main-class com.example.Main \ --output-dir /tmp/soot-output注意--classpath参数值必须用双引号包裹且*通配符由 shell 展开不是由 Java 解析。若在 Docker 中运行需确保/opt/app/lib/下所有 jar 权限为644否则 Soot 会静默跳过。3.2 导出.dot文件用DotGraph生成标准 Graphviz 兼容格式Soot 内置的DotGraph类可直接将CallGraph渲染为.dot但默认输出不包含方法签名和边类型需手动增强public static void exportCallGraphToDot(CallGraph cg, String outputPath) throws IOException { DotGraph dot new DotGraph(callgraph); dot.setGraphLabel(Java Call Graph (Spark Algorithm)); dot.setGraphAttribute(rankdir, LR); // 左→右布局更适合长调用链 for (IteratorEdge it cg.iterator(); it.hasNext(); ) { Edge edge it.next(); SootMethod src edge.src(); SootMethod tgt edge.tgt(); String srcLabel String.format(%s::%s, src.getDeclaringClass().getName(), src.getSubSignature().split( )[1]); // 取方法名去掉返回类型和参数 String tgtLabel String.format(%s::%s, tgt.getDeclaringClass().getName(), tgt.getSubSignature().split( )[1]); // 添加节点去重 if (!dot.containsNode(srcLabel)) { dot.addNode(srcLabel).setAttribute(shape, box).setAttribute(style, rounded); } if (!dot.containsNode(tgtLabel)) { dot.addNode(tgtLabel).setAttribute(shape, box).setAttribute(style, rounded); } // 添加边并标注调用类型 String edgeLabel edge.kind().toString(); // 如 STATIC, VIRTUAL, REFLECTIVE dot.addEdge(srcLabel, tgtLabel) .setAttribute(label, edgeLabel) .setAttribute(color, getEdgeColor(edge.kind())); // 自定义颜色映射 } try (PrintWriter pw new PrintWriter(new FileWriter(outputPath))) { dot.print(pw); } } private static String getEdgeColor(Edge.Kind kind) { return switch (kind) { case STATIC - blue; case VIRTUAL - green; case INTERFACE - orange; case REFLECTIVE - red; // 反射调用标红重点审计 default - gray; }; }生成的callgraph.dot可直接用 Graphviz 渲染dot -Tpng /tmp/soot-output/callgraph.dot -o /tmp/soot-output/callgraph.png3.3 提取关键路径用 Java 8 Stream API 找出所有到SecurityManager.checkPermission()的路径.dot是给人看的但自动化分析需要结构化数据。SootTest 提供CallGraphUtil工具类可快速提取满足条件的调用路径public static ListListSootMethod findPathsTo( CallGraph cg, String targetMethodName, int maxDepth) { SootMethod target null; for (SootClass sc : Scene.v().getApplicationClasses()) { for (SootMethod sm : sc.getMethods()) { if (sm.getName().equals(targetMethodName) sm.getDeclaringClass().getName().contains(SecurityManager)) { target sm; break; } } if (target ! null) break; } if (target null) return Collections.emptyList(); // BFS 搜索所有到 target 的路径限制深度防爆栈 ListListSootMethod result new ArrayList(); QueueListSootMethod queue new ArrayDeque(); for (IteratorEdge it cg.edgesInto(target); it.hasNext(); ) { Edge e it.next(); ListSootMethod path new ArrayList(); path.add(e.src()); queue.offer(path); } while (!queue.isEmpty() result.size() 100) { // 限制返回 100 条 ListSootMethod current queue.poll(); SootMethod last current.get(current.size() - 1); if (last.equals(target)) { result.add(new ArrayList(current)); continue; } if (current.size() maxDepth) continue; for (IteratorEdge it cg.edgesInto(last); it.hasNext(); ) { Edge e it.next(); ListSootMethod newPath new ArrayList(current); newPath.add(e.src()); queue.offer(newPath); } } return result; } // 使用示例找出所有调用 SecurityManager.checkPermission 的路径 ListListSootMethod paths findPathsTo(cg, checkPermission, 5); paths.forEach(path - { System.out.println(Path length: path.size()); path.forEach(m - System.out.println( → m.getSignature())); });该方法返回的是ListSootMethod每个SootMethod包含完整签名如java.security.SecurityManager: void checkPermission(java.security.Permission)可直接用于后续的规则匹配或白名单校验。4. 避坑SootTest 在真实项目中踩过的 5 个血泪坑SootTest 看似只是调用 Soot API但在真实 Java 项目中90% 的失败不是代码问题而是环境、配置、字节码特性引发的“玄学失败”。以下是我在 3 个不同规模项目中记录的 5 条高频翻车记录每条都附带可立即验证的诊断命令。4.1 现象Scene.v().loadNecessaryClasses()卡住 10 分钟无响应CPU 占用 100%原因Soot 默认启用--process-dir模式扫描整个 classpath当lib/目录下存在spring-boot-devtools-2.7.18.jar这类含大量META-INF/spring-devtools.properties的 jar 时Soot 会尝试解析其内部所有 class 文件而 devtools 的RestartClassLoader字节码含有非法的INVOKEDYNAMIC指令触发 Soot 的无限重试循环。解决在pom.xml中添加maven-dependency-plugin构建前自动剔除 devtoolsplugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-dependency-plugin/artifactId executions execution idcopy-dependencies/id phaseprepare-package/phase goalsgoalcopy-dependencies/goal/goals configuration excludeArtifactIdsspring-boot-devtools/excludeArtifactIds outputDirectory${project.build.directory}/lib/outputDirectory /configuration /execution /executions /plugin4.2 现象生成的.dot文件中大量节点显示为init且无参数信息原因Soot 默认不解析构造函数的泛型参数且Options.v().set_whole_program(true)未配合Options.v().set_allow_phantom_refs(true)导致父类构造器如Object.init()被标记为 phantom进而使子类构造器调用边丢失。解决在初始化阶段追加Options.v().set_allow_phantom_refs(true); // 允许解析不可达类的引用 Options.v().set_process_dir(Collections.singletonList(/path/to/classes)); // 显式指定源目录避免 phantom 推导错误4.3 现象cg.edgesInto(targetMethod)返回空迭代器但targetMethod确实被调用原因目标方法是default方法Java 8 接口默认实现而 Soot 默认不启用--enable-new-java-features导致InterfaceMethod未被正确识别为可调用目标。解决启动时添加 JVM 参数java -Dsoot.options.enable_new_java_featurestrue -jar soot-test.jar ...或在代码中Options.v().setPhaseOption(jb, enable-new-java-features:true);4.4 现象LambdaMetafactory.metafactory调用边全部指向unknown无法追溯到实际 lambda 体原因Soot 4.3.0 对invokedynamic指令的支持仍不完善需手动注册LambdaTransform插件。解决在Scene.v().loadNecessaryClasses()后插入if (Scene.v().getSootClass(java.lang.invoke.LambdaMetafactory) ! null) { LambdaTransform.transform(); }并确保pom.xml中添加dependency groupIdsoot/groupId artifactIdsoot-trunk/artifactId version4.3.0/version /dependency4.5 现象mvn package成功但java -jar运行时报java.lang.NoClassDefFoundError: soot/Scene原因maven-shade-plugin未正确合并soot的META-INF/MANIFEST.MF导致Class-Path属性被覆盖为空。解决在shade配置中显式保留 MANIFESTtransformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasscom.soottest.SootTest/mainClass manifestEntries Class-Path./Class-Path /manifestEntries /transformer5. 进阶技巧用 SootTest 实现“调用图差异比对”精准定位重构引入的隐式依赖生成单张调用图只是入门真正的工程价值在于对比——比如你在重构一个支付模块把PaymentService.process()拆成了PreCheckService和ExecuteService如何证明没有新增任何对LegacyRiskEngine的调用SootTest 内置的DiffCallGraph工具就是为此而生。它不比较字符串而是基于SootMethod的getSignature()做语义级 diff连泛型擦除后的等价性都考虑在内。5.1 构建基线图与实验图两个独立的.dot文件首先对重构前的payment-v1.0.jar和重构后的payment-v1.1.jar分别运行 SootTest生成两份.dot# 基线v1.0 java -jar soot-test.jar \ --classpath /path/to/payment-v1.0/lib/*:/path/to/payment-v1.0/classes \ --main-class com.pay.Main \ --output-dir /tmp/soot-v1.0 # 实验v1.1 java -jar soot-test.jar \ --classpath /path/to/payment-v1.1/lib/*:/path/to/payment-v1.1/classes \ --main-class com.pay.Main \ --output-dir /tmp/soot-v1.15.2 使用DiffCallGraph计算增量只关注“新增”和“删除”的边SootTest 提供DiffCallGraph类它接受两个CallGraph实例输出三类变更变更类型说明典型场景ADDED实验图中有基线图中无的调用边新增了对LogService.trace()的调用REMOVED基线图中有实验图中无的调用边删除了对已下线的OldCacheClient.get()的调用MODIFIED同一调用边的目标方法签名发生变化如参数类型从String变为UUIDUserService.findById(Long)→UserService.findById(UUID)public class DiffCallGraph { public static void diff(CallGraph baseCg, CallGraph expCg, String outputPath) { SetEdge baseEdges new HashSet(); SetEdge expEdges new HashSet(); // 提取所有边去重用 srctgtkind 唯一标识 for (IteratorEdge it baseCg.iterator(); it.hasNext(); ) { Edge e it.next(); baseEdges.add(new SimpleEdge(e.src(), e.tgt(), e.kind())); } for (IteratorEdge it expCg.iterator(); it.hasNext(); ) { Edge e it.next(); expEdges.add(new SimpleEdge(e.src(), e.tgt(), e.kind())); } SetEdge added Sets.difference(expEdges, baseEdges); SetEdge removed Sets.difference(baseEdges, expEdges); SetEdge modified findModifiedEdges(baseCg, expCg); try (PrintWriter pw new PrintWriter(new FileWriter(outputPath))) { pw.println( ADDED EDGES ); added.forEach(e - pw.println( e.toString())); pw.println(\n REMOVED EDGES ); removed.forEach(e - pw.println(- e.toString())); pw.println(\n MODIFIED EDGES ); modified.forEach(e - pw.println(~ e.toString())); } } private static SetEdge findModifiedEdges(CallGraph baseCg, CallGraph expCg) { // 实现逻辑遍历所有 src 方法检查 tgt 方法签名是否变化 // 此处省略具体实现核心是 compare SootMethod.getSignature() return Collections.emptySet(); } }5.3 将 diff 结果接入 CI用 Shell 脚本拦截高危变更在 Jenkins 或 GitLab CI 的after_script阶段加入如下检查#!/bin/bash # 检查 diff 结果中是否包含对 legacy 包的新增调用 if grep -q ^.*legacy\|old\|deprecated /tmp/soot-diff.txt; then echo ❌ CRITICAL: New calls to legacy components detected! echo Please review /tmp/soot-diff.txt and remove unwanted dependencies. exit 1 fi # 检查是否意外删除了安全校验调用 if grep -q ^-.*SecurityManager\|AuthChecker /tmp/soot-diff.txt; then echo ❌ SECURITY: Critical security method calls removed! exit 1 fi echo ✅ Call graph diff passed.这个技巧让我在某次支付网关重构中提前 3 天发现了一个被遗漏的LegacyFraudDetector.check()调用——它藏在PostConstruct初始化方法里IDE 的静态分析完全没扫出来。从那以后我每次做模块拆分都强制走一遍soot-test diff哪怕多花 2 分钟也比上线后半夜被告警电话叫醒强。希望帮到你。本文还有配套的精品资源点击获取