ProGuard 进阶系列(三) Java 类文件解析
书接上文,当我们从用户的配置文件中读取到所有的配置信息后,下一步便是将配置中的指定的类文件进行读取,构建需要混淆的 Java 类文件的语法树。在阅读类文件之前,先来看一下输入输出参数中的内容,我使用的是一个 Android 项目的输出产物,使用 -injars
、-outjars
、-libraryjars
指定了相关的信息,运行起来,这些信息会放到 Configuration
中,具体信息看下图:
Java 代码源文件在编译后会转换成 Class 文件,格式定义是固定的,可以使用 ASM 等开源工具进行读取和解析,本文将分析 ProGuard 中,是如何进行类文件读取的。
让我们把目光拉回到 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);
}
在这几行代码中,有几个信息:passRunner
、InputReader
和 appView
先来看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
就是本文的重点,顾名思义,它是用来读取输入信息的,就是用来读取文章开头提到的 libraryJars
和 programJars
:
libraryJars
,指的是依赖库,如在 Android 中使用的 Android SDK ,Support 包等依赖库programJars
,指的是我们自己编写的代码,要进行混淆的目标类文件虽然此处叫 jar
,但其本质上不仅支持 jar 文件,还支持文件夹、war 等各种格式。
在文章的开头,programJars
里面有两个 ClassPathEntry
, 分别指向了 R.jar
文件和 classes
文件夹。在 InputReader
的 execute
方法中,我们先跳过 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
支持多种格式,如 apk
、aab
、jar
等,所以在工厂方法里面会根据文件后缀名去创建不同类型的 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
文件为例,其执行创建过程如下(执行路径参考红色部分):
为了验证最后的产物结构,调试可以查看最终生成的 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
, 示例如下:
因此,在读取内容的时候,还需要一个可以读取 jar
文件的 Reader
。虽然源代码中那样写可以正常执行逻辑,但我觉得它可能还是不够优雅,也许是我没有看懂原作者的用意,如你对此有不同的理解,欢迎与我交流。
继续回到源代码,当 Reader
创建成功后,会直接调用 source
的 pumpDataEntries
方法,实现文件解析与类文件读取,从源码中可以看到,在 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
文件包含的内容如下图所示:
当读取到此文件的第一个 ZipEntry
: com/example/demo/R$style.class
文件时,源码会调用dataEntryReader
去读取内容,根据前面创建Reader
的流程,可以知道当前的 dataEntryReader
是 FilteredDataEntryReader
,它在执行读取时,会根据当前文件的后缀名去处理,如果后缀名匹配, 则会使用 acceptedDataEntryReader
去处理,反之会使用 rejectedDataEntryReader
去读取文件:
因此,com/example/demo/R$style.class
文件的读取会一直嵌套调用,直到可以处理 class
文件的 ClassReader
,文件读取逻辑如下(图中红色部分):
在之前的文章 《深入 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
格式的定义和读取代码做了一个截图,可以对比看看,加深理解。
在本文的开头,提到的 AppView
就是用来存储类数据的,代码逻辑会将输入参数中的 programJars
和 libraryJars
里包含的所有类解析出来,构建生成 ProgramClass
,分别存储在 AppView
这个类中的 programClassPool
以及libraryClassPool
中, 用于后续混淆使用。当然,除了 class
文件,还存在一些资源文件的读取逻辑,如果你感兴趣,可以去翻翻源码。
以上为 ProGuard 中 Java 类文件的读取与解析的内容,如果本文中有描述得不清楚或不对的地方,欢迎各位朋友一起交流讨论。