Soot调用图与过程间控制流图实战:从构建到避坑

发布时间:2026/10/25 14:05:45
Soot调用图与过程间控制流图实战:从构建到避坑

简介:SootTest是一份面向Java开发者的Soot框架实战示例资源,适合对静态程序分析、字节码优化及调用关系可视化感兴趣的读者。项目演示了如何借助Soot从Java源码或字节码构建Scene场景,并通过配置生成调用图(Call Graph)与过程间控制流程图(ICFG),为理解跨方法的数据流和控制流特征提供了可运行的范例。压缩包共23个文件,主要包含Java源码、编译后的class文件、Soot依赖jar包、dot格式图文件、PNG输出图以及Eclipse工程配置文件;其中jar包可直接用于运行示例,dot与PNG结果便于对照和分析生成效果。资源包整体约12.27MB,结构简洁,上手门槛适中。目前已有1439人学习下载,适合初学Soot框架、希望快速掌握调用图与ICFG生成流程的Java开发者参考实践。

1. 从反编译黑匣子到程序分析地基:为什么你需要 Soot 的调用图

做程序分析的人大概率都有过这种经历:拿到一个 APK 或 jar 包,想搞清楚某个敏感函数到底被谁调用了、调用链有多长,打开反编译工具一看,满屏 switch 和 goto,多态一多就彻底断了线索。调用图就是把这个过程从“人肉逆向”变成“形式化分析”的关键一跳,而 Soot 是目前开源工具链里把调用图和过程间控制流图做得最顺手的一个。这套方案能帮你回答三个核心问题:某个方法的所有调用点在哪、程序从入口到敏感操作经过了哪些路径、以及这些路径里的分支条件是否可控。适合做安卓隐私合规检测、漏洞挖掘前的基础建模,以及静态分析框架的底层支撑。本文不绕理论,直接从怎么用 Soot 生成调用图和过程间控制流图(ICFG)讲起,参数、API、踩坑都放在后面。

2. 从字节码到分析模型:Jimple 中间表示与调用图前置概念

2.1 为什么选 Soot 而不是直接解析字节码

直接解析 JVM 字节码或 DEX 字节码的方式,很多时候是从汇编层面去理解程序逻辑。Java 方法里的一个try-catch在字节码里被拆成异常表和跳转指令,一个for循环展开后是几条条件跳转和goto的组合。把控制流从这些底层指令里还原出来,不但费劲,而且非常容易在边界情况上翻车。Soot 做的事情是先把字节码统一转换成一种名叫 Jimple 的三地址中间表示,它保留了类型信息、局部变量、方法调用语句,同时又比字节码抽象一层,读起来像简化版的 Java 代码。这种中间表示最大的好处是:你不用分别处理.class、.dex、.apk三套字节码语义,Soot 已经帮你抹平了格式差异,后面所有分析(调用图、数据流、切片)都在 Jimple 这一层做。

另一个容易被忽略的理由是:Soot 的框架不只是一个“翻译机”,它自带一套完整的分析框架。程序分析里最难的部分不是遍历,而是怎么在遍历中维护别名关系、异常边和跨方法的数据流状态。你用纯手写的方式解析字节码,光处理invokevirtual的类型解析就要写一大坨逻辑;换成 Soot,一个Scene.v().getCallGraph()就能拿到全程序的调用图。分析框架的价值在于,它为“过程间分析”提供了现成的数据结构,而不是让你从零开始搭积木。我一般会建议团队里新做静态分析的人,先花一周把 Soot 的 API 走一遍,胜过自己造一个字节码解析轮子。

2.2 调用图的两类构建算法:CHA 与 Spark 的精确定位

生成调用图在 Soot 里有两条主要路线:CHA(Class Hierarchy Analysis)和 Spark。CHA 的思路很朴素——根据声明类型去类继承关系里找所有可能的重写方法,把所有可能的目标全连上边。它的优点是快,因为只查继承树,不需要走进方法体分析数据流;缺点是虚调用(interface dispatch)会爆出大量不可能的边。比如一个list.add(x)可能被 CHA 连向所有实现List接口的类的add方法,哪怕那类压根没被实例化。实际项目里这种假边占比很高,你要是基于 CHA 做污染路径分析,很容易跑出几百条根本走不通的假路径。

Spark 走的是另一条路:它在构建过程中用指针分析和调用图互相迭代,每次调用图新增一条边,就把对应的实参指向关系传播到被调方法的形参,再反过来通过 new 表达式的流向确定更精确的分派目标。它的分析粒度更细,假边明显减少,缺点是慢,而且要处理递归调用时需要考虑 fixed-point 迭代。Soot 里同时提供几种分析变体,像是paddle和spark框架,默认的 Spark 配置适合大多数场景。实际使用中,我的建议是:如果你只是想知道方法之间的包含关系,CHA 就够用;如果你要基于调用图做污点传播或权限路径分析,上 Spark。后文的命令都基于 Spark 配置展开,参数细节在第三章给出。

3. 把一个 JAR 变成可查询的调用图:最小工程与第一个 Hello World

3.1 工程配置的三条路径(Maven、Python 绑定、命令行)

Soot 最常见的落地方式是 Java Maven 工程,依赖坐标是ca.mcgill.sable:soot。这个坐标在 Maven Central 上长期维护,目前主流版本对应 Java 8 及以上都兼容,Gradle 项目也适用。如果你主要做快速预研,可以直接用 Python 绑定库,装一个soot包(对应 Soot 的 Python 接口实现),API 风格和 Java 版几乎一致,适合在 Jupyter Notebook 里做探索式分析。工程团队我建议用 Maven,因为后续要集成自研分析逻辑,Java API 最完整。

配置完成后的最小代码如下:

import soot # 配置分析目标与输出模式 soot.options.set_option("soot-class-path", "target/classes") soot.options.set_option("output-format", "jimple") soot.options.set_option("keep-line-number", True) # 这一步会把目录下的 .class 文件加载进 Scene soot.Main.main(["com.example.target.Main"]) # 直接访问全局场景中的调用图 call_graph = soot.Scene.v().call_graph() print("调用边总数:", call_graph.size())

这段代码做了三件事:设置类路径、指定入口类、触发 Soot 的整包分析流程。soot-class-path是分析目标的类路径,注意它应该指向编译产出目录而不是源码目录;Soot 只认字节码。keep-line-number打开行号保留,后面做过程间控制流图映射回源码位置时全靠它。soot.Main.main传入的是入口类列表,Soot 会从这些类的 main 方法开始构建调用图。Python 绑定里call_graph对象提供size()、edges()等接口,可以快速做一些基础统计;但在复杂分析场景,还是推荐回 Java 侧拿完整 API。

3.2 深入 Scene:入口、调用边与调用点的三类 API

拿到调用图对象之后,最常见的操作是查询某个方法的所有调用者和被调用者。Soot 的Scene是一个全局单例,它持有所有加载的类、方法、字段和调用图。查询调用关系的核心接口是CallGraph类,里面提供两类查询方式:edgesOutOf(s)和edgesInto(s),其中s是SootMethod对象。在实际代码里,调用图用Unit(Jimple 语句)级别的边来组织,这意味着你可以从一条 invoke 指令精确回溯到它的解析目标。

// 获取入口方法 SootClass mainClass = Scene.v().getSootClass("com.example.target.Main"); SootMethod mainMethod = mainClass.getMethodByName("main"); // 遍历从 main 出发的所有边 for (Iterator<Edge> it = Scene.v().getCallGraph().edgesOutOf(mainMethod); it.hasNext(); ) { Edge e = it.next(); SootMethod tgt = e.tgt(); Unit srcUnit = e.srcUnit(); System.out.println("调用点: " + srcUnit.getJavaSourceLineNumber() + " -> " + tgt.getSignature()); }

这里的e.srcUnit()返回的是调用语句本身,能拿到行号信息。做完这一步,你就可以在 IDE 里跑出第一条真实调用链了。如果你的分析对象是 Android APK,Soot 需要额外指定 Android 平台 JAR 的路径,否则无法解析系统 API 的调用目标,常见的做法是通过soot.options里的android-jars参数指向本地下载好的 android.jar 缓存目录。

4. 从调用图到过程间控制流图:带着异常边和数据流走完整个程序

4.1 ICFG 的结构:Unit 图、显式边与异常边

调用图解决的是“谁调用了谁”,但很多时候你还想知道“从方法入口到某个调用语句,经过了哪些路径”。这就需要过程间控制流图,也就是 ICFG。ICFG 在数据结构上就是把每个方法的 CFG(控制流图)通过调用边和返回边连成一张大图。Soot 里构建 ICFG 的入口是UnitGraph体系:每个方法有一张UnitGraph,节点是 Jimple 语句,边模拟语句之间的执行顺序。方法之间有两种连接方式:显式的调用边,从调用语句连到被调方法入口;返回边,从被调方法的出口(或 return 语句)连回调用语句的下一条语句。

这里有一个很关键的坑:异常边。Java 里的try-catch、方法调用可能抛出的运行时异常,都会产生额外的控制流路径。Soot 在建图时默认会为可能抛异常的方法调用构造异常边,这些边不是你在源码里能直接看到的,但对可达性分析影响很大。举个例子:一个Integer.parseInt()调用,在源码里看起来是顺序执行,但 ICFG 里它可能同时有一条边流向NumberFormatException的 handler。如果你的分析逻辑遍历 ICFG 时没把异常边算进去,会漏掉真实的崩溃路径;算进去了又会在无异常的代码里多出不少分支。Soot 允许在构建UnitGraph时指定是否忽略异常边(ExceptionalUnitGraph构造器的ignoreExceptionalEdges参数),这个参数按需开关:做可达性分析建议保留,做精简路径生成建议关掉。

4.2 写一个三行算法:把 ICFG 的路径原样吐出来

构建 ICFG 在 Soot 里不算复杂,下面这段代码利用ExceptionalUnitGraph做过程间路径遍历,思路是深度优先走当前方法的 CFG,碰到 invoke 指令就进入被调方法的图里继续走,同时记录路径上的方法签名:

import soot from soot.jimple import Stmt, InvokeExpr def build_icfg(entry_method): # 用异常敏感的 UnitGraph 做底图 graph = soot.jimple.UnitGraph(entry_method, True) paths = [] def dfs(stmt, path, depth): if depth > 15: return path.append(stmt) # Jimple 里调用语句可以实现 InvokeExpr 接口 if isinstance(stmt, soot.jimple.InvokeStmt): for expr in stmt.invoke_exprs(): target = expr.method() if target.is_concrete(): # 不进入抽象方法 dfs(target, path, depth + 1) for succ in graph.get_succs_of(stmt): dfs(succ, path, depth) path.pop() dfs(graph.get_body().get_first_non_identity_stmt(), [], 0) return paths

depth > 15是深度限制,防止递归调用把遍历栈打爆。is_concrete()会过滤掉抽象方法,因为它们没有可执行的体。这个实现是一个极简的深度优先路径解析,足够用于小规模 jar 包的路径枚举;但对大型 APK,跑全量路径会产生指数级路径爆炸,常见做法是加约束条件:只追踪从入口到某个特定 target 方法的路径,中间用Unit的getJavaSourceLineNumber记录行号。Soot 在UnitGraph的getSuccsOf里已经包含了普通 CFG 后继和异常后继,所以你的路径天然带有异常分支。

4.3 定位敏感路径:从入口方法到目标调用语句的可达性分析

如果只是要回答“从 main 能不能走到sendTextMessage”,不需要列全量路径,只需要做一次反向的图搜索。Soot 的 ICFG 支持反向遍历,从目标调用出发,沿 ICFG 的入边往回找,直到到达入口方法。下面这段代码用 BFS 检查可达性:

def reachable_in_icfg(entry_method, target_method): graph = soot.jimple.UnitGraph(entry_method, True) queue = [graph.get_body().get_first_non_identity_stmt()] visited = set() while queue: stmt = queue.pop(0) if stmt in visited: continue visited.add(stmt) if hasattr(stmt, 'invoke_exprs') and stmt.invoke_exprs(): for expr in stmt.invoke_exprs(): if expr.method() == target_method: return True # 同样需要跨过程遍历 for succ in graph.get_succs_of(stmt): # 对被调方法的入口也入队 if isinstance(succ, soot.jimple.InvokeStmt): for expr in succ.invoke_exprs(): m = expr.method() if m.is_concrete(): queue.append(m.get_body().get_first_non_identity_stmt()) queue.append(succ) return False

