编程

Java SootUp 静态分析框架简介

19 2025-04-24 07:32:00

1. 介绍

本文将介绍 SootUp 库。SootUp 是一个用于对 JVM 代码进行静态分析的库,可以使用原始源代码或编译后的 JVM 字节码。它是对 Soot 库的全面改造,旨在使其更加模块化、更易于测试、更易于维护、更易于使用。

2. 依赖

在使用 SootUp 之前,我们需要在我们的构建中引入最新版本,在撰写本文时为 1.3.0。

<dependency>
    <groupId>org.soot-oss</groupId>
    <artifactId>sootup.core</artifactId>
    <version>1.3.0</version>
</dependency>
<dependency>
    <groupId>org.soot-oss</groupId>
    <artifactId>sootup.java.core</artifactId>
    <version>1.3.0</version>
</dependency>
<dependency>
    <groupId>org.soot-oss</groupId>
    <artifactId>sootup.java.sourcecode</artifactId>
    <version>1.3.0</version>
</dependency>
<dependency>
    <groupId>org.soot-oss</groupId>
    <artifactId>sootup.java.bytecode</artifactId>
    <version>1.3.0</version>
</dependency>
<dependency>
    <groupId>org.soot-oss</groupId>
    <artifactId>sootup.jimple.parser</artifactId>
    <version>1.3.0</version>
</dependency>

这里有几个不同的依赖项,它们有什么作用是什么呢?

  • org.soot-uss:sootup.core 是核心库。
  • org.soot-uss:sootup.java.core 是用于处理 Java 的核心模块。
  • org.soot-uss:sootup.java.sourcecode 是用于分析 Java 源代码的模块。
  • org.soot-uss:sootup.java.bytecode 是用于分析编译后的 Java 字节码的模块。
  • org.soot-uss:sootup.jimple.parser 是用于解析 Jimple 的模块——Jimple 是 SootUp 用于表示 Java 的中间表示。

遗憾的是,没有可用的 BOM 依赖项,因此我们需要分别管理这些依赖项的每个版本。

3. Jimple 是什么?

SootUp 可以分析多种不同格式的代码——包括 Java 源代码、编译后的字节码,甚至是 JVM 内部的类。

为此,它将各种输入转换为一种称为 Jimple 的中间表示形式

Jimple 的存在是为了表示 Java 源代码或字节码所能完成的所有操作,但其方式更易于分析。这意味着它在某些方面有意与这两种可能的输入有所不同。

JVM 字节码在某些值访问方式上是基于堆栈的。这在运行时非常高效,但在分析方面却更加困难。Jimple 的代码表示形式将其转换为完全基于变量的表示形式。这可以实现完全相同的功能,同时更容易理解。

相反,Java 源代码也是基于变量的,但其嵌套结构也使其更难分析。这对于开发人员来说更容易使用,但对于软件工具来说更难分析。Jimple 的代码表示形式将其转换为扁平结构。

Jimple 也作为一种语言存在,我们可以自己读写代码。例如,如下 Java 源代码:

public void demoMethod() {
    System.out.println("Inside method.");
}

可以用如下 Jimple 代码替换:

public void demoMethod() {
    java.io.PrintStream $stack1;
    target.exercise1.DemoClass this;

    this := @this: target.exercise1.DemoClass;
    $stack1 = <java.lang.System: java.io.PrintStream out>;

    virtualinvoke $stack1.<java.io.PrintStream: void println(java.lang.String)>("Inside method.");
    return;
}

这看起来更冗长,但我们可以看到它具有相同的功能。如果我们需要以这种格式存储和转换代码,SootUp 提供了直接解析和生成此 Jimple 代码的功能。

当我们分析代码时,无论原始代码是什么,它都会被转换为这种结构以供我们使用。然后,我们将使用与此表示直接相关的类型,例如 SootClass、SootField、SootMethod 等。

4. 分析代码

在使用 SootUp 进行任何操作之前,我们需要分析一些代码。这可以通过创建一个合适的 AnalysisInputLocation 实例并围绕它构建一个 JavaView 来实现。

我们创建的 AnalysisInputLocation 的具体类型取决于我们要分析的代码的来源。

最简单的方法,但可能本身用处不大,就是能够分析 JVM 本身的类。我们可以使用 JrtFileSystemAnalysisInputLocation 类来实现这一点:

AnalysisInputLocation inputLocation = new JrtFileSystemAnalysisInputLocation();

更有用的是,我们可以使用 OTFCompileAnalysisInputLocation 分析源文件

AnalysisInputLocation inputLocation = new OTFCompileAnalysisInputLocation(
  Path.of("src/test/java/com/baeldung/sootup/AnalyzeUnitTest.java"));

这也有一个替代构造函数,用于一次性分析整个源文件列表:

AnalysisInputLocation inputLocation = new OTFCompileAnalysisInputLocation(List.of(.....));

我们还可以使用它来分析内存中作为字符串(String)的源代码:

Path javaFile = Path.of("src/test/java/com/baeldung/sootup/AnalyzeUnitTest.java");
String javaContents = Files.readString(javaFile);

AnalysisInputLocation inputLocation = new OTFCompileAnalysisInputLocation("AnalyzeUnitTest.java", javaContents);

最后,我们可以分析已经编译好的字节码。这是使用 JavaClassPathAnalysisInputLocation 完成的,我们可以将其指向任何可以被视为类路径的内容——包括 JAR 文件或包含类文件的目录。

AnalysisInputLocation inputLocation = new JavaClassPathAnalysisInputLocation("target/classes");

还有其他几种标准方法可以访问我们想要分析的代码,包括直接解析 Jimple 表示或读取 Android APK 文件。

获取 AnalysisInputLocation 实例后,我们就可以围绕它创建一个 JavaView

JavaView view = new JavaView(inputLocation);

这样我们就可以访问输入中存在的所有类型。

5. 访问类

旦我们分析了代码并围绕它构建了一个 JavaView 实例,我们就可以开始访问代码的详细信息了。首先从访问类开始。

如果我们知道我们想要的确切类名,我们就可以直接使用完全限定的类名来访问它。SootUp 使用各种 Signature 类来描述我们想要访问的元素。本例中,我们需要一个 ClassType 实例。幸运的是,我们可以使用 SootUp 提供的 IdentifierFactory,轻松地使用完全限定的类名来生成一个这样的实例:

IdentifierFactory identifierFactory = view.getIdentifierFactory();
ClassType javaClass = identifierFactory.getClassType("com.baeldung.sootup.ClassUnitTest");

一旦我们创建了 ClassType 实例,我们就可以使用它来访问此类的详细信息:

Optional<JavaSootClass> sootClass = view.getClass(javaClass);

这里返回一个 Optional<JavaSootClass> ,因为这个类可能在我们的视图中不存在。或者,我们有一个 getClassOrThrow() 方法,它直接返回一个 SootClass —— JavaSootClass 的超类——但如果这个类在我们的 JavaView 中不存在,就会抛出异常::

SootClass sootClass = view.getClassOrThrow(javaClass);

一旦我们得到了 SootClass 实例,我们就可以用它来检查类的细节。这让我们能够确定类本身的细节,比如它的可见性、它是具体类还是抽象类等等

assertTrue(classUnitTest.isPublic());
assertTrue(classUnitTest.isConcrete());
assertFalse(classUnitTest.isFinal());
assertFalse(classUnitTest.isEnum());

我们还可以导航已解析的代码,例如通过访问类的超类或接口:

Optional<? extends ClassType> superclass = sootClass.getSuperclass();
Set<? extends ClassType> interfaces = sootClass.getInterfaces();

注意,这些方法返回的是 ClassType 而不是 SootClass 实例。这是因为无法保证实际的类定义是我们视图的一部分,而只是类的名称。

6. 访问字段及方法

除了类本身之外,我们还可以访问类的内容,例如字段和方法。

如果我们已经有一个可用的 SootClass,那么我们可以直接查询它来找到字段和方法

Set<? extends SootField> fields = sootClass.getFields();
Set<? extends SootMethod> methods = sootClass.getMethods();

与从一个类导航到另一个类不同,这可以安全地返回字段或方法的完整表示,因为它们保证在我们的视图中。

如果我们确切知道要查找的内容,也可以直接访问它。例如,要访问某个字段,我们只需要知道它的名称:

Optional<? extends SootField> field = sootClass.getField("aField");

访问方法稍微复杂一些,因为我们需要知道方法名称和参数类型:

Optional<? extends SootMethod> method = sootClass.getMethod("someMethod", List.of());

如果我们的方法需要参数,那么我们需要提供一个来自 IdentifierFactoryType 实例列表:

Optional<? extends SootMethod> method = sootClass.getMethod("anotherMethod",
  List.of(identifierFactory.getClassType("java.lang.String")));

这样,当我们有重载方法时,就可以获取正确的实例。我们还可以列出所有同名的重载方法:

Set<? extends SootMethod> method = sootClass.getMethodsByName("someMethod");

和以前一样,一旦我们获得了 SootMethodSootField 实例,我们就可以使用它来检查详细信息:

assertTrue(sootMethod.isPrivate());
assertFalse(sootMethod.isStatic());

7. 分析方法体

一旦我们获取了 SootMethod 实例,就可以用它来分析方法体本身。这意味着方法签名、方法中的局部变量以及调用图本身。

在执行任何操作之前,我们需要访问方法体本身:

Body methodBody = sootMethod.getBody();

使用这个,我们现在可以访问方法主体的所有细节。

7.1. 访问局部变量

我们可以做的第一件事是访问方法中可用的任何局部变量:

Set<Local> methodLocals = methodBody.getLocals();

这使我们能够访问方法中可访问的所有变量。此列表可能并非预期,它实际上是来自该方法的 Jimple 表示的变量列表,因此会包含解析过程中的一些额外条目,并且可能不包含原始变量名称。

例如,以下方法有 5 个局部变量:

private void someMethod(String name) {
    var capitals = name.toUpperCase();
    System.out.println("Hello, " + capitals);
}

它们是:

  • this
    I1 – 方法参数。
  • I2 – 变量 “capitals”。
  • $stack3 – 指向 System.out 的局部变量。
  • $stack4 – 表示“Hello, ” + capitals 的局部变量。

$stack3 和 $stack4 局部变量由 Jimple 表示生成,并不直接存在于原始代码中。

7.2. 访问方法语句图(Method Statement Graph)

除了局部变量之外,我们还可以分析整个方法语句图。这是该方法将执行的每个语句的详细信息:

StmtGraph<?> stmtGraph = methodBody.getStmtGraph();
List<Stmt> stmts = stmtGraph.getStmts();

这为我们提供了一个列表,其中列出了该方法将执行的所有语句,并按执行顺序排列。每个语句都将实现 Stmt 接口,表示该方法可以执行的操作。

例如,我们之前的方法将生成以下内容:

这看起来比我们实际编写的代码要多很多——实际代码只有两行。这是因为这是我们代码的 Jimple 表示。但我们可以将其分解开来,看看究竟发生了什么。

我们从两个 JIdentityStmt 实例开始。它们代表传递给我们方法的值—— this 值和我们之前看到的作为第一个参数的 I1。

接下来,我们有三个 JAssignStmt 实例。它们代表对方法中变量的赋值。在本例中,我们将 I1.toUpperCase() 的结果赋值给 I2,将 System.out 的值赋值给 $stack3,并将 “Hello, ” + I2 的结果赋值给 $stack4

之后,我们有一个 JInvokeStmt 实例。它表示在 $stack3 上调用 println() 方法,并将 $stack4 的值传递给它。

最后,我们有一个 JReturnVoidStmt 实例,它代表方法末尾的隐式返回。

这是一个非常简单的方法,没有分支或控制语句,但我们可以清楚地看到,该方法所做的所有操作都在这里体现。对于我们在 Java 应用程序中可以实现的任何功能,情况也是如此。

8. 小结

以上是对 SootUp 的简单介绍。这个库还能实现更多功能。下次需要分析 Java 代码时,不妨试试它。

 

下一篇