简介:SootTest 是一份面向 Java 静态分析与编译优化学习者的实战示例资源,基于开源分析框架 Soot,演示如何生成调用图与过程间控制流程图(ICFG)。它适合已掌握 Java 基础、希望深入字节码分析与程序结构理解的开发者,用于理解方法调用关系、跨方法数据流与控制流特性,为性能优化和缺陷检测打基础。压缩包共 23 个文件,约 12.27MB,包含 10 个 class 编译产物、3 个 java 源码、2 个 dot 图形输出、1 个依赖 jar 及 Eclipse 工程配置等,覆盖从源码到可视化结果的完整链路。项目以 SootTest-master 为目录主体,内含 testers 示例模块与 sootOutput 输出目录,可直观看到调用图与 ICFG 的生成效果。目前已有 1440 人学习下载,适合作为 Soot 入门的练手项目,帮助读者快速掌握场景构建、控制流图与调用图 API 的使用方式。1. 从一行命令到一张图:SootTest 到底在解决什么问题如果你写过 Java 静态分析,大概率经历过这样的场景:接手一个十几万行的老项目,想搞清楚某个接口到底调了哪些方法、异常从哪一层抛出来、哪些分支永远走不到。靠 IDE 的 Find Usages 一层层点,点到第三层就迷路了。SootTest 这个项目就是冲着这个痛点来的——它用 Soot 这个字节码分析框架,把编译后的 class 文件直接转成调用图(Call Graph)和过程间控制流图(ICFG),让你从「人肉追代码」变成「看图说话」。它适合两类人:一类是做代码审计、漏洞挖掘、死代码检测的,需要程序依赖关系做底图;另一类是在学编译原理或程序分析课程,想找一个能跑起来、能改参数的实战 Demo。项目本身不复杂,但麻雀虽小,Soot 的 Scene、Spark、Jimple 这些核心概念一个不少。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份源码拆开讲一遍。2. 环境搭建与首次运行:把 class 文件喂给 Soot2.1 依赖与目录结构SootTest 是一个标准的 Maven 工程,核心依赖就两个:soot 主包和 slf4j 日志门面。我一般会在pom.xml里锁定 Soot 的版本,因为不同版本 API 差异不小,尤其是Scene和Options的调用方式。<dependencies> <!-- Soot 核心,负责字节码解析与图构建 --> <dependency> <groupId>org.soot-oss</groupId> <artifactId>soot</artifactId> <version>4.4.1</version> </dependency> <!-- 日志门面,Soot 内部会打不少调试信息 --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>1.7.36</version> </dependency> </dependencies>目录结构很直白:src/main/java下放分析入口和工具类,src/main/resources放 Soot 的配置文件,target/classes是编译产物。如果你要分析的第三方 jar 不在 Maven 仓库里,常见做法是在项目根目录建一个libs文件夹,运行时通过-cp参数把 jar 路径传进去。2.2 配置 Soot 的入口参数Soot 的入口是soot.Main,但更灵活的方式是在代码里直接操作Options和Scene。下面这段是 SootTest 里最核心的初始化逻辑,我加了逐行注释:// 设置 Soot 的 classpath,告诉它去哪里找待分析的类 Options.v().set_soot_classpath( "target/classes:" + // 本项目编译产物 "/path/to/your/app.jar" // 待分析的目标 jar ); // 指定要分析的类,支持通配符,比如 com.example.* Options.v().set_process_dir( Collections.singletonList("/path/to/your/app.jar") ); // 开启全程序分析模式,这是生成调用图的前提 Options.v().set_whole_program(true); // 设置输出格式为 Jimple,方便后续遍历中间表示 Options.v().set_output_format(Options.output_format_jimple); // 允许 Phantom 引用,避免因为缺少依赖类而直接崩溃 Options.v().set_allow_phantom_refs(true); // 加载基础类,比如 java.lang.*,否则连 String 都识别不了 Scene.v().loadNecessaryClasses();这里有几个参数值得展开说。set_whole_program(true)是必须的,Soot 默认只做单类分析,不开全程序模式就拿不到跨方法的调用边。set_allow_phantom_refs(true)是个后悔药——当你的目标 jar 依赖了某个没传进来的库,Soot 会为缺失的类生成一个占位符而不是抛异常,代价是这部分调用关系会丢失,所以生产环境还是要把依赖补全。loadNecessaryClasses()这一步会触发类加载和 Jimple 体生成,耗时跟项目规模成正比,十万行级别的项目大概要等十几秒。2.3 生成调用图并导出初始化完成后,生成调用图只需要一行:// 使用 Spark 算法构建调用图,比 CHA 精度高 CallGraph callGraph = Scene.v().getCallGraph(); // 遍历所有调用边,打印调用者和被调用者 for (Edge<Unit, SootMethod> edge : callGraph) { SootMethod src = edge.src().getMethod(); // 调用方方法 SootMethod tgt = edge.tgt(); // 被调用方方法 System.out.println(src.getSignature() + " --> " + tgt.getSignature()); }Scene.v().getCallGraph()默认走的是 CHA(Class Hierarchy Analysis),精度较低但速度快。如果你在配置里显式设置了Options.v().setPhaseOption("cg.spark", "enabled:true"),Soot 会切换到 Spark 算法,能处理虚方法调用和接口分派,精度提升明显,代价是内存占用翻倍。我实测过一个 5 万方法左右的项目,CHA 跑完 8 秒,Spark 要 40 秒左右,内存从 1G 涨到 3G。所以如果你的机器内存紧张,或者只是做粗粒度筛选,CHA 够用了。导出格式上,SootTest 默认打印到控制台,但更实用的做法是输出成 DOT 格式,用 Graphviz 渲染成图片:// 将调用图导出为 DOT 文件 soot.util.dot.DotGraph dot = new soot.util.dot.DotGraph("callgraph"); for (Edge<Unit, SootMethod> edge : callGraph) { String src = edge.src().getMethod().getSignature(); String tgt = edge.tgt().getSignature(); dot.drawEdge(src, tgt); // 添加一条有向边 } dot.plot("output/callgraph.dot"); // 写入文件生成的.dot文件用dot -Tpng callgraph.dot -o callgraph.png就能转成图片。不过要注意,大项目的调用图节点动辄上万,直接渲染会糊成一团,常见做法是按包名或类名做过滤,只画你关心的子图。3. 核心模块拆解:从 Jimple 到 ICFG 的完整链路3.1 Jimple 中间表示:Soot 的通用语言Soot 不直接操作 Java 字节码,而是先把它转成 Jimple——一种三地址码形式的中间表示。每个方法体在 Jimple 里就是一条条Stmt组成的链,每条语句最多涉及一个操作。理解 Jimple 是改 SootTest 的前提,因为后面所有图分析都建立在它上面。// 获取某个方法的 Jimple 体 SootMethod method = Scene.v().getMethod("<com.example.Demo: void run()>"); Body body = method.retrieveActiveBody(); // 遍历方法体中的每一条语句 for (Unit unit : body.getUnits()) { Stmt stmt = (Stmt) unit; // 判断语句类型:赋值、调用、返回、分支等 if (stmt.containsInvokeExpr()) { // 提取方法调用表达式 InvokeExpr invoke = stmt.getInvokeExpr(); System.out.println("调用方法: " + invoke.getMethod().getSignature()); } }retrieveActiveBody()会触发 Jimple 体的构建,如果方法没有方法体(比如 native 方法),这里会返回 null,需要做判空。body.getUnits()返回的是一个链式结构,顺序就是字节码的执行顺序。containsInvokeExpr()用来筛选出方法调用语句,这是构建调用图的基础。实际改造时,你可能会想过滤掉 JDK 内部调用,只保留业务代码,那就在循环里加一个包名前缀判断。3.2 过程间控制流图:把调用边和控制边拼起来ICFG 的本质是把每个方法的 CFG 通过调用边连接起来。Soot 本身不直接提供 ICFG 的现成类,SootTest 里的做法是手动组合:先拿到每个方法的 CFG,再根据调用图把 caller 的调用点和 callee 的入口连起来。// 构建 ICFG 的核心逻辑 UnitGraph cfg = new BriefUnitGraph(body); // 单个方法的控制流图 // 遍历 CFG 中的每条边 for (Unit unit : cfg) { // 如果当前节点是方法调用 if (((Stmt) unit).containsInvokeExpr()) { SootMethod callee = ((Stmt) unit).getInvokeExpr().getMethod(); // 找到 callee 的入口节点,建立跨过程边 if (callee.hasActiveBody()) { Unit entry = callee.getActiveBody().getUnits().getFirst(); // 这里记录一条从 unit 到 entry 的边 icfgEdges.add(new Edge<>(unit, entry)); } } }BriefUnitGraph是最常用的 CFG 实现,它只保留必要的分支和跳转边,不包含异常边。如果你需要分析异常传播路径,得换成ExceptionalUnitGraph,它会额外生成 throw 到 catch 的边,图规模会大不少。callee.hasActiveBody()这个判断很关键——如果被调用方法没有方法体(比如接口方法或 native 方法),就没法连边,直接跳过。实际项目中,接口调用占很大比例,Spark 算法能根据类型推断把接口调用解析到具体实现类,这也是为什么做 ICFG 时建议开 Spark。3.3 调用图算法选型:CHA、Spark 与 VTA 的取舍Soot 内置了多种调用图构建算法,SootTest 默认用 CHA,但你可以通过参数切换。下面这张表是我在实际项目中总结的对比:算法精度速度内存适用场景CHA低快小快速筛选、粗粒度分析Spark高慢大漏洞挖掘、精确依赖分析VTA中中中平衡精度与性能切换方式是在Scene.v().loadNecessaryClasses()之前加一行:// 启用 Spark,关闭默认的 CHA Options.v().setPhaseOption("cg.spark", "enabled:true"); // 开启点对点分析,精度更高但更慢 Options.v().setPhaseOption("cg.spark", "on-fly-cg:true");on-fly-cg:true表示在构建调用图的同时做指向分析,能处理更复杂的虚方法分派,但耗时也会增加。如果你的目标项目里接口和继承层次很深,建议开这个选项;如果只是分析工具类之间的调用,CHA 足够了。4. 避坑与排查:Soot 分析中常见的五类翻车4.1 类找不到导致分析中断现象:运行时报Exception in thread "main" java.lang.RuntimeException: This operation requires resolving level SIGNATURES,或者大量Phantom class警告。原因:Soot 的 classpath 没配全,目标 jar 依赖的第三方库没传进去,导致类解析失败。解决:把目标项目的所有依赖 jar 都加到set_soot_classpath里。Maven 项目可以用mvn dependency:copy-dependencies把依赖拉到target/dependency,然后把这个目录拼进 classpath。如果实在凑不齐,至少开set_allow_phantom_refs(true)让分析能跑完,但要清楚丢失的那部分调用关系会影响结果。4.2 调用图边数爆炸现象:一个几千行的项目,调用图边数几十万,DOT 文件几百兆,Graphviz 直接卡死。原因:CHA 算法对虚方法调用做了过度近似,把所有可能的实现类都连了边,导致图规模膨胀。解决:三个方向。一是换 Spark 算法提高精度,减少虚假边;二是在遍历边的时候加过滤,只保留业务包名下的调用;三是用Scene.v().getReachableMethods()先做可达性裁剪,把不可达的方法排除掉再建图。4.3 内存溢出现象:分析大项目时抛OutOfMemoryError: Java heap space。原因:Soot 会把所有类的 Jimple 体加载到内存,加上 Spark 的指向分析数据结构,内存消耗远超预期。解决:启动时加-Xmx4g甚至-Xmx8g。如果还是不够,可以开Options.v().set_keep_line_number(false)和Options.v().set_keep_offset(false)减少元数据存储。另外,分析完一个模块后及时调Scene.v().releaseActiveBodies()释放方法体。4.4 接口调用解析不到实现类现象:调用图里只看到interface.method()的边,没有具体实现类的边。原因:CHA 不做指向分析,无法确定接口变量实际指向哪个对象。解决:启用 Spark 并开启on-fly-cg:true。如果还是解析不到,检查目标类是否在 classpath 里,以及接口的实现类是否被set_process_dir包含。4.5 输出格式乱码或缺失现象:导出的 DOT 文件里方法签名包含特殊字符,Graphviz 渲染报错。原因:Java 方法签名里的<、>、$等字符在 DOT 格式里有特殊含义。解决:在写入 DOT 之前对节点名做转义,把<替换成_,>替换成_,或者用双引号把整个签名包起来。SootTest 里我一般会写一个escapeDot()工具方法统一处理。5. 二次开发改造:从调用图到自定义分析规则5.1 扩展点一:按包名过滤子图原始项目输出全量调用图,实际用的时候往往只关心某几个包。改造思路是在遍历边的时候加一层过滤:// 只保留 com.example.service 包下的调用关系 String targetPackage = "com.example.service"; for (Edge<Unit, SootMethod> edge : callGraph) { String srcClass = edge.src().getMethod().getDeclaringClass().getName(); String tgtClass = edge.tgt().getDeclaringClass().getName(); if (srcClass.startsWith(targetPackage) || tgtClass.startsWith(targetPackage)) { dot.drawEdge(escapeDot(srcClass), escapeDot(tgtClass)); } }这样导出的图节点数能降一个数量级,渲染出来也看得清。如果你要做分层分析,可以按包名把图拆成多个子图分别导出。5.2 扩展点二:标记敏感方法调用做安全审计时,经常需要定位哪些方法调用了Runtime.exec、ProcessBuilder这类敏感 API。可以在遍历调用边时加一个敏感方法名单:// 敏感方法签名列表 Set<String> sensitiveMethods = new HashSet<>(Arrays.asList( "<java.lang.Runtime: java.lang.Process exec(java.lang.String)>", "<java.lang.ProcessBuilder: java.lang.Process start()>" )); for (Edge<Unit, SootMethod> edge : callGraph) { SootMethod tgt = edge.tgt(); if (sensitiveMethods.contains(tgt.getSignature())) { // 标记这条边为高危,用红色输出 dot.drawEdge(escapeDot(edge.src().getMethod().getSignature()), escapeDot(tgt.getSignature()), "color=red"); } }DotGraph.drawEdge支持传属性字符串,color=red能让 Graphviz 把这条边画成红色。实际项目中,敏感方法名单可以从配置文件读,方便随时增删。5.3 扩展点三:结合 ICFG 做数据流追踪调用图只告诉你「谁调了谁」,ICFG 才能告诉你「数据怎么流」。一个实用的改造是在 ICFG 上做简单的污点传播:从 source 方法出发,沿着控制流边和调用边往下走,看能不能到达 sink 方法。// 简化的污点传播:从 source 出发做 BFS Queue<Unit> worklist = new LinkedList<>(); Set<Unit> visited = new HashSet<>(); worklist.add(sourceUnit); while (!worklist.isEmpty()) { Unit current = worklist.poll(); if (visited.contains(current)) continue; visited.add(current); // 如果到达 sink 方法,记录一条污点路径 if (isSink(current)) { System.out.println("发现污点路径: " + current); } // 沿着 ICFG 边继续传播 for (Unit succ : icfg.getSuccsOf(current)) { worklist.add(succ); } }icfg.getSuccsOf(current)返回当前节点的所有后继,包括方法内的控制流后继和跨过程的调用后继。这个 BFS 框架很粗糙,没有做别名分析和字段敏感处理,但作为二次开发的起点足够了。你可以在此基础上加路径记录、加 sanitizer 判断、加字段访问处理,逐步逼近一个可用的污点分析器。5.4 验证改造效果的方法改完代码后怎么确认没跑偏?我的习惯是准备一个小型测试用例,包含三个类:A 调用 B,B 调用 C,C 里有一个敏感方法。跑完分析后检查三件事:调用图里 A→B→C 的边是否完整;ICFG 里跨过程边是否正确连接;敏感方法是否被正确标记。如果这三项都对,再拿真实项目跑一遍,对比改造前后的节点数和边数变化,确认过滤逻辑生效。从那以后我每次改 Soot 分析逻辑,都强制先跑这个小用例,确认基础链路没断再上大项目。希望帮到你。本文还有配套的精品资源,点击获取
简介:SootTest 是一份面向 Java 静态分析与编译优化学习者的实战示例资源,基于开源分析框架 Soot,演示如何生成调用图与过程间控制流程图(ICFG)。它适合已掌握 Java 基础、希望深入字节码分析与程序结构理解的开发者,用于理解方法调用关系、跨方法数据流与控制流特性,为性能优化和缺陷检测打基础。压缩包共 23 个文件,约 12.27MB,包含 10 个 class 编译产物、3 个 java 源码、2 个 dot 图形输出、1 个依赖 jar 及 Eclipse 工程配置等,覆盖从源码到可视化结果的完整链路。项目以 SootTest-master 为目录主体,内含 testers 示例模块与 sootOutput 输出目录,可直观看到调用图与 ICFG 的生成效果。目前已有 1440 人学习下载,适合作为 Soot 入门的练手项目,帮助读者快速掌握场景构建、控制流图与调用图 API 的使用方式。1. 从一行命令到一张图:SootTest 到底在解决什么问题如果你写过 Java 静态分析,大概率经历过这样的场景:接手一个十几万行的老项目,想搞清楚某个接口到底调了哪些方法、异常从哪一层抛出来、哪些分支永远走不到。靠 IDE 的 Find Usages 一层层点,点到第三层就迷路了。SootTest 这个项目就是冲着这个痛点来的——它用 Soot 这个字节码分析框架,把编译后的 class 文件直接转成调用图(Call Graph)和过程间控制流图(ICFG),让你从「人肉追代码」变成「看图说话」。它适合两类人:一类是做代码审计、漏洞挖掘、死代码检测的,需要程序依赖关系做底图;另一类是在学编译原理或程序分析课程,想找一个能跑起来、能改参数的实战 Demo。项目本身不复杂,但麻雀虽小,Soot 的 Scene、Spark、Jimple 这些核心概念一个不少。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份源码拆开讲一遍。2. 环境搭建与首次运行:把 class 文件喂给 Soot2.1 依赖与目录结构SootTest 是一个标准的 Maven 工程,核心依赖就两个:soot 主包和 slf4j 日志门面。我一般会在pom.xml里锁定 Soot 的版本,因为不同版本 API 差异不小,尤其是Scene和Options的调用方式。<dependencies> <!-- Soot 核心,负责字节码解析与图构建 --> <dependency> <groupId>org.soot-oss</groupId> <artifactId>soot</artifactId> <version>4.4.1</version> </dependency> <!-- 日志门面,Soot 内部会打不少调试信息 --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>1.7.36</version> </dependency> </dependencies>目录结构很直白:src/main/java下放分析入口和工具类,src/main/resources放 Soot 的配置文件,target/classes是编译产物。如果你要分析的第三方 jar 不在 Maven 仓库里,常见做法是在项目根目录建一个libs文件夹,运行时通过-cp参数把 jar 路径传进去。2.2 配置 Soot 的入口参数Soot 的入口是soot.Main,但更灵活的方式是在代码里直接操作Options和Scene。下面这段是 SootTest 里最核心的初始化逻辑,我加了逐行注释:// 设置 Soot 的 classpath,告诉它去哪里找待分析的类 Options.v().set_soot_classpath( "target/classes:" + // 本项目编译产物 "/path/to/your/app.jar" // 待分析的目标 jar ); // 指定要分析的类,支持通配符,比如 com.example.* Options.v().set_process_dir( Collections.singletonList("/path/to/your/app.jar") ); // 开启全程序分析模式,这是生成调用图的前提 Options.v().set_whole_program(true); // 设置输出格式为 Jimple,方便后续遍历中间表示 Options.v().set_output_format(Options.output_format_jimple); // 允许 Phantom 引用,避免因为缺少依赖类而直接崩溃 Options.v().set_allow_phantom_refs(true); // 加载基础类,比如 java.lang.*,否则连 String 都识别不了 Scene.v().loadNecessaryClasses();这里有几个参数值得展开说。set_whole_program(true)是必须的,Soot 默认只做单类分析,不开全程序模式就拿不到跨方法的调用边。set_allow_phantom_refs(true)是个后悔药——当你的目标 jar 依赖了某个没传进来的库,Soot 会为缺失的类生成一个占位符而不是抛异常,代价是这部分调用关系会丢失,所以生产环境还是要把依赖补全。loadNecessaryClasses()这一步会触发类加载和 Jimple 体生成,耗时跟项目规模成正比,十万行级别的项目大概要等十几秒。2.3 生成调用图并导出初始化完成后,生成调用图只需要一行:// 使用 Spark 算法构建调用图,比 CHA 精度高 CallGraph callGraph = Scene.v().getCallGraph(); // 遍历所有调用边,打印调用者和被调用者 for (Edge<Unit, SootMethod> edge : callGraph) { SootMethod src = edge.src().getMethod(); // 调用方方法 SootMethod tgt = edge.tgt(); // 被调用方方法 System.out.println(src.getSignature() + " --> " + tgt.getSignature()); }Scene.v().getCallGraph()默认走的是 CHA(Class Hierarchy Analysis),精度较低但速度快。如果你在配置里显式设置了Options.v().setPhaseOption("cg.spark", "enabled:true"),Soot 会切换到 Spark 算法,能处理虚方法调用和接口分派,精度提升明显,代价是内存占用翻倍。我实测过一个 5 万方法左右的项目,CHA 跑完 8 秒,Spark 要 40 秒左右,内存从 1G 涨到 3G。所以如果你的机器内存紧张,或者只是做粗粒度筛选,CHA 够用了。导出格式上,SootTest 默认打印到控制台,但更实用的做法是输出成 DOT 格式,用 Graphviz 渲染成图片:// 将调用图导出为 DOT 文件 soot.util.dot.DotGraph dot = new soot.util.dot.DotGraph("callgraph"); for (Edge<Unit, SootMethod> edge : callGraph) { String src = edge.src().getMethod().getSignature(); String tgt = edge.tgt().getSignature(); dot.drawEdge(src, tgt); // 添加一条有向边 } dot.plot("output/callgraph.dot"); // 写入文件生成的.dot文件用dot -Tpng callgraph.dot -o callgraph.png就能转成图片。不过要注意,大项目的调用图节点动辄上万,直接渲染会糊成一团,常见做法是按包名或类名做过滤,只画你关心的子图。3. 核心模块拆解:从 Jimple 到 ICFG 的完整链路3.1 Jimple 中间表示:Soot 的通用语言Soot 不直接操作 Java 字节码,而是先把它转成 Jimple——一种三地址码形式的中间表示。每个方法体在 Jimple 里就是一条条Stmt组成的链,每条语句最多涉及一个操作。理解 Jimple 是改 SootTest 的前提,因为后面所有图分析都建立在它上面。// 获取某个方法的 Jimple 体 SootMethod method = Scene.v().getMethod("<com.example.Demo: void run()>"); Body body = method.retrieveActiveBody(); // 遍历方法体中的每一条语句 for (Unit unit : body.getUnits()) { Stmt stmt = (Stmt) unit; // 判断语句类型:赋值、调用、返回、分支等 if (stmt.containsInvokeExpr()) { // 提取方法调用表达式 InvokeExpr invoke = stmt.getInvokeExpr(); System.out.println("调用方法: " + invoke.getMethod().getSignature()); } }retrieveActiveBody()会触发 Jimple 体的构建,如果方法没有方法体(比如 native 方法),这里会返回 null,需要做判空。body.getUnits()返回的是一个链式结构,顺序就是字节码的执行顺序。containsInvokeExpr()用来筛选出方法调用语句,这是构建调用图的基础。实际改造时,你可能会想过滤掉 JDK 内部调用,只保留业务代码,那就在循环里加一个包名前缀判断。3.2 过程间控制流图:把调用边和控制边拼起来ICFG 的本质是把每个方法的 CFG 通过调用边连接起来。Soot 本身不直接提供 ICFG 的现成类,SootTest 里的做法是手动组合:先拿到每个方法的 CFG,再根据调用图把 caller 的调用点和 callee 的入口连起来。// 构建 ICFG 的核心逻辑 UnitGraph cfg = new BriefUnitGraph(body); // 单个方法的控制流图 // 遍历 CFG 中的每条边 for (Unit unit : cfg) { // 如果当前节点是方法调用 if (((Stmt) unit).containsInvokeExpr()) { SootMethod callee = ((Stmt) unit).getInvokeExpr().getMethod(); // 找到 callee 的入口节点,建立跨过程边 if (callee.hasActiveBody()) { Unit entry = callee.getActiveBody().getUnits().getFirst(); // 这里记录一条从 unit 到 entry 的边 icfgEdges.add(new Edge<>(unit, entry)); } } }BriefUnitGraph是最常用的 CFG 实现,它只保留必要的分支和跳转边,不包含异常边。如果你需要分析异常传播路径,得换成ExceptionalUnitGraph,它会额外生成 throw 到 catch 的边,图规模会大不少。callee.hasActiveBody()这个判断很关键——如果被调用方法没有方法体(比如接口方法或 native 方法),就没法连边,直接跳过。实际项目中,接口调用占很大比例,Spark 算法能根据类型推断把接口调用解析到具体实现类,这也是为什么做 ICFG 时建议开 Spark。3.3 调用图算法选型:CHA、Spark 与 VTA 的取舍Soot 内置了多种调用图构建算法,SootTest 默认用 CHA,但你可以通过参数切换。下面这张表是我在实际项目中总结的对比:算法精度速度内存适用场景CHA低快小快速筛选、粗粒度分析Spark高慢大漏洞挖掘、精确依赖分析VTA中中中平衡精度与性能切换方式是在Scene.v().loadNecessaryClasses()之前加一行:// 启用 Spark,关闭默认的 CHA Options.v().setPhaseOption("cg.spark", "enabled:true"); // 开启点对点分析,精度更高但更慢 Options.v().setPhaseOption("cg.spark", "on-fly-cg:true");on-fly-cg:true表示在构建调用图的同时做指向分析,能处理更复杂的虚方法分派,但耗时也会增加。如果你的目标项目里接口和继承层次很深,建议开这个选项;如果只是分析工具类之间的调用,CHA 足够了。4. 避坑与排查:Soot 分析中常见的五类翻车4.1 类找不到导致分析中断现象:运行时报Exception in thread "main" java.lang.RuntimeException: This operation requires resolving level SIGNATURES,或者大量Phantom class警告。原因:Soot 的 classpath 没配全,目标 jar 依赖的第三方库没传进去,导致类解析失败。解决:把目标项目的所有依赖 jar 都加到set_soot_classpath里。Maven 项目可以用mvn dependency:copy-dependencies把依赖拉到target/dependency,然后把这个目录拼进 classpath。如果实在凑不齐,至少开set_allow_phantom_refs(true)让分析能跑完,但要清楚丢失的那部分调用关系会影响结果。4.2 调用图边数爆炸现象:一个几千行的项目,调用图边数几十万,DOT 文件几百兆,Graphviz 直接卡死。原因:CHA 算法对虚方法调用做了过度近似,把所有可能的实现类都连了边,导致图规模膨胀。解决:三个方向。一是换 Spark 算法提高精度,减少虚假边;二是在遍历边的时候加过滤,只保留业务包名下的调用;三是用Scene.v().getReachableMethods()先做可达性裁剪,把不可达的方法排除掉再建图。4.3 内存溢出现象:分析大项目时抛OutOfMemoryError: Java heap space。原因:Soot 会把所有类的 Jimple 体加载到内存,加上 Spark 的指向分析数据结构,内存消耗远超预期。解决:启动时加-Xmx4g甚至-Xmx8g。如果还是不够,可以开Options.v().set_keep_line_number(false)和Options.v().set_keep_offset(false)减少元数据存储。另外,分析完一个模块后及时调Scene.v().releaseActiveBodies()释放方法体。4.4 接口调用解析不到实现类现象:调用图里只看到interface.method()的边,没有具体实现类的边。原因:CHA 不做指向分析,无法确定接口变量实际指向哪个对象。解决:启用 Spark 并开启on-fly-cg:true。如果还是解析不到,检查目标类是否在 classpath 里,以及接口的实现类是否被set_process_dir包含。4.5 输出格式乱码或缺失现象:导出的 DOT 文件里方法签名包含特殊字符,Graphviz 渲染报错。原因:Java 方法签名里的<、>、$等字符在 DOT 格式里有特殊含义。解决:在写入 DOT 之前对节点名做转义,把<替换成_,>替换成_,或者用双引号把整个签名包起来。SootTest 里我一般会写一个escapeDot()工具方法统一处理。5. 二次开发改造:从调用图到自定义分析规则5.1 扩展点一:按包名过滤子图原始项目输出全量调用图,实际用的时候往往只关心某几个包。改造思路是在遍历边的时候加一层过滤:// 只保留 com.example.service 包下的调用关系 String targetPackage = "com.example.service"; for (Edge<Unit, SootMethod> edge : callGraph) { String srcClass = edge.src().getMethod().getDeclaringClass().getName(); String tgtClass = edge.tgt().getDeclaringClass().getName(); if (srcClass.startsWith(targetPackage) || tgtClass.startsWith(targetPackage)) { dot.drawEdge(escapeDot(srcClass), escapeDot(tgtClass)); } }这样导出的图节点数能降一个数量级,渲染出来也看得清。如果你要做分层分析,可以按包名把图拆成多个子图分别导出。5.2 扩展点二:标记敏感方法调用做安全审计时,经常需要定位哪些方法调用了Runtime.exec、ProcessBuilder这类敏感 API。可以在遍历调用边时加一个敏感方法名单:// 敏感方法签名列表 Set<String> sensitiveMethods = new HashSet<>(Arrays.asList( "<java.lang.Runtime: java.lang.Process exec(java.lang.String)>", "<java.lang.ProcessBuilder: java.lang.Process start()>" )); for (Edge<Unit, SootMethod> edge : callGraph) { SootMethod tgt = edge.tgt(); if (sensitiveMethods.contains(tgt.getSignature())) { // 标记这条边为高危,用红色输出 dot.drawEdge(escapeDot(edge.src().getMethod().getSignature()), escapeDot(tgt.getSignature()), "color=red"); } }DotGraph.drawEdge支持传属性字符串,color=red能让 Graphviz 把这条边画成红色。实际项目中,敏感方法名单可以从配置文件读,方便随时增删。5.3 扩展点三:结合 ICFG 做数据流追踪调用图只告诉你「谁调了谁」,ICFG 才能告诉你「数据怎么流」。一个实用的改造是在 ICFG 上做简单的污点传播:从 source 方法出发,沿着控制流边和调用边往下走,看能不能到达 sink 方法。// 简化的污点传播:从 source 出发做 BFS Queue<Unit> worklist = new LinkedList<>(); Set<Unit> visited = new HashSet<>(); worklist.add(sourceUnit); while (!worklist.isEmpty()) { Unit current = worklist.poll(); if (visited.contains(current)) continue; visited.add(current); // 如果到达 sink 方法,记录一条污点路径 if (isSink(current)) { System.out.println("发现污点路径: " + current); } // 沿着 ICFG 边继续传播 for (Unit succ : icfg.getSuccsOf(current)) { worklist.add(succ); } }icfg.getSuccsOf(current)返回当前节点的所有后继,包括方法内的控制流后继和跨过程的调用后继。这个 BFS 框架很粗糙,没有做别名分析和字段敏感处理,但作为二次开发的起点足够了。你可以在此基础上加路径记录、加 sanitizer 判断、加字段访问处理,逐步逼近一个可用的污点分析器。5.4 验证改造效果的方法改完代码后怎么确认没跑偏?我的习惯是准备一个小型测试用例,包含三个类:A 调用 B,B 调用 C,C 里有一个敏感方法。跑完分析后检查三件事:调用图里 A→B→C 的边是否完整;ICFG 里跨过程边是否正确连接;敏感方法是否被正确标记。如果这三项都对,再拿真实项目跑一遍,对比改造前后的节点数和边数变化,确认过滤逻辑生效。从那以后我每次改 Soot 分析逻辑,都强制先跑这个小用例,确认基础链路没断再上大项目。希望帮到你。本文还有配套的精品资源,点击获取