这里有个细节容易踩坑:UnitGraph.get_succs_of只返回当前方法内部的后继,不会自动跨方法跳转。你在遍历时遇到调用语句,要手动把被调方法体的第一条语句加入队列。上面代码里的InvokeStmt分支就是在补这一步。如果你用的是 Soot 里更高层的ExceptionalUnitGraph,它在线性化时同样不会替你跨过程边,跨过程这部分永远得自己处理。这也是为什么很多二次开发会直接基于 Soot 的Patching框架去改 IR,而不是自己造遍历器。

5. 常见避坑:调用边界、递归与 Android 场景的三个黑匣子

5.1 现象:调用图里多出一堆Library类,连的是System.exit

用 CHA 生成调用图后,会发现图里经常出现java.lang.System.exit、android.util.Log.d这类库方法的调用边。原因是 Soot 默认加载rt.jar或 Android JAR 中的系统类,而这些类的某些方法可能在你的入口可达范围内被调用到。看起来无害,但如果你把调用图渲染出来,图上会塞满来自java.*包的噪声节点,完全没法看。

原因有两个:第一,Soot 把库类也加载进Scene,没有区分 application 类与 library 类;第二,CHA 对虚方法的处理会把所有子类可能的实现都连上。解决方式是构建调用图前调用Scene.v().setApplicationClassPath()或者显式设置soot.ClassProvider,告诉 Soot 只有target/classes下的类是分析对象。更进一步的方案:使用soot.Scene.v().getSootClass().setApplicationClass()对单个类标记,把库类排除在调度目标之外。项目里一般会在配置处加一行allow-phantom-refs来允许引用缺失的库类,避免因为缺一个类导致整个分析中断。

5.2 现象:递归方法导致路径枚举卡死,内存飙升

跑路径遍历时,遇到A -> B -> A这种递归调用,如果深度限制写得不对,程序会一直往深走,最终堆内存溢出。这个坑在真实项目里出现频率极高,特别是处理缓存框架、树形递归遍历这类代码时。原因很简单:DFS 里只记录当前路径是否访问过,没有记录“已访问过的方法调用组合”。解决方式有两个方向:一个是在路径枚举时设定全局节点访问集合,同一轮分析中一个(method, stmt)组合最多进入一次;另一个是在调用图上做后向切片时,每次递归检查调用栈中是否已存在同目标方法,是则直接剪枝。上面代码里的depth > 15只是粗暴的止损,实际项目里限制条件一般改为“同一方法在递归调用栈内只能出现一次”,避免因为大循环而无谓扩容。

5.3 现象:Android APK 分析时调用图缺入口,也没法定位到 Activity

如果你喂给 Soot 的是一个 APK,而入口配置还是com.example.target.Main,大概率会报找不到类的错误或者一个几乎为空的调用图。因为 Android 应用的入口不是一个简单的 main 方法,而是AndroidManifest.xml里声明的 Activity、Service、BroadcastReceiver。Soot 提供专门的入口点计算机制:当你通过soot.Main加载 APK 时,它会自动解析 Manifest 并生成一组包括onCreate、onStart在内的 stub 方法。但如果你配置错了,比如漏了-process-dir或-android-jars,Soot 会退化成只分析某一个 class 文件,调用图自然不完整。

通常的处理方式是:分析 APK 时给soot.options传入process-dir(指向 APK 解压后的目录或直接指向 APK 文件)和android-jars(指向本地缓存的安卓平台 jar),然后使用Soot里内置的AndroidEntryPointCalculator来计算入口点。这个类会扫描 Manifest,把<application>下的组件都作为入口。如果你用的是 Python 接口,入口点计算预设在soot.Main内部,但建议手动指定soot.options.set_option("src-prec", "apk")把输入格式切为 APK 模式,否则默认按 class 文件处理,解析必然出错。

5.4 现象:ICFG 里出现不存在的边:通过get_UseAndDefs发现异常处理被错误合并

有些场景下,Soot 的UnitGraph会生成错误边,比如一个 try 块末尾的赋值语句竟然指向 catch 块。这通常源于字节码级别的异常表把覆盖范围扩大到了你预期之外的范围。JVM 字节码里异常表的最小粒度是字节码偏移区间,如果一个 try 块里的某个指令内部发生了栈状态变化,那么整个区间内的所有指令都有潜在异常边。Soot 在转换时由于没有源码级语义,只能保守地保留异常边。从分析角度看,这不是 bug,但如果你拿着 ICFG 里的边去反推控制流是否真实,容易得出错误结论。

