尧图精选

SootTest实战:从字节码生成调用图与ICFG的完整指南

🕒 发布时间:2026/10/1 9:30:48 📁 来源:尧图网络
简介SootTest 是一份面向 Java 静态分析与编译优化学习者的实战示例资源基于开源分析框架 Soot演示如何生成调用图与过程间控制流程图ICFG。它适合已掌握 Java 基础、希望深入字节码分析与程序结构理解的开发者用于理解方法间调用关系、跨方法数据流与控制流特性为性能优化和缺陷检测打基础。压缩包共 23 个文件约 12.27MB包含 10 个 class、3 个 java 源码、2 个 dot 图形文件、1 个依赖 jar 及 Eclipse 工程配置等覆盖从源码、中间表示到图形输出的完整链路。其中 dot 文件可直接查看调用图与 ICFG 的生成结果java 源码展示 Soot API 的调用方式jar 包省去环境搭建成本。目前已有 1439 人学习下载读者可借助示例快速跑通 Soot 分析流程掌握场景构建、控制流图与调用图生成的关键思路并对照输出结果加深对 Jimple 中间表示的理解。1. SootTest 到底能干什么从字节码到调用图与 ICFG 的一条龙如果你曾经接手过一个没有文档、没有测试、连入口都靠猜的 Java 老系统就会明白“把代码结构画出来”这件事有多值钱。SootTest 这个资源包解决的正是这个痛点它基于 Soot 这个老牌 Java 字节码分析框架把编译后的 class 或 jar 直接解析成可遍历的中间表示再生成两类图——调用图Call Graph和过程间控制流程图Interprocedural Control Flow GraphICFG。前者回答“谁调用了谁”后者回答“一次调用从进入到返回控制流跨方法怎么走”。它适合三类人做静态分析、漏洞扫描、代码审计的工程师研究程序切片、污点传播、死代码检测的学生和研究员以及需要给遗留系统补一份结构视图的维护者。你不需要源码只要有字节码就能跑这一点对闭源依赖和第三方库尤其友好。下面我按“能跑起来 → 看懂输出 → 避开坑 → 进阶用法”的顺序拆一遍中间的命令和参数都可以直接抄。2. 环境搭建与 Soot 核心 API把字节码喂进去2.1 依赖选型为什么是 Soot 而不是 ASM、Javassist做 Java 字节码分析常见方案有三个ASM 偏底层写 visitor 灵活但调用图、控制流都得自己造Javassist 偏运行时改写做静态全量分析不顺手Soot 自带 Jimple 中间表示、调用图构造器和 CFG 生成属于“开箱即用”的那一档。SootTest 选它本质是省掉了从字节码到图结构的重复造轮子。Soot 的工作流是加载 class/jar → 转成 Jimple一种三地址码的中间表示→ 在 Jimple 上做调用图构造和 CFG 分析。Jimple 是关键它把栈式字节码拍平成带局部变量的语句序列读起来接近伪代码遍历起来也稳定。常见做法是用 Maven 引入 soot 主包和 soot-infoflow 这类配套库版本上 4.x 系列对 Java 8 到 17 的字节码支持比较完整。dependency groupIdorg.soot-oss/groupId artifactIdsoot/artifactId version4.4.1/version /dependency参数说明soot-oss是社区维护的坐标4.4.1 是写这篇时较稳的版本别盲目追最新快照。如果你的目标字节码是 Java 17 编译的低于 4.3 的版本可能在解析 record、sealed 类时翻车。2.2 初始化 SootOptions 里几个必须设对的参数Soot 的入口是soot.Main或soot.Scene但真正决定成败的是Options。下面这段是加载目标 jar 并转 Jimple 的最小可用配置。import soot.*; import soot.options.Options; import java.util.Collections; public class SootInit { public static void main(String[] args) { // 清空默认加载路径避免混入 JDK 自带类干扰分析 G.reset(); Options opts Options.v(); // 设置待分析的字节码路径可以是目录或 jar opts.set_process_dir(Collections.singletonList(target/classes)); // 输出格式设为 Jimple便于后续遍历 opts.set_output_format(Options.output_format_jimple); // 允许分析整个程序这是构造调用图的前提 opts.set_whole_program(true); // 保留行号信息报错和定位时能对回源码 opts.set_keep_line_number(true); // 指定 JDK 基础库路径否则 java.lang.* 解析不出来 opts.set_prepend_classpath(true); Scene.v().loadNecessaryClasses(); } }逻辑说明G.reset()清掉上一次运行的全局状态Soot 的Scene是单例不重置会串数据。set_whole_program(true)打开全程序模式调用图构造依赖它做跨方法解析。set_prepend_classpath(true)让 Soot 自动带上当前 JVM 的 rt.jar 或模块路径省得手动指定 JDK 库。参数上process_dir支持多个路径分析多模块项目时把每个模块的输出目录都塞进去。提示如果目标代码用了反射、动态代理或 SPISoot 的静态调用图会漏边这不是配置问题是静态分析的固有边界后面避坑章节会细说。3. 生成调用图从 Scene 到 CallGraph 的完整链路3.1 调用图构造器怎么选CHA、RTA、SPARK 的取舍Soot 内置多种调用图算法选错会让结果要么漏要么炸。常见的有三类算法精度速度适用场景CHAClass Hierarchy Analysis低快快速摸底只看类层次RTARapid Type Analysis中中平衡精度与开销日常首选SPARK高慢需要精确指向分析做污点传播CHA 只按声明类型连边接口多实现时会连出一堆假边RTA 会看实际实例化的类型砍掉不可能的分支SPARK 做上下文敏感的指向分析精度最高但内存吃紧。我一般先用 RTA 跑一遍看规模确认没问题再上 SPARK 做精细分析。3.2 代码实操构造并遍历调用图import soot.*; import soot.jimple.toolkits.callgraph.*; public class CallGraphDemo { public static void buildAndDump() { // 指定入口类调用图从这些类的 main 或指定方法开始扩展 Scene.v().setEntryPoints( EntryPoints.v().all() ); // 使用 SPARK 构造器开启指向分析 CHATransformer.v().transform(); // 先跑 CHA 打底 SparkTransformer.v().transform(); // 再跑 SPARK 精化 CallGraph cg Scene.v().getCallGraph(); // 遍历所有边打印调用者 - 被调用者 for (IteratorEdge it cg.iterator(); it.hasNext(); ) { Edge e it.next(); SootMethod src e.src(); SootMethod tgt e.tgt(); System.out.println(src.getSignature() -- tgt.getSignature()); } } }逻辑说明EntryPoints.v().all()把所有 public static void main 当作入口也可以手动指定某个方法。先 CHA 再 SPARK 是常见组合CHA 给 SPARK 一个初始边集能加速收敛。Edge对象带src()和tgt()分别是被调用点和目标方法getSignature()输出完整方法签名方便去重和比对。参数说明SPARK 的精度受set_whole_program和set_omit_excepting_unit_edges影响后者设为 true 会忽略异常处理边做正常调用分析时可以开做异常流分析时必须关。遍历大项目时边数可能到几十万建议边遍历边写文件别全堆内存里。3.3 输出格式DOT 可视化与文本清单拿到边集后落地成两种格式最实用DOT 给 Graphviz 画图文本清单给脚本做后续处理。import java.io.*; public class CgWriter { public static void writeDot(CallGraph cg, String path) throws IOException { try (PrintWriter pw new PrintWriter(new FileWriter(path))) { pw.println(digraph callgraph {); for (IteratorEdge it cg.iterator(); it.hasNext(); ) { Edge e it.next(); // 用方法签名做节点 ID去掉特殊字符避免 DOT 解析失败 String s e.src().getSignature().replace(\, ); String t e.tgt().getSignature().replace(\, ); pw.printf( \%s\ - \%s\;%n, s, t); } pw.println(}); } } }逻辑说明DOT 语法里节点名带引号可以容纳空格和括号但双引号本身要转义所以先 replace 掉。生成后用dot -Tpng callgraph.dot -o callgraph.png渲染。大图渲染会糊成一团建议先按包名过滤只画关心的子图。注意调用图边数不等于方法数一个方法被多处调用就有多条入边统计“被调用次数”时要去重后再算。4. 生成过程间控制流程图把跨方法的控制流串起来4.1 ICFG 与普通 CFG 的区别为什么要跨方法普通 CFG 只描述单个方法内部的基本块跳转遇到方法调用就断了。ICFG 把调用点展开从调用者的调用语句连到被调用者的入口再从被调用者的返回点连回调用者的下一条语句。这样一条从入口到出口的完整路径能跨越任意多层方法调用是做过程间数据流分析的基础。Soot 本身提供BriefUnitGraph、ExceptionalUnitGraph等单方法 CFGICFG 需要自己拼。SootTest 的价值就在于把这层拼接逻辑封装好了不用从零写。4.2 代码实操基于 UnitGraph 拼 ICFGimport soot.*; import soot.toolkits.graph.*; import soot.jimple.*; import java.util.*; public class IcfgBuilder { // 递归展开调用点构建跨方法边 public static void expand(SootMethod method, SetString visited) { if (!visited.add(method.getSignature())) return; // 防递归死循环 Body body method.retrieveActiveBody(); UnitGraph cfg new BriefUnitGraph(body); for (Unit u : body.getUnits()) { // 找到调用语句 if (u instanceof InvokeStmt || u instanceof AssignStmt) { for (ValueBox vb : u.getUseAndDefBoxes()) { if (vb.getValue() instanceof InvokeExpr) { SootMethod callee ((InvokeExpr) vb.getValue()).getMethod(); System.out.println(ICFG edge: method.getSignature() - callee.getSignature()); // 递归进入被调用方法 if (callee.isConcrete()) { expand(callee, visited); } } } } } } }逻辑说明retrieveActiveBody()拿到方法的 Jimple 体BriefUnitGraph给出方法内 CFG。遍历每个 Unit识别InvokeExpr就是调用点连一条跨方法边后递归进入被调用方法。visited集合防止递归调用导致无限展开这是必须的否则遇到自递归方法直接栈溢出。参数说明BriefUnitGraph不含异常边做正常控制流够用要覆盖 try-catch 得换ExceptionalUnitGraph。isConcrete()过滤掉抽象方法和接口方法它们没有方法体展开不了。4.3 路径枚举与剪枝别让路径爆炸ICFG 建好后枚举从入口到出口的路径是常见需求但路径数随分支指数增长。实操中必须剪枝只保留包含特定方法调用或特定变量的路径。public class PathFinder { // 只保留经过 targetMethod 的路径 public static void findPaths(Unit start, Unit end, SetString targetMethods, ListUnit current, ListListUnit result) { current.add(start); if (start.equals(end)) { // 检查当前路径是否命中目标方法 boolean hit current.stream().anyMatch(u - u.toString().contains(targetMethods.iterator().next())); if (hit) result.add(new ArrayList(current)); } else { for (Unit succ : getSuccs(start)) { if (!current.contains(succ)) { // 防环 findPaths(succ, end, targetMethods, current, result); } } } current.remove(current.size() - 1); } }逻辑说明这是带环检测的 DFScurrent.contains(succ)防止在环里打转。命中判断用字符串包含是简化写法生产环境应该比对方法签名。result收集所有命中路径路径数多时建议加深度上限。提示路径枚举是 ICFG 最耗时的环节先在小子图上验证逻辑再放到全量代码上跑不然容易等到怀疑人生。5. 避坑与排查Soot 分析里最容易翻车的五件事5.1 现象调用图里 java.lang.* 的边一大堆自己的方法却没几条原因set_prepend_classpath(true)把整个 JDK 库都加载进来了调用图把 JDK 内部调用也算进去淹没了业务代码。解决用set_exclude排除java.*、javax.*、sun.*包或者构造完调用图后按包名过滤边。opts.set_exclude(Collections.singletonList(java.*,javax.*,sun.*));5.2 现象SPARK 跑到一半 OOM堆内存越吃越大原因SPARK 做指向分析时对每个变量维护指向集大项目里指向集膨胀极快。解决调大 JVM 堆-Xmx8g起步或者先用 RTA 跑只对关心的子图用 SPARK。另一个办法是开set_omit_excepting_unit_edges(true)砍掉异常边能省不少内存。5.3 现象反射、动态代理、SPI 加载的调用在图里完全看不到原因这些调用在字节码里是Class.forName、Method.invoke这类间接调用静态分析无法确定目标方法。解决这是静态分析的固有边界没有银弹。常见做法是维护一份反射配置文件手动补边或者结合运行时 trace 做混合分析。别指望纯静态能覆盖全。5.4 现象ICFG 展开时栈溢出报 StackOverflowError原因递归展开调用点遇到深层调用链或递归方法直接爆栈。解决把递归改成显式栈的迭代写法或者给递归深度设上限超过就停止展开并记录。visited集合只能防重复防不了深度。5.5 现象Jimple 里出现大量临时变量看不懂对应哪行源码原因Soot 转 Jimple 时会引入$stack系列临时变量表示操作数栈。解决开set_keep_line_number(true)保留行号遍历时用unit.getJavaSourceStartLineNumber()对回源码行。临时变量本身不用管关注真实局部变量和字段就行。6. 进阶用法把 ICFG 接到污点分析上调用图和 ICFG 单独看只是结构图真正值钱的是拿它们做数据流分析。一个具体技巧是在 ICFG 上做前向污点传播把 source 方法比如getParameter标记为污染源沿 ICFG 边传播到 sink 方法比如executeQuery命中的路径就是潜在注入点。实现上把第 4 章的expand改成携带污点状态的遍历每个 Unit 维护一个污染变量集合遇到赋值语句就传播遇到调用语句就跨方法传递参数污染状态。下面是一个简化骨架。public class TaintPropagation { // 沿 ICFG 传播污点返回命中 sink 的路径 public static boolean propagate(SootMethod m, SetLocal tainted, SetString sinks, SetString visited) { if (!visited.add(m.getSignature())) return false; Body body m.retrieveActiveBody(); for (Unit u : body.getUnits()) { // 赋值语句右值污染则左值污染 if (u instanceof AssignStmt) { AssignStmt as (AssignStmt) u; if (as.getRightOp() instanceof Local tainted.contains(as.getRightOp())) { tainted.add((Local) as.getLeftOp()); } } // 调用语句命中 sink 则报告 if (u instanceof InvokeStmt) { InvokeExpr ie ((InvokeStmt) u).getInvokeExpr(); if (sinks.contains(ie.getMethod().getSignature())) { System.out.println(Taint hit: u); return true; } // 参数污染传递给被调用方法 for (Value arg : ie.getArgs()) { if (arg instanceof Local tainted.contains(arg)) { propagate(ie.getMethod(), tainted, sinks, visited); } } } } return false; } }逻辑说明tainted集合在方法内传播跨方法时把污染参数带过去。visited防重复分析同一方法。命中 sink 就打印并返回实际用的时候应该收集完整路径而不是提前返回。参数说明source 和 sink 的签名列表建议从配置文件读别硬编码。污点传播的精度取决于 ICFG 的精度用 SPARK 构造的调用图做底座误报会明显少于 CHA。验证方法拿一个已知有 SQL 注入的测试项目跑一遍看能不能命中再拿一个干净项目跑看误报率。两个都过了这套流程才算能用。从那以后我每次接静态分析任务都强制先用 RTA 跑一遍调用图看规模确认边数和内存可控再决定要不要上 SPARK 和 ICFG 展开。这个习惯帮我省过好几次通宵调内存的夜晚。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →