ProGuard 进阶系列(三) Java 类文件解析

书接上文,当我们从用户的配置文件中读取到所有的配置信息后,下一步便是将配置中的指定的类文件进行读取,构建需要混淆的 Java 类文件的语法树。在阅读类文件之前,先来看一下输入输出参数中的内容,我使用的是一个 Android 项目的输出产物,使用 -injars-outjars-libraryjars 指定了相关的信息,运行起来,这些信息会放到 Configuration 中,具体信息看下图:

programJars 与 libraryJars

Java 代码源文件在编译后会转换成 Class 文件,格式定义是固定的,可以使用 ASM 等开源工具进行读取和解析,本文将分析 ProGuard 中,是如何进行类文件读取的。

让我们把目光拉回到 ProGuard 的 main 方法中:

ProGuard 的 Main 函数代码

从代码中可以看到,配置信息解析结束后,就会执行 ProGuard 的 execute 方法。继续执行下去,除去一些前置校验的操作,下一步便是本文关注的 readInput,读取 Class 文件的内容。

private void readInput() throws Exception {
  // Fill the program class pool and the library class pool.
  passRunner.run(new InputReader(configuration), appView);
}

在这几行代码中,有几个信息:passRunnerInputReaderappView

先来看passRunner,它只有很少的几行代码:

public class PassRunner {
    private static final Logger logger = LogManager.getLogger(PassRunner.class);
    private final Benchmark benchmark = new Benchmark();

    public void run(Pass pass, AppView appView) throws Exception {
        benchmark.start();
        pass.execute(appView);
        benchmark.stop();

        logger.debug("Pass {} completed in {}", pass::getName, () -> TimeUtil.millisecondsToMinSecReadable(benchmark.getElapsedTimeMs()));
    }
}

当执行 run 方法是,会执行 pass.execute 方法,并且记录其执行时间。

其次是 appView,它是一个 POJO 类,主要用来存储类信息和资源信息。

InputReader 就是本文的重点,顾名思义,它是用来读取输入信息的,就是用来读取文章开头提到的 libraryJarsprogramJars

  • libraryJars ,指的是依赖库,如在 Android 中使用的 Android SDK ,Support 包等依赖库
  • programJars,指的是我们自己编写的代码,要进行混淆的目标类文件

虽然此处叫 jar ,但其本质上不仅支持 jar 文件,还支持文件夹、war 等各种格式。

一、文件的读取

在文章的开头,programJars 里面有两个 ClassPathEntry, 分别指向了 R.jar 文件和 classes 文件夹。在 InputReaderexecute 方法中,我们先跳过 ClassReader 相关的创建逻辑,直接来看 readInput 方法:

// InputReader.java, 省略不相关的代码
private void readInput(String messagePrefix, ClassPathEntry classPathEntry, DataEntryReader dataEntryReader) throws IOException {
    try {
      
        DataEntryReader reader = new DataEntryReaderFactory(configuration.android).createDataEntryReader(classPathEntry, dataEntryReader);

        DataEntrySource source = new DirectorySource(classPathEntry.getFile());

        source.pumpDataEntries(reader);
    } catch (IOException ex) {
        throw new IOException("Can't read [" + classPathEntry + "] (" + ex.getMessage() + ")", ex);
    }
}

此方法的入参 dataEntryReader 就是用于读取 Class 的实现,后面会讲到。从 readInput 的方法实现中可以看到,需要先创建 reader,代码中此处使用了工厂模式。先来回忆一下工厂模式:

工厂模式是一种创建型设计模式,它通过委托给一个工厂类来实例化对象,而不是直接使用 new 关键字。这一模式可以避免调用方的复杂性,提供一个抽象的接口来创建实例,让调用方不必关心创建对象的细节。使用工厂模式可以提高代码复用性,更容易维护代码,让调用方只关注业务逻辑实现细节。

DataEntryReaderFactory 中将 DataEntryReader 的创建过程封装起来,调用的时候,不需要感知创建的过程。前面提到了,programJars 支持多种格式,如 apkaabjar 等,所以在工厂方法里面会根据文件后缀名去创建不同类型的 DataEntryReader ,代码如下:

