☰
Soot静态分析实战:从调用图到过程间控制流图
2026/10/10 17:17:53 网站建设 项目流程

简介:SootTest项目是一份面向Java开发者与静态分析初学者的Soot实践示例,演示如何借助Soot框架生成调用图(Call Graph)与过程间控制流程图(ICFG)。工程包含Java源码、可运行的class文件、依赖jar包以及生成的dot与png图形输出,共23个文件,压缩包约12.27MB,整体结构紧凑,适合对照源码理解Soot配置、Scene构建、UnitGraph与CallGraph API的调用流程。项目还展示如何从字节码或Jimple中间表示出发,构建方法内控制流,再扩展至跨方法调用关系分析,为性能优化、缺陷检测等工作打下基础。从内容预览看,src、testers与example等目录划分清晰,main-call-graph.dot和output.png可直接观察调用图生成效果。当前已有1439人学习下载,可作为入门Soot静态分析、开展Java程序结构可视化研究的实用参考。

1. SootTest:为什么静态分析第一课要落在调用图和过程间控制流图上

做 Java 或 Android 的静态分析,迟早会撞上“只看单个方法根本说不清问题”的墙。一个漏洞跨三个类、一条污点路径穿过五次调用,单看方法内控制流图(CFG)什么都看不出来。SootTest 这个方向做的事情,就是借助 Soot 这个老牌静态分析框架,把整个程序的调用关系织成一张调用图(CG),再在每个方法内部画好控制流图之后,把跨方法的路径串成过程间控制流程图(ICFG)。有了这两张图,别名分析、污点传播、死代码检测、回归影响分析才真正有下手的地方。

这个方案适合谁?适合已经在写 Java/Android 业务代码、想进入静态分析或白盒安全测试的开发者。不需要你是编译器科班出身,但至少得对字节码、类加载这些词不陌生。Soot 把字节码转成 Jimple 中间表示之后,处理单位从“源码行”变成了“三地址指令”,整个分析逻辑会清晰很多。本文会从 Soot 的基本入口讲起,用最小代码生成调用图,再一步步扩展到带过程间控制流的 Whole Program 分析,最后把最常见的一批坑摆出来。你按着步骤走,基本能在本地跑通一个可用的 SootTest 雏形。

2. 前置准备:搭一个能跑的 Soot 分析工程,理解 Jimple 与 Scene

2.1 为什么选 Soot 而不是自己写字节码解析器

很多人一开始会想:直接用 ASM 或 BCEL 读字节码,自己维护调用图不是更轻吗?确实,如果只分析单个 class 文件,ASM 更轻快。但一旦进入过程间分析,你要面对的是类层次、虚方法分派、异常边、字符串常量拼接的调用目标,这些逻辑散落在字节码各个角落,自己实现很容易漏。Soot 的价值在于它已经把类加载、Jimple 转换、调用图算法(CHA、RTA、SPARK)都做好了,你要做的是配置策略、遍历图结构、跑自己的分析逻辑。

Soot 的中间表示 Jimple 是一个三地址码,每个指令最多三个操作数,类型显式写在变量上。相比字节码,Jimple 最大的好处是直观:invoke 指令直接能看到 receiver 和参数,if/switch 的跳转目标也一目了然。基于 Jimple 写分析,基本等于在源码语法树和字节码之间取了一个平衡点,调试时不用对着操作码栈猜来猜去。SootTest 这个工程名称里的 Test,其实也说明它是为测试、验证、教学这类场景准备的,不是生产级超大项目的分析器,但它覆盖了最常见的需求。

2.2 用 Gradle 搭最小工程:依赖、目录、入口类

我用 Gradle 来搭工程,因为 Soot 的依赖树比较长,Maven 也能用,但 Gradle 对多版本 JDK 的切换更省心。Soot 目前推荐用 4.x 系列,记得把 Java 工具链固定在 8 或 11。以下是最小 build.gradle 配置:

plugins { id 'java' } group 'com.example' version '1.0-SNAPSHOT' repositories { mavenCentral() } dependencies { implementation 'org.soot-oss:soot:4.4.0' implementation 'commons-io:commons-io:2.11.0' } tasks.withType(JavaCompile) { options.encoding = 'UTF-8' }