一个稳健的调试手法是:用UnitGraph的getEdges()遍历所有边,打印出每条边的源码行号,把有异常边指向的语句单独标记出来。观察一段时间你会发现,几乎所有的异常边都指向一个区域的“合并 catch 块”——这是 Java 编译器把多个 catch 合并成一个异常处理器的产物。遇到这种情况,不要尝试从 Soot 配置层面去掉异常边,因为那是字节码层面的结构问题;而是在分析逻辑里用ThrowAnalysis接口给每条调用语句重新计算“可能抛出的异常类型”,滤掉与当前场景无关的异常路径。Soot 默认的ThrowAnalysis是朴素实现,对重载优化过的 Obfuscated 代码有偏差,但作为初筛已经足够了。

6. 进阶:把你的调用图落成可视化 Web 服务和动态验证一条边

6.1 用 TinyGraph 把 CALL_GRAPH 转成 DOT 格式做局部放大

调用图生成之后,第一件事是渲染出来。Soot 的DotGraph类可以导出 DOT 格式,但图一大就会乱成一团。常用做法是提取某几个方法的局部子图,再在 Graphviz 里展示。下面是一段基于 Java API 的导出逻辑:

private void exportSubGraph(SootMethod root, String path) { DotGraph graph = DotGraph.DotGraph("subgraph"); CallGraph cg = Scene.v().getCallGraph(); for (Iterator<Edge> it = cg.edgesOutOf(root); it.hasNext(); ) { Edge e = it.next(); SootMethod src = e.src(); SootMethod tgt = e.tgt(); graph.drawEdge(src.getSignature(), tgt.getSignature()); } graph.plot(path + ".dot"); }

导出之后再跑一遍dot -Tpng subgraph.dot -o subgraph.png,就能得到单一入口的调用关系图了。这个方案的优点是绕开了 Soot 内置可视化对超大图的渲染瓶颈,缺点是只能静态地看一层。如果想让图可交互,我一般会把调用图写进 Neo4j:把SootMethod的getSignature()作为节点 ID,Edge的类型(invokevirtual、invokeinterface等)作为关系类型,这样就能在浏览器里点一个方法顺藤摸瓜找到它触发的一整条链路。数据量在十万节点以内时,这种查询都是毫秒级返回的。

6.2 动态验证调用边:用 JUnit 加 AOP 打印真实运行栈

静态调用图存在误报,为了确认一条边真的会被触发,可以用一个轻量级的运行时验证法。给目标项目加一个面向切面的探针,在候选方法入口打印当前线程的调用栈,然后跑一次测试或复现用例,看实际运行路径里有没有出现静态分析出的那条边。这个手法成本很低,但能把分析结果从“理论上可达”变成“实践中真实发生”。

@Aspect public class CallLogAspect { @Before("execution(* com.example.target.*.*(..))") public void logCall(JoinPoint jp) { StackTraceElement[] stack = Thread.currentThread().getStackTrace(); for (int i = 0; i < Math.min(stack.length, 15); i++) { System.out.println("== " + stack[i].getClassName() + "." + stack[i].getMethodName()); } } }

观察的重点不是打印出的完整调用栈,而是对比静态分析出来的调用图上某一条边是否能在运行栈里找到。如果这条边在静态图里存在但运行时始终不出现,一般有三种可能:静态分析时的入口不够完备、Spark 指针分析把变量指向了过度近似的位置、或者运行时压根没走到那段逻辑。动态验证最大的价值是给静态结论标记可信度,后续做代码审计报告时可以直接引用“运行栈实测确认”或“仅静态可达”的结论,这比空口说“分析工具发现漏洞”有说服力得多。

跑过一轮完整流程之后,我对 Soot 的最大感受是:它把最繁琐的字节码解析和类层级管理都包掉了,但分析策略的质量还是握在使用者手里。越往后做越会发现,边界条件、异常路径、递归深度这类细节,才是调用图和 ICFG 能不能真正落地到生产环境的分水岭。我自己做 Android 隐私合规路径分析时,前期大半时间不是花在写分析规则上,而是花在调 Soot 的类路径和异常处理策略上。希望这些踩坑经验能让你少走一段弯路,把精力放到真正有价值的分析逻辑上。

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