作者简介:Serpit,Android开发工程师,2023年加入37手游技术部,目前负责国内游戏发行 Android SDK 开发。
前言
由于做需求时,最近遇到了一个apktool反编译时报错,虽然问题简单,在排查解决问题的同时,借此机会顺便学习一下apktool的源码,了解apktool是如何实现反编译的。
关于apktool的使用方式,前面已有文章有相关的介绍,链接如下,感兴趣的同学可以先学习一下~
一个APK是如何诞生的
在看apktool源码之前,我们需要了解一下APK是如何通过我们的项目代码编程一个APK文件的,使得后续了解反编译过程更简单。主要步骤如下:
- 资源处理,利用aapt打包生成res资源文件,生成resources.arsc、R.java和res文件
- 源代码通过编译生成class文件进而转化成dex文件
- 通过apkbuilder工具将resources.arsc、res文件、assets文件和dex文件打包生成apk
- 签名
- 签名后文件对齐
开始阅读源码
由于团队中使用的apktool 2.7.0版本,所以本文阅读的源码也基于2.7.0。源码链接如下:
Main方法
java程序的入口都是从Main方法开始的,因此先来看看Main方法。
java
public static void main(String[] args) throws BrutException {
// headless
System.setProperty("java.awt.headless", "true");
// set verbosity default
Verbosity verbosity = Verbosity.NORMAL;
// cli parser
CommandLineParser parser = new DefaultParser();
CommandLine commandLine;
// load options
_Options();
try {
commandLine = parser.parse(allOptions, args, false);
if (! OSDetection.is64Bit()) {
System.err.println("32 bit support is deprecated. Apktool will not support 32bit on v3.0.0.");
}
} catch (ParseException ex) {
System.err.println(ex.getMessage());
usage();
System.exit(1);
return;
}
// check for verbose / quiet
if (commandLine.hasOption("-v") || commandLine.hasOption("--verbose")) {
verbosity = Verbosity.VERBOSE;
} else if (commandLine.hasOption("-q") || commandLine.hasOption("--quiet")) {
verbosity = Verbosity.QUIET;
}
setupLogging(verbosity);
// check for advance mode
if (commandLine.hasOption("advance") || commandLine.hasOption("advanced")) {
setAdvanceMode();
}
boolean cmdFound = false;
for (String opt : commandLine.getArgs()) {
if (opt.equalsIgnoreCase("d") || opt.equalsIgnoreCase("decode")) {
cmdDecode(commandLine);
cmdFound = true;
} else if (opt.equalsIgnoreCase("b") || opt.equalsIgnoreCase("build")) {
cmdBuild(commandLine);
cmdFound = true;
} else if (opt.equalsIgnoreCase("if") || opt.equalsIgnoreCase("install-framework")) {
cmdInstallFramework(commandLine);
cmdFound = true;
} else if (opt.equalsIgnoreCase("empty-framework-dir")) {
cmdEmptyFrameworkDirectory(commandLine);
cmdFound = true;
} else if (opt.equalsIgnoreCase("list-frameworks")) {
cmdListFrameworks(commandLine);
cmdFound = true;
} else if (opt.equalsIgnoreCase("publicize-resources")) {
cmdPublicizeResources(commandLine);
cmdFound = true;
}
}
// if no commands ran, run the version / usage check.
if (!cmdFound) {
if (commandLine.hasOption("version")) {
_version();
System.exit(0);
} else {
usage();
}
}
}
Main方法不长,主要分为两部分
- 加载命令行
- 匹配命令并执行相应的命令
由于我们关注的是反编译流程,所以找到反编译相关的入口代码 cmdDecode(commandLine)
java
if (opt.equalsIgnoreCase("d") || opt.equalsIgnoreCase("decode")){
cmdDecode(commandLine);
cmdFound = true;
}
顺带说一句,这里也可以根据我们apktool的使用命令 apktool d xxx.apk
来匹配到这一行代码
反编译的准备工作
进入cmdDecode后,代码如下
java
private static void cmdDecode(CommandLine cli) throws AndrolibException {
ApkDecoder decoder = new ApkDecoder();
int paraCount = cli.getArgList().size();
String apkName = cli.getArgList().get(paraCount - 1);
File outDir;
// check for options
if (cli.hasOption("s") || cli.hasOption("no-src")) {
decoder.setDecodeSources(ApkDecoder.DECODE_SOURCES_NONE);
}
if (cli.hasOption("only-main-classes")) {
decoder.setDecodeSources(ApkDecoder.DECODE_SOURCES_SMALI_ONLY_MAIN_CLASSES);
}
if (cli.hasOption("d") || cli.hasOption("debug")) {
System.err.println("SmaliDebugging has been removed in 2.1.0 onward. Please see: https://github.com/iBotPeaches/Apktool/issues/1061");
System.exit(1);
}
if (cli.hasOption("b") || cli.hasOption("no-debug-info")) {
decoder.setBaksmaliDebugMode(false);
}
if (cli.hasOption("t") || cli.hasOption("frame-tag")) {
decoder.setFrameworkTag(cli.getOptionValue("t"));
}
if (cli.hasOption("f") || cli.hasOption("force")) {
decoder.setForceDelete(true);
}
if (cli.hasOption("r") || cli.hasOption("no-res")) {
decoder.setDecodeResources(ApkDecoder.DECODE_RESOURCES_NONE);
}
if (cli.hasOption("force-manifest")) {
decoder.setForceDecodeManifest(ApkDecoder.FORCE_DECODE_MANIFEST_FULL);
}
if (cli.hasOption("no-assets")) {
decoder.setDecodeAssets(ApkDecoder.DECODE_ASSETS_NONE);
}
if (cli.hasOption("k") || cli.hasOption("keep-broken-res")) {
decoder.setKeepBrokenResources(true);
}
if (cli.hasOption("p") || cli.hasOption("frame-path")) {
decoder.setFrameworkDir(cli.getOptionValue("p"));
}
if (cli.hasOption("m") || cli.hasOption("match-original")) {
decoder.setAnalysisMode(true);
}
if (cli.hasOption("api") || cli.hasOption("api-level")) {
decoder.setApiLevel(Integer.parseInt(cli.getOptionValue("api")));
}
if (cli.hasOption("o") || cli.hasOption("output")) {
outDir = new File(cli.getOptionValue("o"));
} else {
// make out folder manually using name of apk
String outName = apkName;
outName = outName.endsWith(".apk") ? outName.substring(0,
outName.length() - 4).trim() : outName + ".out";
// make file from path
outName = new File(outName).getName();
outDir = new File(outName);
}
decoder.setOutDir(outDir);
decoder.setApkFile(new File(apkName));
try {
decoder.decode();
} catch (OutDirExistsException ex) {
System.err
.println("Destination directory ("
+ outDir.getAbsolutePath()
+ ") "
+ "already exists. Use -f switch if you want to overwrite it.");
System.exit(1);
} catch (InFileNotFoundException ex) {
System.err.println("Input file (" + apkName + ") " + "was not found or was not readable.");
System.exit(1);
} catch (CantFindFrameworkResException ex) {
System.err
.println("Can't find framework resources for package of id: "
+ ex.getPkgId()
+ ". You must install proper "
+ "framework files, see project website for more info.");
System.exit(1);
} catch (IOException ex) {
System.err.println("Could not modify file. Please ensure you have permission.");
System.exit(1);
} catch (DirectoryException ex) {
System.err.println("Could not modify internal dex files. Please ensure you have permission.");
System.exit(1);
} finally {
try {
decoder.close();
} catch (IOException ignored) {}
}
}
在这里,逻辑是读取配置,初始化ApkDecoder
并设置一系列传入的参数,最后调用到decode来执行反编操作。
java
decoder.decode();
至此,反编译的前置流程已经结束,进入decode方法后,就是真正的反编译流程了。在开始看反编译前,简单了解一下apk里面都有哪些东西,毕竟apk文件是反编译流程的输入,还是有必要知道的。(大神可以忽略这一环节~)
APK的组成
话不多说,直接上图
可以看到,解压APK后的文件分成:
- AndroidManifest.xml:清单文件
- assets:资源文件,编译的时候不会编译assets中的文件
- lib:三方库存放的地方,一般都是so库存放的位置
- META-INF:包体信息,跟(V1)签名相关的文件会在这里
- res:项目中的res文件目录
- resources.arsc:编译后的二进制资源文件
- classes.dex:源码编译后的dex文件,供Dalvik虚拟机使用
decode
创建反编译的输出路径
java
File outDir = getOutDir();
AndrolibResources.sKeepBroken = mKeepBrokenResources;
if (!mForceDelete && outDir.exists()) {
throw new OutDirExistsException();
}
if (!mApkFile.isFile() || !mApkFile.canRead()) {
throw new InFileNotFoundException();
}
try {
OS.rmdir(outDir);
} catch (BrutException ex) {
throw new AndrolibException(ex);
}
outDir.mkdirs();
LOGGER.info("Using Apktool " + Androlib.getVersion() + " on " + mApkFile.getName());
资源解析
java
if (hasResources()) {
switch (mDecodeResources) {
case DECODE_RESOURCES_NONE:
mAndrolib.decodeResourcesRaw(mApkFile, outDir);
if (mForceDecodeManifest == FORCE_DECODE_MANIFEST_FULL) {
// done after raw decoding of resources because copyToDir overwrites dest files
if (hasManifest()) {
mAndrolib.decodeManifestWithResources(mApkFile, outDir, getResTable());
}
}
break;
case DECODE_RESOURCES_FULL:
if (hasManifest()) {
mAndrolib.decodeManifestWithResources(mApkFile, outDir, getResTable());
}
mAndrolib.decodeResourcesFull(mApkFile, outDir, getResTable());
break;
}
} else {
// if there's no resources.arsc, decode the manifest without looking
// up attribute references
if (hasManifest()) {
if (mDecodeResources == DECODE_RESOURCES_FULL
|| mForceDecodeManifest == FORCE_DECODE_MANIFEST_FULL) {
mAndrolib.decodeManifestFull(mApkFile, outDir, getResTable());
}
else {
mAndrolib.decodeManifestRaw(mApkFile, outDir);
}
}
}
首先判断是否存在resources.arsc
文件,有则判断是否包含清单文件并解码清单文件,然后接着解码resource.arsc。由于先前执行apktool的参数中没有额外的配置,因此默认都会走到DECODE_RESOURCES_FULL
的case中
java
case DECODE_RESOURCES_FULL:
if (hasManifest()) {
mAndrolib.decodeManifestWithResources(mApkFile, outDir, getResTable());
}
mAndrolib.decodeResourcesFull(mApkFile, outDir, getResTable());
break;
至此,处理了两个文件resources.arsc
和AndroidManifest.xml
源码解析
java
if (hasSources()) {
switch (mDecodeSources) {
case DECODE_SOURCES_NONE:
mAndrolib.decodeSourcesRaw(mApkFile, outDir, "classes.dex");
break;
case DECODE_SOURCES_SMALI:
case DECODE_SOURCES_SMALI_ONLY_MAIN_CLASSES:
mAndrolib.decodeSourcesSmali(mApkFile, outDir, "classes.dex", mBakDeb, mApiLevel);
break;
}
}
首先判断是否有class.dex文件,然后执行解码操作,这里有两个情况,分别是DECODE_SOURCES_NONE
对应no-src命令,因为默认输入的命令中无此命令,故先忽略,看下面的case,解码.dex格式的文件。
可以留意一下Androlib的decodeSourcesSmali,这个方法的作用是将dex解析成smali源码。利用的是第三方库dexlib2来处理字节码,最后生成smail文件。
处理完class.dex之后,会继续处理其余dex,调用的还是decodeSourcesSmali
进行处理。
java
if (hasMultipleSources()) {
// foreach unknown dex file in root, lets disassemble it
Set<String> files = mApkFile.getDirectory().getFiles(true);
for (String file : files) {
if (file.endsWith(".dex")) {
if (! file.equalsIgnoreCase("classes.dex")) {
switch(mDecodeSources) {
case DECODE_SOURCES_NONE:
mAndrolib.decodeSourcesRaw(mApkFile, outDir, file);
break;
case DECODE_SOURCES_SMALI:
mAndrolib.decodeSourcesSmali(mApkFile, outDir, file, mBakDeb, mApiLevel);
break;
case DECODE_SOURCES_SMALI_ONLY_MAIN_CLASSES:
if (file.startsWith("classes") && file.endsWith(".dex")) {
mAndrolib.decodeSourcesSmali(mApkFile, outDir, file, mBakDeb, mApiLevel);
} else {
mAndrolib.decodeSourcesRaw(mApkFile, outDir, file);
}
break;
}
}
}
}
}
至此,源码解析阶段处理了所有的class.dex文件。将smail源码解出来放到smali文件夹中
RawFile处理
在处理完dex后,接着处理lib/libs,assets,kotlin这几个文件夹的东西
java
mAndrolib.decodeRawFiles(mApkFile, outDir, mDecodeAssets);
public void decodeRawFiles(ExtFile apkFile, File outDir, short decodeAssetMode)
throws AndrolibException {
LOGGER.info("Copying assets and libs...");
try {
Directory in = apkFile.getDirectory();
if (decodeAssetMode == ApkDecoder.DECODE_ASSETS_FULL) {
if (in.containsDir("assets")) {
in.copyToDir(outDir, "assets");
}
}
if (in.containsDir("lib")) {
in.copyToDir(outDir, "lib");
}
if (in.containsDir("libs")) {
in.copyToDir(outDir, "libs");
}
if (in.containsDir("kotlin")) {
in.copyToDir(outDir, "kotlin");
}
} catch (DirectoryException ex) {
throw new AndrolibException(ex);
}
}
这部分代码还是相当简单的,就是纯拷贝出去的逻辑
处理未知文件
在Androlib中定义了正常的APK文件会有哪些文件
java
private final static String[] APK_STANDARD_ALL_FILENAMES = new String[] {
"classes.dex", "AndroidManifest.xml", "resources.arsc", "res", "r", "R",
"lib", "libs", "assets", "META-INF", "kotlin" };
java
mAndrolib.decodeUnknownFiles(mApkFile, outDir);
public void decodeUnknownFiles(ExtFile apkFile, File outDir)
throws AndrolibException {
LOGGER.info("Copying unknown files...");
File unknownOut = new File(outDir, UNK_DIRNAME);
try {
Directory unk = apkFile.getDirectory();
// loop all items in container recursively, ignoring any that are pre-defined by aapt
Set<String> files = unk.getFiles(true);
for (String file : files) {
if (!isAPKFileNames(file) && !file.endsWith(".dex")) {
// copy file out of archive into special "unknown" folder
unk.copyToDir(unknownOut, file);
// lets record the name of the file, and its compression type
// so that we may re-include it the same way
mResUnknownFiles.addUnknownFileInfo(file, String.valueOf(unk.getCompressionLevel(file)));
}
}
} catch (DirectoryException ex) {
throw new AndrolibException(ex);
}
}
除了定义的文件,其余出现的文件,会被定义为unknown文件。这些文件会被复制到unknown文件夹中,并记录文件名和压缩类型,方便后续可以用相同的方式获取到它们
处理META-INF&记录信息
至此,还剩META-INF文件外,其他都得到了处理。但源码中还有一个记录的流程
java
mUncompressedFiles = new ArrayList<>();
mAndrolib.recordUncompressedFiles(mApkFile, mUncompressedFiles);
mAndrolib.writeOriginalFiles(mApkFile, outDir);
writeMetaFile();
首先,recordUncompressedFiles
记录未压缩的文件,这里的作用主要是将未压缩的文件记录到apktool.yml中的doNotCompress字段中。
然后,writeOriginalFiles
中会将META-INF文件拷贝出去。
java
public void writeOriginalFiles(ExtFile apkFile, File outDir)
throws AndrolibException {
LOGGER.info("Copying original files...");
File originalDir = new File(outDir, "original");
if (!originalDir.exists()) {
originalDir.mkdirs();
}
try {
Directory in = apkFile.getDirectory();
if (in.containsFile("AndroidManifest.xml")) {
in.copyToDir(originalDir, "AndroidManifest.xml");
}
if (in.containsFile("stamp-cert-sha256")) {
in.copyToDir(originalDir, "stamp-cert-sha256");
}
if (in.containsDir("META-INF")) {
in.copyToDir(originalDir, "META-INF");
if (in.containsDir("META-INF/services")) {
// If the original APK contains the folder META-INF/services folder
// that is used for service locators (like coroutines on android),
// copy it to the destination folder so it does not get dropped.
LOGGER.info("Copying META-INF/services directory");
in.copyToDir(outDir, "META-INF/services");
}
}
} catch (DirectoryException ex) {
throw new AndrolibException(ex);
}
}
最后,writeMetaFile
生成apktool.yml,具体对应的写入逻辑下方有备注
java
private void writeMetaFile() throws AndrolibException {
MetaInfo meta = new MetaInfo();
meta.version = Androlib.getVersion();
meta.apkFileName = mApkFile.getName();
if (mResTable != null) {
meta.isFrameworkApk = mAndrolib.isFrameworkApk(mResTable);
putUsesFramework(meta); //对应usesFramework字段
putSdkInfo(meta); //对应sdkInfo字段
putPackageInfo(meta); //对应packageInfo字段
putVersionInfo(meta); //对应versionInfo字段
putSharedLibraryInfo(meta); //对应sharedLibrary字段
putSparseResourcesInfo(meta); //对应sparseResources字段
} else {
putMinSdkInfo(meta);
}
putUnknownInfo(meta);
putFileCompressionInfo(meta);
mAndrolib.writeMetaFile(mOutDir, meta);
}
放一个apktool.yml的样例
yml
!!brut.androlib.meta.MetaInfo
apkFileName: demo.apk
compressionType: false
doNotCompress:
- resources.arsc
- png
- mp4
- ogg
- assets/cache/https.__res.xxh5.z7xz.com_xxh5dev/assetsid.txt
- assets/cache/https.__res.xxh5.z7xz.com_xxh5dev/filetable.bin
isFrameworkApk: false
packageInfo:
forcedPackageId: '127'
renameManifestPackage: null
sdkInfo:
minSdkVersion: '19'
targetSdkVersion: '26'
sharedLibrary: false
sparseResources: false
unknownFiles:
xxxhttp/okhttp3/internal/publicsuffix/publicsuffixes.gz: '0'
usesFramework:
ids:
- 1
tag: null
version: 2.7.0
versionInfo:
versionCode: '1055'
versionName: 9.6.4
小结
可以看到,apktool的源码还是很简单明了的,反编译的主流程就是: 读取命令参数 -> 生成ApkDecoder -> 反编译开始 -> 逐步处理各个文件夹/文件并输出到目标文件夹中 -> 生成apktool.yml
值得一提的是,每个文件夹/文件的处理核心逻辑都在Androlib
类中,包括但不限于dex转smail的封装、清单文件解析等。但本文的主旨只是领略apktool的反编译流程,未深入涉及到具体的每种文件处理细节,后续会针对某种文件细节会出相应的博客分析,敬请期待~
makefile
最后的最后:
我们是37手游移动客户端开发团队,致力于为游戏行业提供高质量的SDK开发服务。
欢迎关注我们,了解更多移动开发和游戏 SDK 技术动态~
技术问题/交流/进群等可以加官方微信 MobileTeam37