逻辑说明:soot 4.4.0 是 mavenCentral 上比较稳定的版本,包含完整的调用图生成与 points-to 分析包。commons-io 不是必须的,但我经常用它做临时文件清理,Soot 输出文件较多时能省不少事。Java 版本建议不要用 17 以上,Soot 在某些版本上对强模块化支持不完整,会报模块访问错误。

目录结构按标准 Gradle 布局:

src/main/java/com/example/soottest/CallGraphGenerator.java src/main/java/com/example/soottest/ICFGGenerator.java src/test/resources/testdata/ # 放被测的 jar 或 class 目录

入口类里先做全局配置,Soot 的所有分析都建立在全局状态上,也就是 Scene 对象。下面是最小可运行的配置代码:

import soot.*; import soot.options.Options; public class CallGraphGenerator { public static void main(String[] args) { String classPath = "src/test/resources/testdata"; Options.v().set_soot_classpath(classPath); Options.v().set_process_dir(Arrays.asList(classPath)); Options.v().set_whole_program(true); Options.v().set_app_mode(true); Options.v().set_output_format(Options.output_format_none); soot.Main.v().autoRun(); Scene.v().loadNecessaryClasses(); } }

逻辑说明:set_whole_program(true) 是生成调用图的前提,表示不是只分析单方法,而是加载整个程序再分析。set_app_mode(true) 让 Soot 把传入的目录下所有类视为应用类(app class),而不是库类。库类默认不分析内部实现,只保留调用边界。set_output_format_none 表示我们不需要 Soot 输出新的 class 或 jar,只看内存分析结果。loadNecessaryClasses 会把所有用到的 JDK 类加载进 Scene,这一步耗时较长,但缺了它后续分析经常遇到 NoClassDefFoundError。

2.3 Scene 与 SootClass/SootMethod 的关系

理解 Scene 是理解 Soot 分析的钥匙。Scene 是 Soot 的全局容器,保存了所有加载的 SootClass。每个 SootClass 包含多个 SootMethod,每个 SootMethod 又包含 Body,Body 里有 Local、Unit、UnitGraph。当你启用 whole-program 模式后,Scene 会建立类层次关系(hierarchy),后面的调用图构建就是基于这个层次关系来做虚方法分派的。

写分析逻辑时,最常见的遍历方式是:

for (SootClass sc : Scene.v().getApplicationClasses()) { for (SootMethod sm : sc.getMethods()) { if (!sm.hasActiveBody()) continue; System.out.println(sc.getName() + "." + sm.getName() + ":" + sm.getParameterCount()); } }

这里要注意 hasActiveBody 的判断,SootMethod 如果没有被解析出 Body,就没有实际指令序列,这样的方法可能来自库类或者没有方法体的接口方法。遍历时先过滤掉可以省很多空指针问题。参数说明:getParameterCount 可以直观看到方法签名,但调试时我更建议打印 sm.getSignature(),那个包含完整返回类型、参数类型、类名,能直接和字节码对上。

3. 用 Soot 生成调用图:从 CG 算法选择到结果可视化

3.1 调用图的三种主流算法:CHA、RTA、SPARK 怎么选

调用图的核心难题是虚方法分派。Java 里 a.foo() 到底是哪个 foo() 被执行,取决于 a 的运行时类型。静态分析没法穷举所有可能,只能用保守近似。Soot 内置了 CHA、RTA、VTA、SPARK 这几种。

CHA(Class Hierarchy Analysis)是最简单暴力的:根据静态类型,在类层次里找出所有可覆盖的候选方法,全部纳入调用图。优点是快,缺点是一堆根本不会被调用的分支也进来了,图上可能有大量噪声。RTA(Rapid Type Analysis)做了改进:先收集整个程序里 new 出来的类型集合,再只限制在这个集合内做分派。精度好一些,但仍然粗。SPARK 是基于 points-to 分析的调用图算法,它追踪每个变量的可能指向,再结合流敏感信息做分派,精度最高,但耗时也高。

实际常用配置是:小工具或教学用 CHA 足够;做污点分析、漏洞扫描这类对精度有要求的场景,直接上 SPARK。注意 SPARK 需要先做 whole-program 分析,并且要开启 points-to 计算。下面是我的推荐配置代码:

Options.v().set_phase_option("cg", "enabled:true"); Options.v().set_phase_option("cg.cha", "enabled:false"); Options.v().set_phase_option("cg.spark", "enabled:true"); Options.v().set_phase_option("cg.spark", "verbose:false"); Options.v().set_phase_option("cg.spark", "ignore-types:false");

逻辑说明:cg.cha 和 cg.spark 是 Soot 的 phase 选项。Soot 把分析过程拆成很多 phase,调用图构建在 cg 阶段。如果你没有显式开启 cg.spark,Soot 默认走 CHA。ignore-types 设为 false 表示严格按类型约束来算 points-to,能减少一部分假调用边,但也会让分析更慢。建议在你的测试小项目里先保持默认,等跑通了再试着开关这里。

3.2 生成调用图并输出 GraphViz 格式:最小完整代码

生成调用图本身只需一句 API 调用:Scene.v().getCallGraph()。难点在于怎么把图变成你眼睛能看的东西。我一般直接把调用图导出成 dot 文件,再用 GraphViz 渲染成 PNG。下面是完整的生成与导出代码:

import soot.Scene; import soot.SootMethod; import soot.jimple.toolkits.callgraph.CallGraph; import soot.jimple.toolkits.callgraph.Edge; import java.io.FileWriter; import java.io.IOException; import java.io.PrintWriter; public class CallGraphExporter { public static void exportDot(String path) throws IOException { CallGraph cg = Scene.v().getCallGraph(); PrintWriter writer = new PrintWriter(new FileWriter(path)); writer.println("digraph callgraph {"); writer.println(" rankdir=LR;"); for (SootMethod method : cg) { if (method.getDeclaringClass().isLibraryClass()) continue; String color = method.isConcrete() ? "black" : "gray"; writer.println(" \"" + method.getSignature() + "\" [color=" + color + "];"); } for (Edge edge : cg) { SootMethod src = edge.getSrc(); SootMethod tgt = edge.getTgt(); if (src.getDeclaringClass().isLibraryClass() || tgt.getDeclaringClass().isLibraryClass()) continue; writer.println(" \"" + src.getSignature() + "\" -> \"" + tgt.getSignature() + "\" [label=\"" + edge.kind() + "\"];"); } writer.println("}"); writer.close(); } }

逻辑说明:cg 实现了 Iterable ,可以直接遍历所有有调用关系的节点方法。Edge 的 kind 很关键,它告诉你这条边是怎么解析出来的。常见值有 Virtual、Static、Special(构造函数和私有方法)、Interface。如果图上某条边是 Virtual 但实际目标只有一个,说明这里可能可以优化;如果 Interface 边很多,说明项目里用接口回调比较频繁,分析时会放大路径空间。导出时我跳过库类,不然 JDK 内部调用会让图爆炸。你如果想看 JDK 调用,可以再加一行条件判断,但建议先在应用类上排查。

3.3 怎么看这张调用图:打印调用路径验证结果

生成的 dot 文件可以用 GraphViz 的 dot 命令转成 PNG。但大项目里直接看图不现实,更实用的方式是用代码去验证某条调用路径是否存在。比如,你想确认 Main.main 是否能调到 AlarmHelper.checkCondition:

public static boolean hasPath(SootMethod from, SootMethod to) { CallGraph cg = Scene.v().getCallGraph(); Set<SootMethod> visited = new HashSet<>(); Deque<SootMethod> stack = new ArrayDeque<>(); stack.push(from); while (!stack.isEmpty()) { SootMethod current = stack.pop(); if (current.equals(to)) return true; if (!visited.add(current)) continue; for (Iterator<Edge> it = cg.edgesOutOf(current); it.hasNext(); ) { stack.push(it.next().getTgt()); } } return false; }

这里的遍历不是 BFS,这个写法是 DFS。调用图本身就是有环的,所以 visited 集合必须要有,否则递归调用直接栈溢出。edgesOutOf 返回的是迭代器,Soot 设计上这么做是为了避免一次性构造大集合。如果需要更详细的路径,可以自己存一个 Map<SootMethod, SootMethod> 记录前驱,最后回溯。对于 SootTest 这个场景,hasPath 式的验证足够用,跑测试时能自动断言“从入口到 sink 是否有可达路径”。