public DataEntryReader createDataEntryReader(ClassPathEntry classPathEntry, DataEntryReader reader) {
    // 省略部分代码
    // Unzip any apks, if necessary.
    reader = wrapInJarReader(reader, false, false, isApk, apkFilter, ".apk");
    if (!isApk) {
        // Unzip any aabs, if necessary.
        reader = wrapInJarReader(reader, false, false, isAab, aabFilter, ".aab");
        if(!isAab) {
            // Unzip any jars, if necessary.
            reader = wrapInJarReader(reader, false, false, isJar, jarFilter, ".jar");
            // 省略部分代码
        }
    }
    return reader;
}
private DataEntryReader wrapInJarReader(DataEntryReader reader,
                                        boolean stripClassesPrefix,
                                        boolean stripJmodHeader,
                                        boolean isJar,
                                        List<String> jarFilter,
                                        String jarExtension) {
    // 不管当前格式是什么,直接创建 JarReader
  DataEntryReader jarReader = new JarReader(stripJmodHeader, reader);

  if (isJar) {
    // 如果当前需要读取的文件格式是对应后缀格式,直接返回 
    return jarReader;
  } else {
    // 创建一个后缀匹配器
    StringMatcher jarMatcher = new ExtensionMatcher(jarExtension);
        // 返回一个格式判断的 Reader
    return new FilteredDataEntryReader(
      new DataEntryNameFilter(jarMatcher),
      jarReader,
      reader);
  }
}

在代码中,创建 reader 的时候构建了一个嵌套的结构,此处以 Android 项目中,生成的 R.jar 文件为例,其执行创建过程如下(执行路径参考红色部分):

Reader 创建流程

为了验证最后的产物结构,调试可以查看最终生成的 DataEntryReader 的结构信息截图如下, 可以与上面创建的图进行对照理解:

DataEntryReader 示例

当你理解 Reader 的创建逻辑后,可能会有和我一样的困惑,为什么此处需要使用嵌套的结构呢?既然已经知道文件格式了,为什么不直接创建对应的 JarReader 呢?按照我个人的理解,代码可能会这样子写:

// 此处非项目中源代码,仅个人思路。
public DataEntryReader createDataEntryReader(ClassPathEntry classPathEntry, DataEntryReader reader) {
    // 省略部分代码
    // Unzip any apks, if necessary.
    if (isApk) {
        reader = wrapInJarReader(reader, false, false, isApk, apkFilter, ".apk");
    } else if(isAab) {
        reader = wrapInJarReader(reader, false, false, isAab, aabFilter, ".aab");
    } else if (isJar) {
        reader = wrapInJarReader(reader, false, false, isJar, jarFilter, ".jar");
    }
    // 省略部分代码
    return reader;
}

但经过多方查证,在读取文件的时候,可能会出现嵌套的问题,拿 Android 来说,在 aar 格式的文件中,会存在有 jar 格式的文件 classes.jar , 示例如下:

example.aar 文件列表

因此,在读取内容的时候,还需要一个可以读取 jar 文件的 Reader 。虽然源代码中那样写可以正常执行逻辑,但我觉得它可能还是不够优雅,也许是我没有看懂原作者的用意,如你对此有不同的理解,欢迎与我交流。

继续回到源代码,当 Reader 创建成功后,会直接调用 sourcepumpDataEntries 方法,实现文件解析与类文件读取,从源码中可以看到,在 pumpDataEntries 中,是直接调用前面使用工厂模式创建出来的 Reader 实例中的 read 方法:

public void pumpDataEntries(DataEntryReader dataEntryReader) throws IOException {
    readFiles(directory, dataEntryReader);
}

private void readFiles(File file, DataEntryReader dataEntryReader) throws IOException {
    // 直接调用 read 方法
    dataEntryReader.read(new FileDataEntry(directory, file));
    // 如果是文件夹,则遍历读取所有的子文件
    if (file.isDirectory()) {
        File[] listedFiles = file.listFiles();
        for (int index = 0; index < listedFiles.length; index++) {
            File listedFile = listedFiles[index];
            readFiles(listedFile, dataEntryReader);
        }
    }
}

在前面的例子中,传入的 jar 文件,最后返回的 Reader 就是 JarReader, 而它会将传入的文件进行解压读取,并使用 dataEntryReader 去读取压缩包中的其它文件,代码如下:

public void read(DataEntry dataEntry) throws IOException {
    // 省略部分代码
    FileDataEntry fileDataEntry = (FileDataEntry)dataEntry;
    // 处理 zip 文件
    ZipFile zipFile = new ZipFile(fileDataEntry.getFile(), StandardCharsets.UTF_8);
    try {
        Enumeration entries = zipFile.entries();
        // 读取压缩包中的所有文件
        while (entries.hasMoreElements()) {
            ZipEntry zipEntry = (ZipEntry)entries.nextElement();
            // 转换成真实的 reader 去读取类容。
            dataEntryReader.read(new ZipFileDataEntry(dataEntry, zipEntry, zipFile));
        }
    } finally {
        zipFile.close();
    }
    // 省略部分代码
}

在本例中,R.jar 文件包含的内容如下图所示:

R.jar 文件内容

当读取到此文件的第一个 ZipEntrycom/example/demo/R$style.class 文件时,源码会调用dataEntryReader 去读取内容,根据前面创建Reader 的流程,可以知道当前的 dataEntryReaderFilteredDataEntryReader ,它在执行读取时,会根据当前文件的后缀名去处理,如果后缀名匹配, 则会使用 acceptedDataEntryReader 去处理,反之会使用 rejectedDataEntryReader 去读取文件:

FilteredDataEntryReader 读取

因此,com/example/demo/R$style.class 文件的读取会一直嵌套调用,直到可以处理 class 文件的 ClassReader ,文件读取逻辑如下(图中红色部分):

文件读取嵌套逻辑

二、CLASS 文件的读取与解析

在之前的文章 《深入 Android 混淆实践:多模块打包爬坑之旅 》中,使用了 ASM 去解析 class 文件,而在 ProGuard 中,自己实现了一套,源代码在开源库 proguard-core 中。

Java 的 class 文件格式在 JVM 规范中,有明确的定义,不论是在开源库 ASM 中,还是在 proguard-core 中,实现对 class 文件的读取与处理,都使用了访问者模式,有关访问者模式,将在后面的文章进行详细的讲解。下面在来看看 ClassReader 里面干了些什么事情。

public void read(DataEntry dataEntry) throws IOException {
    try {
        // 获取当前数据流
        InputStream inputStream = dataEntry.getInputStream();

        // 在包一层,使用 DataInputStream
        DataInputStream dataInputStream = new DataInputStream(inputStream);
                // 创建 ProgramClass
        Clazz clazz = new ProgramClass();
        // 创建访问者 ProgramClassReader
        ClassVisitor programClassReader = new ProgramClassReader(
                dataInputStream,
                ignoreStackMapAttributes
        );
        
          // 调用 accept 方法,实现派发,让 programClassReader 执行 visitProgramClass 方法
        clazz.accept(programClassReader);
        

        // 如果解析 class 成功
        String className = clazz.getName();
        if (className != null) {
            // 省略部分代码
              // 用过 Visistor 模式,将 ProgramClass 添加到 AppView 的 programClassPool 中
            clazz.accept(classVisitor);
        }
        dataEntry.closeInputStream();
    } catch (Exception ex) {
                // ......
    }
}

代码中,通过访问者模式,触发 ProgramClassReader 从输入数据流中读取 class 文件的内容。此处读取的逻辑相对比较简单,按照 class 格式定义,按字节读取就可以了。我将 CLASS 格式的定义和读取代码做了一个截图,可以对比看看,加深理解。

class文件读取逻辑,左测为读取代码,右测为代码结构

结语

在本文的开头,提到的 AppView 就是用来存储类数据的,代码逻辑会将输入参数中的 programJarslibraryJars 里包含的所有类解析出来,构建生成 ProgramClass,分别存储在 AppView 这个类中的 programClassPool 以及libraryClassPool 中, 用于后续混淆使用。当然,除了 class 文件,还存在一些资源文件的读取逻辑,如果你感兴趣,可以去翻翻源码。

以上为 ProGuard 中 Java 类文件的读取与解析的内容,如果本文中有描述得不清楚或不对的地方,欢迎各位朋友一起交流讨论。