4. 构建过程间控制流程图(ICFG):从单个 CFG 到跨方法连接

4.1 CFG、ICFG 与调用边、返回边的定义

方法内的控制流图 CFG 描述的是单个方法中指令的执行顺序:每个节点是一条 Jimple 语句,边代表控制流从一条指令到另一条指令。但跨方法的分析不能只看单个方法,比如 A 调用 B,B 内部有分支,分支的后果会影响 A 的后续行为。这时候需要把每个方法的 CFG 通过调用边(call edge)和返回边(return edge)拼接起来,形成过程间控制流程图 ICFG。

ICFG 的经典定义来自 Team 的论文,Soot 里没有现成的 ICFG 类,但我们可以用 CallGraph 配合 UnitGraph 自己构造。具体拆法是:每个方法有自己的 CFD 内部边;当一条 invoke 指令存在对应的调用图边时,从调用点连接到被调用方法的入口节点(entry);同时被调用方法的每个返回节点连接到调用点之后的下一条指令。注意异常处理边和返回边要区分开,否则后续分析路径会串错。

Soot 里每个 SootMethod 的 Body 可以通过 getUnits() 配合 ExceptionalUnitGraph 得到 CFG。下面是生成一个方法 CFG 的代码:

import soot.toolkits.graph.ExceptionalUnitGraph; import soot.toolkits.graph.UnitGraph; public static UnitGraph buildCFG(SootMethod method) { if (!method.hasActiveBody()) return null; return new ExceptionalUnitGraph(method.getActiveBody()); }

逻辑说明:ExceptionalUnitGraph 会把可能抛异常的指令也加上边,比如 invoke 指令可能抛 NullPointerException、数组访问可能抛 IndexOutOfBounds。如果不关心异常流,可以改用 UnitGraph 的默认实现。坑在于:异常边会让图多出很多节点,分析时路径数量会爆炸。如果有性能压力,可以在构建后手动过滤掉异常边,只保留正常控制流。

4.2 用 CallGraph 构造 ICFG 的完整实现

下面这个类是我在 SootTest 里常用的 ICFG 构建方式。它不追求性能极致,但结构清晰:

import soot.*; import soot.toolkits.graph.UnitGraph; import soot.jimple.toolkits.callgraph.CallGraph; import soot.jimple.toolkits.callgraph.Edge; import java.util.*; public class ICFGBuilder { private final Map<SootMethod, UnitGraph> methodCFGs = new HashMap<>(); private final CallGraph callGraph = Scene.v().getCallGraph(); public UnitGraph getOrCreateCFG(SootMethod method) { return methodCFGs.computeIfAbsent(method, m -> new ExceptionalUnitGraph(m.getActiveBody())); } public List<Unit> getCallTargets(SootMethod caller, Unit callSite) { List<Unit> targets = new ArrayList<>(); for (Iterator<Edge> it = callGraph.edgesOutOf(caller); it.hasNext(); ) { Edge edge = it.next(); if (edge.srcUnit().equals(callSite)) { targets.add(edge.tgt().getActiveBody().getUnits().getFirst()); } } return targets; } public Unit getReturnTarget(SootMethod caller, Unit callSite) { Unit successor = callSite.getBoxes().isEmpty() ? null : null; for (Unit u : caller.getActiveBody().getUnits()) { // 这里简化思路:找到 callSite 的下一条指令 } return null; } }

上面的 getReturnTarget 我给的是半成品思路,完整实现要考虑 Jimple 里 Unit 链如何取后继。正常做法是用 UnitGraph 的 getSuccsOf(callSite),把引用到的后继排除掉异常分支后,就是返回点。这里贴一个更实用的版本:

public List<Unit> getReturnTarget(SootMethod caller, Unit callSite) { UnitGraph cfg = getOrCreateCFG(caller); List<Unit> results = new ArrayList<>(); for (Unit succ : cfg.getSuccsOf(callSite)) { if (!(succ instanceof soot.jimple.Stmt)) continue; results.add(succ); } return results; }

参数说明:getSuccsOf 返回的是指令列表,正常情况下 invoke 指令只有一个正常后继。但加了异常处理块后,后继数量会变多,所以用 List 接住。实际分析时,我们还需要记录调用边的返回点映射,方便后续做过程间数据流分析时回跳。Soot 没有强制要求你实现完整的 ICFG 类,只要能回答“某个调用点之后去哪”和“某个 imply 节点是最初哪个方法”就够用。

4.3 验证 ICFG 连通性:写一个路径计数器

我建议你写一个验证函数,确认 ICFG 的拼接是否成功。比如统计从 Main.main 开始的调用深度,看能不能走到某个目标方法:

public static int countPaths(SootMethod entry, SootMethod target, int maxDepth) { return dfsCount(entry, target, maxDepth, new HashSet<>()); } private static int dfsCount(SootMethod current, SootMethod target, int depth, Set<SootMethod> visited) { if (depth > 10) return 0; if (current.equals(target)) return 1; if (!visited.add(current)) return 0; int count = 0; CallGraph cg = Scene.v().getCallGraph(); for (Iterator<Edge> it = cg.edgesOutOf(current); it.hasNext(); ) { count += dfsCount(it.next().getTgt(), target, depth + 1, visited); } visited.remove(current); return count; }

注意这里有个小技巧:visited.remove(current) 在回溯后删除当前节点。这个细节很重要。如果不做这个恢复,后续分支无法重新访问当前节点,路径数会被低估。但对于大图,这种递归 DFS 会指数爆炸,所以 maxDepth 限制必须有。验证阶段能跑通就说明调用图和 ICFG 的基本连接没有大问题。

5. 参数调优与避坑:从 classpath 到 SPARK 的常见翻车现场

5.1 classpath 不完整导致的 NoClassDefFoundError

现象:程序跑着跑着抛 NoClassDefFoundError,堆栈指向某个 JDK 类或第三方库类。

原因:Soot 加载类时,类层次分析需要能解析所有父类和接口。如果你只指定了应用 classpath 而没有把依赖 jar 放进去,Soot 找不到父类就会报错。

解决:把依赖 jar 全部拼到 set_soot_classpath 里,或者直接用 Soot 的 soot.class.path 默认值。我一般这么拼:

String javaHome = System.getProperty("java.home"); Options.v().set_soot_classpath(classPath + File.pathSeparator + javaHome + "/lib/rt.jar");

注意新 JDK 没有 rt.jar,Soot 4.x 支持在 modules 模式下用 java.base 等模块名,但更容易的办法是安装一个 JDK 8 专门给 Soot 用。这不是玄学,是老版本字节码处理库对模块化支持欠佳。如果你只能 JDK 11+,尝试在启动参数加 --add-modules java.base 有时能缓解,但最稳的还是 JDK 8。

5.2 调用图边数异常多或异常少:SPARK 配置失衡

现象:调用图输出几千条边,大部分是到 java.lang.Object 的 equals 或 hashCode,或者反过来边极少,明显缺少虚方法调用。

原因:边异常多是因为类层次分析把所以可能的子类全部算了进来;边异常少往往是因为没有开 whole-program 模式,或者 process_dir 没包含所有应用类。

解决:先检查 Options.v().get_whole_program() 是否为 true。如果已经 true,再调整 SPARK 的 on-fly-callgraph 参数。一个常见组合是:

Options.v().setPhaseOption("cg.spark", "on-fly-callgraph:true"); Options.v().setPhaseOption("cg.spark", "propagater:true");

on-fly-callgraph 表示在 points-to 传播过程中同步构建调用图,能去掉一部分不可达方法。但注意开启后分析时间会成倍增加,你的测试用例如果超过几百个类,建议先关掉跑基线,确认功能正确后再开启优化。

5.3 分析结果包含库调用导致路径爆炸

现象:hasPath 方法几乎对所有目标都返回 true,因为 JDK 内部的调用路径也被纳入,比如字符串拼接的 StringBuilder.append 一路连到任何对象。

原因:Soot 的调用图默认包含库边,库方法之间互相调用非常多。

解决:在遍历和输出时过滤掉 isLibraryClass,这是最简单有效的。如果必须分析库方法内部实现,可以用 PhantomRef 方式加载,但成本太高,不建议初期做。下面是过滤代码:

public static boolean isApplicationMethod(SootMethod method) { return !method.getDeclaringClass().isLibraryClass(); }

这个判断在每个中间分析步骤里都要用。我经常直接在遍历调用图时判断,这样能减少九成以上的无用边。

5.4 Jimple 指令类型判断错误:InvokeStmt 与 AssignStmt 混用

现象:分析调用点时,有些 invoke 指令被漏掉。

原因:Jimple 里调用分两种——单独一条语句 InvokeStmt(不返回值)和赋值语句 AssignStmt 的右值(返回值的调用)。很多新手只判断了 InvokeStmt。

解决:用 Unit .getId 或者更精细的 InstanceInvokeExpr 判断。常见代码:

if (unit instanceof soot.jimple.InvokeStmt) { ... } else if (unit instanceof soot.jimple.AssignStmt) { Value rhs = ((soot.jimple.AssignStmt) unit).getRightOp(); if (rhs instanceof soot.jimple.InstanceInvokeExpr) { ... } }

注意接口默认方法也可能通过 InvokeStmt 调用,所以不要认为 invoke 一定在 AssignStmt 右值。这种边界在写工具时很容易踩。

5.5 自己写的测试类没有被加载进 Scene

现象:遍历应用类时,自己写的 Test.java 编译后的 class 没出现在结果里。

原因:Soot 的 process_dir 只处理该目录下的 class 文件,不处理源码。你需要先用 javac 编译,或者把源码目录也加进 process_dir 但设置 allow-phantom-refs。

解决:在 Gradle 里配置先编译再运行,确保 testdata 目录在 Soot 运行前已经有 class 文件。如果坚持要直接分析源码,用 Soot 的 -src-prec java 选项,但它很多分析特性不支持,不建议。

6. 让 SootTest 真正可用:封装成命令行工具并用单元测试验证调用图

6.1 设计一个可复用的调用图查询 CLI

前面写的都是散方法,最终要集成成一个工具。我会把 SootTest 封装成命令行工具,支持输入 class 目录或 jar,然后指定入口方法,输出调用图 dot 文件和简单的统计信息。CLI 用 JCommander 解析参数,但没有也没关系,直接用 main 函数解析字符串数组即可。下面是一个简化版的参数处理:

public static void main(String[] args) throws IOException { if (args.length < 2) { System.err.println("用法: soottest <classpath> <entry-class>"); System.exit(1); } String classPath = args[0]; String entryClass = args[1]; Options.v().set_soot_classpath(classPath); Options.v().set_process_dir(Arrays.asList(classPath)); Options.v().set_whole_program(true); Options.v().set_app_mode(true); Options.v().set_output_format(Options.output_format_none); soot.Main.v().autoRun(); Scene.v().loadNecessaryClasses(); SootClass entrySootClass = Scene.v().getSootClass(entryClass); SootMethod entry = entrySootClass.getMethodByName("main"); CallGraphExporter.exportDot("callgraph.dot"); int reachable = countReachableMethods(entry); System.out.println("从入口可到达方法数: " + reachable); } public static int countReachableMethods(SootMethod entry) { Set<SootMethod> visited = new HashSet<>(); Deque<SootMethod> stack = new ArrayDeque<>(); stack.push(entry); while (!stack.isEmpty()) { SootMethod m = stack.pop(); if (!visited.add(m)) continue; CallGraph cg = Scene.v().getCallGraph(); for (Iterator<Edge> it = cg.edgesOutOf(m); it.hasNext(); ) { stack.push(it.next().getTgt()); } } return visited.size(); }

这个工具的价值很明显:你拿到一个第三方 jar,跑一下就知道 main 方法通向哪些类,哪些方法实际上从未被调用。这个“不可达集合”在安全测试和代码瘦身中都很值钱。注意 loadNecessaryClasses 之后不要再去改 classpath,Soot 的 Scene 一旦建立就不允许动态加载新类,否则要重新初始化。这也是 Soot 最反直觉的地方:它是一个单例全局状态框架,脚本里写循环多次分析不同 jar 时,必须在每次迭代里重置 Soot 的全局状态。重置方法如下:

soot.G.reset();

这个 G.reset() 是后悔药也是陷阱。忘记调用它会让你第二次分析时拿到上一次残留的类,结果完全错误。建议每个测试方法上标注 @Before 和 @After 分别做初始化和 reset。

6.2 用 JUnit 跑验证:断言调用图存在且仅包含预期边

工具能做出来,还得能持续回归。我在 SootTest 里加一组 JUnit 用例,专门验证小程序的调用图是否和预期一致。假设被测类:

class A { void foo() { new B().bar(); } } class B { void bar() { System.out.println("hi"); } }

测试用例可以这样写:

@Test public void testCallGraphHasExpectedEdge() throws Exception { setupSoot("src/test/resources/testdata"); Scene.v().loadNecessaryClasses(); SootClass aClass = Scene.v().getSootClass("A"); SootMethod foo = aClass.getMethodByName("foo"); SootClass bClass = Scene.v().getSootClass("B"); SootMethod bar = bClass.getMethodByName("bar"); CallGraph cg = Scene.v().getCallGraph(); boolean hasEdge = false; for (Iterator<Edge> it = cg.edgesOutOf(foo); it.hasNext(); ) { Edge e = it.next(); if (e.getTgt().equals(bar)) hasEdge = true; } assertTrue(hasEdge); } @After public void tearDown() { soot.G.reset(); }

setupSoot 方法里封装一遍 Options 配置,注意每个测试都重新跑一遍 Soot 初始化,速度会很慢。所以更合理的做法是用 @BeforeClass 初始化一次,但那样多个测试共享全局状态,容易互相污染。我最后是选择了每组测试类里只初始化一次,但保证一个用例只测一个独立程序。如果你的测试程序有几十个类,初始化一次的成本大约三五秒,可以接受。

6.3 把 ICFG 用于简单的过程间切片

当你把调用图和 CFG 组合成 ICFG 后,最直接的应用是过程间切片。假设你在某个调用点之后的 return 语句上定义了兴趣变量,希望找到所有影响它的语句。传统方法内切片只能看单方法,但过程间切片能顺着调用边和返回边跨方法回溯。Soot 自带的数据流分析框架提供了一些工具,但自己基于 ICFG 写一个正向或反向遍历也不复杂。我建议的练习是:从目标方法的 return 语句开始,反向遍历它的 CFG 前驱;当遇到赋值语句时,把赋值的变量加入兴趣集合;遇到 invoke 语句时,如果参数或者 receiver 在兴趣集合里,就把被调用方法的返回值相关的语句加入分析队列。这个实现虽然粗糙,但跑在 SootTest 的小例子上,能帮你把 ICFG 的数据结构彻底用熟。

6.4 我最后的一个习惯:先画一个最小可画图

给新手一个忠告。刚开始做 SootTest 时,别急着拿一个大 jar 去跑调用图。那只会得到一张密密麻麻根本没法 debug 的 dot 文件。我的习惯是,先写一个 5 到 8 个类的小程序,包括继承、接口实现、递归调用、静态方法调用、构造函数链,每个特征都覆盖到。然后在生成调用图后用 GraphViz 渲染,肉眼核对每个方向的调用是否正确。核对通过后,再去换真实项目。这样遇到问题,你永远能确定是 Soot 配置错了还是分析逻辑错了,而不是把时间耗在几百个类的调用图里找一条可疑的边。

另外一个细节是 dot 渲染时中文签名乱码。Soot 的 getSignature 在类名和参数类型上用的都是 JVM 内部名字,比如 java.lang.String 会写成 java.lang.String,没有中文,但如果你在源码里有中文注释或类名,Jimple 里不会保留,所以一般不会遇到。真正会乱码的是输出文件名里的中文字符,建议所有路径用英文。我在 Windows 上吃过一次亏,Soot 输出 temp 文件到中文目录时直接崩溃,改成英文路径就好了。

最后希望这个 SootTest 的实践路径能帮你少走弯路。静态分析里调用图和 ICFG 是最底层的两块地基,把这两张图生成并验证清楚,后面做污点分析、权限检测、死代码清理都会顺手很多。如果跑通后有疑问,可以先回看自己的 phase 配置和类路径,九成问题都出在这两处。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询