Apktool 源码分析那些一定要懂的细节(上篇)
前言
看到这篇技术文章皆是缘分。本人在一家研运一体的游戏公司做安卓游戏SDK,并不是安卓逆向从业人员。工作中经常使用Apktool工具,写这一篇技术文纯粹是好奇心作祟,好奇这东西是什么原理,怎么做到把Apk拆解成最原始的样子。
Apktool它是一个开源的逆向工具,Java写出来的。多么强大我就不多说,能找到这里说明你应该知道它是做什么的。在写文之前看过很多,也搜过很多技术大佬的文章,分析的过程并没有让我的好奇心得到满足。于是我把源码Clone下来分析一下。单纯的记录一下分析过程还有产生的疑问,没准那一次忘了,回头看看自己写的文章会有不同的感受。
Apk的构建流程
新版官网Apk构建流程(常识部分)
构建流程涉及许多将项目转换成 Android 应用软件包 (APK) 的工具和流程。构建流程非常灵活,因此了解它的一些底层工作原理会很有帮助。(熟悉流程的可以忽略此部分,继续往下观看)
Apk新版构建过程.pngAndroid 应用模块的构建流程如上图所示,按照如上常规步骤执行:(如下步骤均来源于安卓官网,我只是做一个搬运工)
-
编译器将您的源代码转换成 DEX 文件(Dalvik 可执行文件,其中包括在 Android 设备上运行的字节码),并将其他所有内容转换成编译后的资源。
-
APK 打包器将 DEX 文件和编译后的资源组合成单个 APK。不过,必须先为 APK 签名,然后才能将应用安装并部署到 Android 设备上。
-
APK 打包器使用调试或发布密钥库为 APK 签名:
-
如果你构建的是调试版应用(即专用于测试和分析的应用),则打包器会使用调试密钥库为应用签名。Android Studio 会自动使用调试密钥库配置新项目。
-
如果你构建的是打算对外发布的发布版应用,则打包器会使用发布密钥库为应用签名。
-
-
在生成最终 APK 之前,打包器会使用 zipalign 工具对应用进行优化,以减少其在设备上运行时所占用的内存。
老版官网Apk构建流程(了解部分)
安卓官网只能找到新版本的apk构建流程,老版本的流程图在官网找了半天也没找到。于是求助了一下Google大佬。找到了老版本的流程图,下图就是老版本的apk构建流程。(熟悉流程的可以忽略此部分,继续往下观看)
Apk老版构建过程.png上图可以分为七个大模块:
-
处理资源相关文件,生成R.java: aapt来打包res资源文件,生成R.java、resources.arsc和res文件
-
处理aidl文件,生成相应的Java文件 :aidl工具解析接口定义文件然后生成相应的Java代码接口供程序调用
-
编译项目源代码,生成class文件 :Java Compiler阶段。项目中所有的Java代码,包括R.java和.aidl文件,javac编译成.class文件
-
转换所有的class文件,生成classes.dex文件 :通过dx工具,将.class文件和第三方库中的.class文件处理生成classes.dex文件。(新版已经被d8代替)
-
打包生成APK文件 :通过apkbuilder工具,将aapt生成的resources.arsc和res文件、assets文件和classes.dex一起打包生成apk
-
对APK文件进行签名 :通过Jarsigner工具,对上面的apk进行debug或release签名。(新版已经被apksigner代替)
-
对签名后的APK文件进行对齐处理 :通过zipalign工具,将Android 应用 (APK) 文件提供重要的优化。其目的是要确保所有未压缩数据的开头均相对于文件开头部分执行特定的对齐。具体来说,它会使 APK 中的所有未压缩数据(例如图片或原始文件)在 4 字节边界上对齐。
敲重点:明明要说Apktool 源码?怎么单独写了一章无关紧要的apk构建流程?这并不冲突,只是做了一个铺垫。首先你要知道apk是如何构建,知道如何构建(怎么来的)才能更好理解Apktool逆向。而这一章节最关键的就是AAPT概念。后续源码中解码和构建都离不开aapt工具。那AAPT是什么呢,我做下简单介绍:
AAPT(Android 资源打包工具)是一种构建工具,Android Studio 和 Android Gradle 插件使用它来编译和打包应用的资源。AAPT 会解析资源、为资源编制索引,并将资源编译为针对 Android 平台进行过优化的二进制格式。
Apktool源码解析
本次演示的源码为最新版v2.5.1 Master,大家有兴趣的话可以看下我以前分享过的文章 Apktool源码下载与编译
Apktool工程项目介绍(了解部分,不感兴趣的往下看)
Apktool源码下载我这里就不做演示,下图就是下载完成后的文件夹结构图
apktool文件夹结构.pngApktool是java项目同时也是gradle项目,下图是项目引用的第三方库
//全局自定义部分
ext {
depends = [
baksmali: 'org.smali:baksmali:2.4.0',
commons_cli: 'commons-cli:commons-cli:1.4',
commons_io: 'commons-io:commons-io:2.4',
commons_lang: 'org.apache.commons:commons-lang3:3.1',
guava: 'com.google.guava:guava:14.0',
junit: 'junit:junit:4.12',
proguard_gradle: 'com.guardsquare:proguard-gradle:7.0.0',
snakeyaml: 'org.yaml:snakeyaml:1.18:android',
smali: 'org.smali:smali:2.4.0',
xmlpull: 'xpp3:xpp3:1.1.4c',
xmlunit: 'xmlunit:xmlunit:1.6',
]
}
//依赖部分
api project(':brut.j.dir'),
project(':brut.j.util'),
project(':brut.j.common')
implementation depends.baksmali,
depends.smali,
depends.snakeyaml,
depends.xmlpull,
depends.guava,
depends.commons_lang,
depends.commons_io
根据上面的第三方库重要的做下简单介绍
-
baksmali :是dalvik(Android的Java VM实现)使用的dex格式的汇编程序/反汇编程序,项目中dex文件解析靠的就是它
-
commons_cli : Apache开源组织提供的用于解析命令行参数的库
-
snakeyaml :配置文件解释器。对应就是反编译后的apktool.yml文件
-
guava : Google的 Java项目广泛依赖 的核心库
-
xmlpull : xml解析框架,项目中用于解析清单文件还有xml等
Apktool工程结构图
Apktool工程结构.png针对上图的结构做下说明
-
brut.apktool : apktool核心工程
-
apktool-cli :这部分的工程只有一个main.java 程序的主入口
-
apktool-lib : 这部分的工程内容最为丰富,包含了apktool中所有的命令 和交互调用的类,还有项目自带的三大系统的aapt和aapt2工具,同时还包含系统框架android-framework.jar
-
-
brut.j.common : 有关异常定义和处理的工具类,服务于brut.apktool.
-
brut.j.dir : 有关文件方面工具类,服务于brut.apktool .
-
brut.j.util :工程通用工具类,服务于brut.apktool .
主函数Main逻辑(重要部分)
既然是Java项目程序入口肯定是Main(),可得知入口类为brut.apktool.Main。还有一种方式我们可以通过生成的jar包来查找程序入口(既然下载了源码,这种方式不建议了。)
public static void main(String[] args) throws IOException, InterruptedException, BrutException {
//通俗的解释这句话的意思就是:开启了headless模式,就不要指望硬件帮忙,你需要自立更生,依靠系统的计算能力模拟出这些特性
System.setProperty("java.awt.headless", "true");
//设置为默认模式
Verbosity verbosity = Verbosity.NORMAL;
//创建cli命令行解释器
CommandLineParser parser = new DefaultParser();
CommandLine commandLine;
// 加载命令行选项
_Options();
try {
//参数1:全局option 用来遍历参数是否包含此选项
// 参数2:解析命令行输入的参数
// 参数3:布尔类型的参数,意思无法识别的参数将抛出异常(可以去看源码参数描述)
commandLine = parser.parse(allOptions, args, false); //根据指定的选项解析参数
if (! OSDetection.is64Bit()) { //如果不是64位操作系统 抛出异常
System.err.println("32 bit support is deprecated. Apktool will not support 32bit on v2.6.0.");//简单说就是不支持32位系统
}
} catch (ParseException ex) { //出现解析异常打印帮助消息
System.err.println(ex.getMessage());
usage(); //出现异常则打印帮助信息
System.exit(1); //异常下退出程序
return;
}
/**
* 检查命令行是否有详细模式或者静默模式命令
* hasOption() 判断是否含有指定的参数,判断命令行是否出现该命令。出现则返回true,否则返回false
*/
if (commandLine.hasOption("-v") || commandLine.hasOption("--verbose")) {
verbosity = Verbosity.VERBOSE; //详细模式展示命令行
} else if (commandLine.hasOption("-q") || commandLine.hasOption("--quiet")) {
verbosity = Verbosity.QUIET; //详细模式展示命令行
}
setupLogging(verbosity); //设置日志模式
//检测高级模式,如果输入的命令是apktool advance或者apktool advanced 那么就设置成高级选项模式
if (commandLine.hasOption("advance") || commandLine.hasOption("advanced")) {
setAdvanceMode();
}
boolean cmdFound = false;
for (String opt : commandLine.getArgs()) {
//例如 执行apktool d or decode xx.apk时 命令行参数大小写都可以
//equalsIgnoreCase() 方法用于将字符串与指定的对象比较,不考虑大小写。
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;
}
}
//如果未运行任何命令,请运行aktool -version 命令检查使用情况
if (!cmdFound) {
if (commandLine.hasOption("version")) { //如果输入命令 apktool -version 展示apktool版本号
_version();
System.exit(0);
} else {
usage(); //展示参数详细用法
}
}
}
//省略部分。。。。。
上述代码为主函数中部分代码截取。为什么截取部分代码?首先Main.java代码量加上注释大约有800行代码,放上来占地不说而且看着乱。然而我觉得这样做没必要,代码大家都能下载,能进来的都是技术,有谁又看不懂呢?个人觉得主要来分析一下大体的逻辑还有一些细节,这很重要!!!
主函数(Main.java)做了什么逻辑,我从上到下给大家梳理一下
-
设置模式选项 。 针对的是日志级别打印。其中包括三项模式 默认模式(NORMAL) 、详细模式(VERBOSE) 、 静默模式(QUIET)
-
创建cli命令行解析器。包括定义阶段(创建命令行选项)、解析阶段(解析命令行参数)、询问阶段(判断命令行出现了哪个选项)、 进阶部分(获得参数值)、帮助信息等。我笼统的说了一下,英语好的同学可以去Apache Commons CLI官网去看Doc,英语不好的可以Google搜索一下文档语法。
- 自定义异常 ,针对可能出现的问题捕获异常。
-
创建Apktool 五种常规的用法(附上对应的方法名)
-
解码: cmdDecode(CommandLine cli)
-
构建 : cmdBuild(CommandLine cli)
-
安装框架: cmdInstallFramework(CommandLine cli)
-
清除框架目录 : cmdEmptyFrameworkDirectory(CommandLine cli)
-
列出框架目录(v2.5.0新增): cmdListFrameworks(CommandLine cli)
-
其实上述五种常规用法,最核心的就是解码(反编译)和构建(回编),余下的属于附属用法。重点说一下解码和构建的逻辑 。至于框架是什么?往下看会解释。apktool设定的命令可以看一下我之前发过的博客、Apktool命令大全。一定要看 ,与下面代码讲解有关联,方便大家理解。
帮助文档(了解部分)
如下截图相信大家非常熟悉,这一部分单独写出来纯属很经典。这部分是描述apktool的帮助文档,只要输入apktool即可展示。详细可以看下代码usage()方法
帮助文档.png解码调用逻辑
下面就是解码部分代码。传入命令行获的值、实例解码核心类ApkDecoder 、设置解码命令参数、创建输出目录、最终调用到decoder.decode()
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;
//.........省略部分
String outName = apkName; //使用apk名称创建一个新文件夹
outName = outName.endsWith(".apk") ? outName.substring(0,
outName.length() - 4).trim() : outName + ".out";
//从路径创建文件
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: "
+ String.valueOf(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) {}
}
Apk文件结构(了解部分,铺垫下文)
开篇简述了Apk文件是怎么来的。这部分章节简述一下Apk文件结构,这有助对下文的理解,熟悉这部分可以跳过。
APK是AndroidPackage的缩写,即Android安装包(apk)。APK是类似Symbian Sis或Sisx的文件格式。通过将APK文件直接传到Android模拟器或Android手机中执行即可安装。
apk文件和sis一样,把android sdk编译的工程打包成一个安装程序文件,格式为apk。APK文件其实是zip格式,但后缀名被修改为apk,通过UnZip解压后,可以看到如下:
apk文件结构.png-
assets :存放资源文件,系统在编译的时候不会编译assets下的资源文件;
-
lib或者libs :用来存放三方库的地方;
-
META-INF :描述包信息的目录;
-
res :项目中的资源文件夹;
-
AndroidManifest.xml :功能清单文件;
-
classes.dex :包含所有class的文件,供DVM执行;
-
resources.arsc :编译后的二进制资源文件;
ApkDecoder类中的解码逻辑(耐心读完)
粗略看下整体的逻辑然后在细扒。先从hasResources()解析资源文件判断条件成立看起,hasResources()上面代码我就不说了注释非常齐全。如何判断条件是否成立呢? 逻辑很简单,该文件夹里面是否包含resources.arsc(上一章已经讲述过resources.arsc是什么)。继续往下看,有两个判断值 DECODE_RESOURCES_NONE(不需要解码资源)和DECODE_RESOURCES_FULL(完整的解码资源)。
/**
* 执行反编译主逻辑
* @throws AndrolibException
* @throws IOException
* @throws DirectoryException
*/
public void decode() throws AndrolibException, IOException, DirectoryException {
try {
//获取输出目录。执行的逻辑:如果指定了具体路径 (-o命令)用指定目录,没有指定就用默认目录。可看下Main代码.setOutDir()
File outDir = getOutDir();
AndrolibResources.sKeepBroken = mKeepBrokenResources; //keep-broken-res跟这个命令有关 ,可以看下对应引用关系
if (!mForceDelete && outDir.exists()) { //如果当前反编译apk名称目录存在 ,抛出异常
throw new OutDirExistsException();
}
if (!mApkFile.isFile() || !mApkFile.canRead()) { //找不到反编译apk文件或apk文件不可读 抛异常 对应的可以去看Main.java
throw new InFileNotFoundException();
}
try {
OS.rmdir(outDir); // 如果文件夹不存在return,存在递归删除
} catch (BrutException ex) {
throw new AndrolibException(ex);
}
outDir.mkdirs(); //创建多层文件夹
LOGGER.info("Using Apktool " + Androlib.getVersion() + " on " + mApkFile.getName());
先说DECODE_RESOURCES_NONE(不需要解码资源)判断值,正常我们输入apktool d xx.apk没有额外命令时候一定不会走这个条件值,只有加入了no-res命令才会执行。实例Androlib对象(decodeResourcesRaw方法),既然不解码资源那么直接复制"resources.arsc", "AndroidManifest.xml", "res"三个文件里的内容到指定文件夹里。如果在额外加入了force-manifest解析清单文件命令,则会加一层判断,如果文件中包含清单文件,才能真正执行解码清单文件的逻辑。
再说下DECODE_RESOURCES_FULL(完整解码资源文件)判断值,正常我们输入apktool d xx.apk一定会走这个条件值。实例Androlib对象,这个条件值只做了两件事 ,第一件 判断文件中是否包含清单文件,有的话直接解码清单文件。第二件就是执行解码resource.arsc的逻辑。
接着看hasResources()解析资源文件不成立(else部分)执行的逻辑.主要的逻辑是针对没有resources.arsc情况,并且没有属性引用的文件。还要说一下,decodeManifestFull()和decodeManifestWithResources()有着本质的区别。虽然都是解析清单文件。decodeManifestFull方法是没有属性引用的清单文件,而decodeManifestWithResources是有属性引用的清单文件。
怎么理解呢?正常情况下清单文件会引用到系统或者当前应用的res/下的资源(也就是resources.arsc),所以解码有属性引用的清单文件一定用到decodeManifestWithResources方法。
if (hasResources()) { //含有resources.arsc资源
switch (mDecodeResources) { //反编译资源
case DECODE_RESOURCES_NONE: //没有解码资源,针对 no-res命令
mAndrolib.decodeResourcesRaw(mApkFile, outDir); //不反编译资源文件直接copy("resources.arsc", "AndroidManifest.xml", "res"保持原封不动)
if (mForceDecodeManifest == FORCE_DECODE_MANIFEST_FULL) { //对应force-manifest命令 强制解析清单文件
setTargetSdkVersion();
setAnalysisMode(mAnalysisMode, true);
if (hasManifest()) { //如果含有清单文件
//开始解析清单文件二进制数据
mAndrolib.decodeManifestWithResources(mApkFile, outDir, getResTable());
}
}
break;
case DECODE_RESOURCES_FULL: //正常执行反编译资源逻辑
setTargetSdkVersion();
setAnalysisMode(mAnalysisMode, true); //设置为分析模式
if (hasManifest()) {//如果是清单文件
mAndrolib.decodeManifestWithResources(mApkFile, outDir, getResTable()); //开始解析清单文件二进制数据
}
mAndrolib.decodeResourcesFull(mApkFile, outDir, getResTable());//解析 resource.arsc文件
break;
}
} else {
//如果没有resources.arsc文件,则在不查找属性引用下的解码清单文件
if (hasManifest()) {//如果有清单文件
if (mDecodeResources == DECODE_RESOURCES_FULL //如果想完整的反编译资源并且强制解析清单文件
|| mForceDecodeManifest == FORCE_DECODE_MANIFEST_FULL) {
mAndrolib.decodeManifestFull(mApkFile, outDir, getResTable());
}
else {
mAndrolib.decodeManifestRaw(mApkFile, outDir); //执行的逻辑是直接复制清单文件
}
}
}
继续看hasSources()部分代码,成立条件是否包含dex文件,判断条件值有关键的两个。第一条件判断值:DECODE_SOURCES_NONE,翻译过来就是无解码源,对应着no-src命令。如果不加上no-src命令这个判断条件一定不会执行的。做的逻辑就是复制classes.dex文件到指定目录。
第二条件判断值 :DECODE_SOURCES_SMALI或DECODE_SOURCES_SMALI_ONLY_MAIN_CLASSESZ,没有额外命令情况下正常都会走这个条件的,执行就是解码.dex格式的文件。hasMultipleSources()就是对多个dex文件进行处理。逻辑与hasSources()没什么区别,不再做解释。
if (hasSources()) { //文件夹 含有单个dex文件
switch (mDecodeSources) {
case DECODE_SOURCES_NONE://不需要解析dex文件,对应no-src 命令
mAndrolib.decodeSourcesRaw(mApkFile, outDir, "classes.dex"); //这一步做的逻辑,直接复制classes.dex文件
break;
case DECODE_SOURCES_SMALI:
case DECODE_SOURCES_SMALI_ONLY_MAIN_CLASSES: //对应only-main-classes 命令
mAndrolib.decodeSourcesSmali(mApkFile, outDir, "classes.dex", mBakDeb, mApi); //反编译把dex文件转换成smali文件 很关键这部分
break;
}
}
if (hasMultipleSources()) { //文件夹含有多个dex文件,跟hasSources()里面逻辑基本一致不复述
// 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, mApi);//反编译把dex文件转换成smali文件 很关键这部分
break;
case DECODE_SOURCES_SMALI_ONLY_MAIN_CLASSES:
if (file.startsWith("classes") && file.endsWith(".dex")) {
mAndrolib.decodeSourcesSmali(mApkFile, outDir, file, mBakDeb, mApi);
} else {
mAndrolib.decodeSourcesRaw(mApkFile, outDir, file);
}
break;
}
}
}
}
}
下面几个方法对于理解源码也很重要,先看decodeRawFiles(),用到了Androlib类里面的方法。这个方法主要解析原生的文件,就是Android在编译apk的过程中不参与编译的文件目录,执行的逻辑直接复制Assets和libs、lib、kotlin四个文件到指定的目录。在反编译资源时候经常能看到这么一段日志,Copying assets and libs... 就是来自这个方法。
mAndrolib.decodeRawFiles(mApkFile, outDir, mDecodeAssets); //解析原始的文件,Assets和libs、lib、kotlin四个文件不需要处理,直接copy
mAndrolib.decodeUnknownFiles(mApkFile, outDir, mResTable); //处理未知文件。未知类型指的是非apk固定的文件。
接着看decodeUnknownFiles()方法,也是用到Androlib类里面的方法。根据方法字面意思可知,解码apk未知文件,那么什么是未知文件呢?Androlib.decodeUnknownFiles()里面有一个isAPKFileNames方法,它规定了一个正常APK中应该有哪些文件。
decodeUnknownFiles全部代码.pngisAPKFileNames()方法中有一个名为APK_STANDARD_ALL_FILENAMES定义,它是一个字符串数组,包含范围有"classes.dex", "AndroidManifest.xml", "resources.arsc", "res", "r", "R","lib", "libs", "assets", "META-INF", "kotlin"。不再此范围的就会被划分为未知文件。
执行此方法逻辑时候会自动创建一个unknown文件夹,将筛选出来的未知文件复制到unknown文件夹内,并记录在apkool.yml文件中(下面有三张图片作为演示验证),最后在构建(回编)时候会用到。在反编译资源时候经常能看到这么一段日志,Copying unknown files... 就是来自这个方法。
zip解压后apk文件目录.png 反编译后的unknown文件夹.png apktool.yml中记录的未知文件.png接着看recordUncompressedFiles(),同样用到Androlib类里面的方法。根据方法字面意思可知,记录未压缩的文件。我先说一下方法里具体执行的逻辑。for循环遍历解压后apk所有文件,如果满足条件的文件会记录到uncompressedFilesOrExts集合中。
第一个判断条件isAPKFileNames是否是一个apk文件,这个在上面未知文件中介绍过了不在细说。第二个判断条件unk.getCompressionLevel(file)==0 表示压缩的等级,0表示不压缩。
recordUncompressedFiles方法.png满足上面2个条件,有符合条件的文件,直接获取文件的扩展名称。NO_COMPRESS_PATTERN是一个正则表达式,意思是无压缩模式,判断条件前面加了一个!即压缩模式。如果ext字符串为空或者是压缩过的文件走这个条件。最终将不压缩的文件扩展类型添加到uncompressedFilesOrExts集合供apktool.yml中doNotCompress字段记录。
//不压缩模式正则
private final static Pattern NO_COMPRESS_PATTERN = Pattern.compile("(" +
"jpg|jpeg|png|gif|wav|mp2|mp3|ogg|aac|mpg|mpeg|mid|midi|smf|jet|rtttl|imy|xmf|mp4|" +
"m4a|m4v|3gp|3gpp|3g2|3gpp2|amr|awb|wma|wmv|webm|mkv)$");
没看完源码之前我曾有过这样的一个疑问?整个apktool源码里并没有执行真正压缩的逻辑,那压缩文件哪里来的。经查阅一番资料得知压缩逻辑源于appt,开篇时候我讲过apk生成离不开aapt打包工具,而apktool是对原始的apk进行解码。 对于压缩逻辑好奇的同学可以翻阅aapt源码,源码中能找到你想要的答案(需要有c++底子) 。其实用aapt命令就能很好理解apktool中的压缩到底是什么鬼了。输入以下命令可知!!!!
aapt命令.png其中各字段代表的含义如下(作为了解):
-
Length:文件的长度。
-
Method:数据压缩算法,有Deflate和Stored两种类型。
-
Ratio:压缩率。
-
Size:文件压缩后节省的大小。跟压缩率有关。Size=(1-压缩率)*Length。
-
Date:日期。
-
Time:时间。
-
CRC-32:循环冗余校验,是一种加密算法。
-
Name:文件名称。
重点是看Ratio字段,其中0%就是不压缩文件,在下图中apktool.yml里doNotCompress字段里得到验证。当然大家可以自己去试验一下,来验证我说的!!!
apktool中doNotCompress字段部分截图.png mUncompressedFiles = new ArrayList<String>();
mAndrolib.recordUncompressedFiles(mApkFile, mUncompressedFiles);//记录未压缩的文件
mAndrolib.writeOriginalFiles(mApkFile, outDir);//具体逻辑已经对该方法加说明了
不压缩代码部分已经分析完了,接着看下writeOriginalFiles(),写入原始数据,同样也是用到Androlib类里面的方法。执行的逻辑先创建一个original文件夹,把原始二进制清单文件、META-INF 、META-INF/services等文件复制到original文件夹下。在反编译资源时候经常能看到这么一段日志,Copying original files... 就是来自这个方法。很简单不细说了。
writeOriginalFiles.png 反编译后的original文件夹.png最后来看一下 writeMetaFile()方法, 在反编译时候一定会有apktool.yml文件,下面方法就是有关apktool.yml全部的属性。下面注释很齐全,我们看下每个字段属性的细节。
//writeMetaFile代码部分
private void writeMetaFile() throws AndrolibException {
MetaInfo meta = new MetaInfo();
meta.version = Androlib.getVersion(); //获取apktool版本号
meta.apkFileName = mApkFile.getName(); //获取文件名
//如果要反编译资源并且有resources.arsc文件或者manifest文件,执行条件内的逻辑
if (mDecodeResources != DECODE_RESOURCES_NONE && (hasManifest() || hasResources())) {
//apktool.yml中 isFrameworkApk字段布尔类型 是否引用了系统apk,可以看下方法注释
meta.isFrameworkApk = mAndrolib.isFrameworkApk(getResTable());
putUsesFramework(meta); //放置是否使用系统框架
putSdkInfo(meta); //放置sdkInfo信息
putPackageInfo(meta); //放置PackageInfo信息
putVersionInfo(meta); //放置versionCode 和versionName
putSharedLibraryInfo(meta); //记录是否是库文件信息
putSparseResourcesInfo(meta); //特殊资源(看方法注释)
}
putUnknownInfo(meta); //记录未知文件
putFileCompressionInfo(meta); //设置文件压缩信息(也是列出不压缩的文件)
mAndrolib.writeMetaFile(mOutDir, meta); //写入Meta文件数据
}
!!brut.androlib.meta.MetaInfo
apkFileName: demo.apk
compressionType: false
doNotCompress:
- resources.arsc
- META-INF/android.arch.core_runtime.version
- assets/AssetBundles/app_version.bytes
......省略若干
isFrameworkApk: false
packageInfo:
forcedPackageId: '127'
renameManifestPackage: null
sdkInfo:
minSdkVersion: '19'
targetSdkVersion: '29'
sharedLibrary: false
sparseResources: false
unknownFiles:
firebase-measurement-connector.properties: '8'
firebase-messaging.properties: '8'
......省略若干
usesFramework:
ids:
- 1
tag: null
version: 2.4.1
versionInfo:
versionCode: '2'
versionName: 2.0.0
看这部分代码前一定要看懂.arsc数据结构,不然我说的你可能不懂。推一篇不错的博文参考 ARSC 文件格式解析
按代码顺序详细说下!!!
mAndrolib.isFrameworkApk(getResTable()) : 是否是一个系统apk文件;对应isFrameworkApk字段
执行的逻辑,罗列出apk中所有资源包然后遍历,从ResTable()表中(先理解成解析当前应用.arsc文件,下面会详细说ResTable是什么)获取应用资源id,如果资源id<64 则认为是系统apk,反之不是。
id是int值代表的是10进制,系统应用资源id是16进制0x01开头(转换成10进制是1) ,当前应用的资源id是0x7f开头(转换成10进制是127)。正常情况下的应用该值基本都是false 。
putUsesFramework(meta) : 记录使用的框架;对应usesFramework字段
这个字段包含两个属性 分别是 ids 和tag。先说tag它最简单,说白了这个就是-t 或--tag 给框架加标签用的命令,区分多个厂商定制或Framework。没有指定tag命令正常情况下会是null。 然后看下ids属性之所以为1 ,是因为系统应用资源id是16进制0x01开头(转换成10进制是1) ,那个不是-1 细看是有空格的应该是yml格式导致的,这个- 1的问题被骗了好多年。(断点亲测过值=1,详细看源码)。
狗头.jpgputSdkInfo(meta): 记录sdk信息;对应sdkInfo字段
包含了两个属性minSdkVersion和targetSdkVersion,严格来说三个才对还有一个maxSdkVersion属性,源码中有写到。如果你没有在AS根录build.gradle中定义maxSdkVersion属性,apktool.yml也不会展示的。
androidStudio.png这三个值哪里来的呢,说白了解析清单文件二进制数据格式得到的,详细见源码XmlPullStreamDecoder.parseAttr()
aapt命令.png 展示属性.pngputPackageInfo(meta): 记录包信息;对应packageInfo字段
其中包含两个属性forcedPackageId和renameManifestPackage。最开始的时候我一直好奇forcedPackageId属性中127那里来的?在这方面下点功夫知道了怎么回事。看下图注释吧比较详细,我不细说了。非系统库和共享库条件下这个forcedPackageId基本是127 不会变。
putPackageInfo方法.jpgputVersionInfo(meta): 记录版本信息;对应versionInfo字段
这个字段比较简单 ,如下图所示,看一下在AS中根目录build.gradle中设置的两个属性,最后都会被读取出来放在apktool.yml里面。具体怎么读取数值,可以看下上面putSdkInfo()说过的aapt命令。
androidStudio版本信息.pngputSharedLibraryInfo(meta): 记录是否是一个库文件信息;对应sharedLibrary字段
这个属性字段的意思是否用到系统资源共享库,说白了就是清单文件中<uses-library/>属性 。我不做详细的解释涉及的内容较多,不是一句两句说得清楚,详细可以搜一下SharedLibrary去了解一下。我推荐一篇技术文章供大家参考,有兴趣深入理解的可以去看看写的非常不错Android资源管理中的SharedLibrary资源共享库!!!
之前我做过这样的测试清单文件添加<uses-library/>引用库,执行反编译命令后apktool.yml属性sharedLibrary依旧是false。这让我百思不得其解,也是看了上面我推荐的博客才知道需要执行aapt中的--shared-lib命令才行,这命令的意思是要把当前应用编译资源共享库,而不是普通的APK。(犹豫本人功力不足没有吃透aapt源码整体逻辑,还是老话有能力底子深的翻阅源码。如果下载不便,可以私聊我提供!!!)
aapt源码中的shared-lib命令.png该方法执行核心逻辑可以看下ARSCDecoder中的readTablePackage方法里 mResTable.setSharedLibrary(true)。只有资源id、0x00才会走到id==0这个条件里sharedLibrary才会变成true,常规应用apk基本上是false。
也就是说 if (id == 0) {} packageId=0说明了有资源共享库id 也就是SharedLibrary,那么为什么给id重新赋值呢,是因为针对多个资源共享库,我们知道0是共享库资源,多个共享库呢 资源id怎么排列呢。难道都是0x00 ?如何区分呢? 0x01是系统资源,不能占用.所以从2(0x02)开始
//ARSCDecoder类下的readTablePackage()方法
private ResPackage readTablePackage() throws IOException, AndrolibException {
checkChunkType(Header.TYPE_PACKAGE); //读取ResTablePackage头文件
int id = mIn.readInt(); //读取包中的id, 一般来说package id为0x01是系统资源,0x7F为自己应用的id
if (id == 0) {
id = 2;
if (mResTable.getPackageOriginal() == null && mResTable.getPackageRenamed() == null) {
mResTable.setSharedLibrary(true);
}
}
下图是aapt源码中有关资源ID定义的方法和属性,这个部分很重要,appt源码中给packageId定义类型。
-
AppFeature packageId = 0x7f; 当前应用的资源包
-
System packageId = 0x01; 系统应用资源包
-
SharedLibrary packageId = 0x00; 共享库资源包
putSparseResourcesInfo(meta): 记录是否是特殊资源信息;对应sparseResources字段
sparseResources是指.arsc文件中的特殊资源布尔类型值,没有特殊资源即为false,可以到ARSCDecoder类readTableTypeSpec()查看引用关系。这个跟aapt和aapt2版本区别有关,深究的话就要看下aapt2的源码。
现在唯一能知道这个跟aapt2命令 enable-sparse-encoding 有关,该命令的意思:允许使用二进制搜索树对稀疏条目进行编码。 这有助于优化 APK 大小,但会降低资源检索性能。
putUnknownInfo(meta): 记录未知文件;对应unknownFiles字段
这个字段记录着apk中未知的文件,上面已经复述过了不细讲了。
putFileCompressionInfo(meta): 记录不压缩文件;对应doNotCompress字段
该字段描述的是不压缩文件,上面已经细说过不在复述。
小结
我们粗略的看了一下apktool解码逻辑,到这里我们可以发现,apktool在反编译的整个过程核心点按解码顺序就三个:resource.arsc文件,AndroidManifest.xml文件,dex文件。通俗点说解码主要就是解析这三种文件,当然这里面多少也有aapt的影子在里面。
解码核心逻辑(非常重要)
上面讲述内容只是粗略的过滤了一下解码的整体过程。但是解码的核心却没有细究过,解码三个文件是怎么解析的?一直提过的table是什么也没过细说过,带着问题我们详细重新捋一下逻辑。先看一下正常执行命令来反编译apk文件的输出日志!如下所示:
//apktool d demo.apk
1: Using Apktool 2.5.1-dirty on demo.apk.
2: Loading resource table...
3: Decoding AndroidManifest.xml with resources...
4: Loading resource table from file: /Users/LongFei/Library/apktool/framework/1.apk
5: Regular manifest package...
6: Decoding file-resources...
7: Decoding values * / * XMLs...
8: Baksmaling classes.dex...
9: Copying assets and libs...
10: Copying unknown files...
11: Copying original files...
步骤1-从反编译第一个日志看起,开始使用apktool打印当前版本以及反编译应用apk名称 ! ! !
Using Apktool.png步骤2-加载数据表,数据表就是resources.arsc文件
这里只看完整反编译资源文件的情况,也就是DECODE_RESOURCES_FULL所在条件下面的代码。分支条件下第一行代码就是setTargetSdkVersion,我们看看setTargetSdkVersion方法的内部实现。
public void setTargetSdkVersion() throws AndrolibException, IOException {
if (mResTable == null) {
mResTable = mAndrolib.getResTable(mApkFile);
}
Map<String, String> sdkInfo = mResTable.getSdkInfo(); //从table中获取sdkInfo
if (sdkInfo.get("targetSdkVersion") != null) {
mApi = Integer.parseInt(sdkInfo.get("targetSdkVersion"));
}
//ResTable的定义字段
private ResTable mResTable;
ApkDecoder内部是维护了一个ResTable的,我们的任何的资源数据信息都是根据ResTable来取的。当ApkDecoder发现mResTable变量是空的的时候,会对此进行初始化。
public ResTable getResTable() throws AndrolibException {
if (mResTable == null) {
boolean hasResources = hasResources(); //是否包含arsc文件
boolean hasManifest = hasManifest(); //是否包含清单文件
if (! (hasManifest || hasResources)) { //都不存在抛异常
throw new AndrolibException(
"Apk doesn't contain either AndroidManifest.xml file or resources.arsc file");
}
mResTable = mAndrolib.getResTable(mApkFile, hasResources); //ResTable的生成和数据结构的填充主要是通过Androlib.getResTable方法。
}
return mResTable;
}
接下来我们就主要看看Androlib的getResTable方法,getRestable()函数主要创建一个ResTable类,用于表示resources.arsc反编译后的数据结构。 ResTable的数据填充主要通过loadMainPkg函数完成。loadMainPkg又通过getResPackagesFromApk函数完成。
public ResTable getResTable(ExtFile apkFile, boolean loadMainPkg)
throws AndrolibException {
ResTable resTable = new ResTable(this);
if (loadMainPkg) {
loadMainPkg(resTable, apkFile);
}
return resTable;
}
/**
* 数据填充
*/
public ResPackage loadMainPkg(ResTable resTable, ExtFile apkFile)
throws AndrolibException {
LOGGER.info("Loading resource table...");
ResPackage[] pkgs = getResPackagesFromApk(apkFile, resTable, sKeepBroken); //核心的逻辑执行
ResPackage pkg = null;
switch (pkgs.length) {
case 1:
pkg = pkgs[0];
break;
case 2: //针对多个arsc文件
if (pkgs[0].getName().equals("android")) {
LOGGER.warning("Skipping \"android\" package group");
pkg = pkgs[1];
break;
} else if (pkgs[0].getName().equals("com.htc")) {
LOGGER.warning("Skipping \"htc\" package group");
pkg = pkgs[1];
break;
}
default:
pkg = selectPkgWithMostResSpecs(pkgs);
break;
}
if (pkg == null) {
throw new AndrolibException("arsc files with zero packages or no arsc file found.");
}
resTable.addPackage(pkg, true);
return pkg;
}
继续接着上面的说,AndrolibResources类下getResPackagesFromApk方法。BufferedInputStream读取 resources.arsc二进制文件,层层调用到了ARSCDecoder.decode();
private ResPackage[] getResPackagesFromApk(ExtFile apkFile,ResTable resTable, boolean keepBroken)
throws AndrolibException {
try {
Directory dir = apkFile.getDirectory();
BufferedInputStream bfi = new BufferedInputStream(dir.getFileInput("resources.arsc"));
try {
return ARSCDecoder.decode(bfi, false, keepBroken, resTable).getPackages(); //核心走到这里
} finally {
try {
bfi.close();
} catch (IOException ignored) {}
}
} catch (DirectoryException ex) {
throw new AndrolibException("Could not load resources.arsc from file: " + apkFile, ex);
}
}
继续执行到ARSCDecoder类下的decode方法,ARSCDecoder调用readTableHeader,这个方法叫readTableHeader,实则把整个resources.arsc都解析了。
解析完成后,代码层层返回得到ResTable数据,ResTable数据得到填充。解析resources.arsc先说到这里,细说起来不是一句两句的事情,已经给大家开了一个头,下面内容就是按照resources.ars数据格式解析二进制数据。有关resources.arsc格式可以看一下博文ARSC 文件格式解析 能帮助大家更好的理解接下来的代码 !!!
/**
* arsc数据解码
* @param arscStream arsc文件流数据
* @param findFlagsOffsets 是否查找偏移量
* @param keepBroken 是否强制解码资源
* @param resTable 资源表
* @return
* @throws AndrolibException
*/
public static ARSCData decode(InputStream arscStream, boolean findFlagsOffsets, boolean keepBroken,
ResTable resTable)
throws AndrolibException {
try {
//首先根据输入流,resTable等参数new一个ARSCDecoder
ARSCDecoder decoder = new ARSCDecoder(arscStream, resTable, findFlagsOffsets, keepBroken);
ResPackage[] pkgs = decoder.readTableHeader(); //读取 table 头文件
return new ARSCData(pkgs, decoder.mFlagsOffsets == null
? null
: decoder.mFlagsOffsets.toArray(new FlagsOffset[0]), resTable);
} catch (IOException ex) {
throw new AndrolibException("Could not decode arsc file", ex);
}
}
private ARSCDecoder(InputStream arscStream, ResTable resTable, boolean storeFlagsOffsets, boolean keepBroken) {
arscStream = mCountIn = new CountingInputStream(arscStream);
if (storeFlagsOffsets) {
mFlagsOffsets = new ArrayList<FlagsOffset>();
} else {
mFlagsOffsets = null;
}
//我们需要显式地强制转换为DataInput,否则构造函数会模棱两可。
// 我们选择DataInput而不是InputStream,因为ExtDataInput将InputStream封装在大端字节序的DataInputStream中,并忽略了小端字节序的行为。
mIn = new ExtDataInput((DataInput) new LittleEndianDataInputStream(arscStream));
mResTable = resTable;
mKeepBroken = keepBroken;
}
/**
* 读取数据表头文件(读懂这部分一定要吃透arsc文件的数据结构,不然肯定是一脸懵逼)
* 执行逻辑:实际 解析了整个resources.arsc文件
* @return
* @throws IOException
* @throws AndrolibException
*/
private ResPackage[] readTableHeader() throws IOException, AndrolibException {
nextChunkCheckType(Header.TYPE_TABLE); //读取arsc的头文件信息
int packageCount = mIn.readInt(); //表示arsc文件ResTablePackage的个数,即数据块的个数,通常这值是1
mTableStrings = StringBlock.read(mIn); //读取的字符串池 (ResStringPool)
ResPackage[] packages = new ResPackage[packageCount];//ResTablePackage 这个里面包含5个小部分
nextChunk();
for (int i = 0; i < packageCount; i++) {
mTypeIdOffset = 0;
packages[i] = readTablePackage(); //使用readTablePackage方法来分析
}
return packages;
}
步骤3-解码AndroidManifest.xml文件与资源…
该到解析清单文件这一环节了,先看完整解码清单文件的条件。Androlib 类/decodeManifestWithResources(),继续走到AndrolibResources类decodeManifestWithResources()。接着走到了ResFileDecoder类下的decodeManifest(),最后执行到XmlPullStreamDecoder/decode函数。
//Androlib 类下decodeManifestWithResources()
/**
* 解析清单文件(针对有属性引用的清单文件)
* @param apkFile
* @param outDir
* @param resTable
* @throws AndrolibException
*/
public void decodeManifestWithResources(ExtFile apkFile, File outDir, ResTable resTable)
throws AndrolibException {
mAndRes.decodeManifestWithResources(resTable, apkFile, outDir);
}
//AndrolibResources类下的decodeManifestWithResources方法
/**
* 有属性引用解析清单文件核心类
* @param resTable
* @param apkFile
* @param outDir
* @throws AndrolibException
*/
public void decodeManifestWithResources(ResTable resTable, ExtFile apkFile, File outDir)
throws AndrolibException {
Duo<ResFileDecoder, AXmlResourceParser> duo = getResFileDecoder();
ResFileDecoder fileDecoder = duo.m1; //res文件解码
ResAttrDecoder attrDecoder = duo.m2.getAttrDecoder(); //res资源属性解码
attrDecoder.setCurrentPackage(resTable.listMainPackages().iterator().next());
Directory inApk, in = null, out;
try {
inApk = apkFile.getDirectory();
out = new FileDirectory(outDir);
LOGGER.info("Decoding AndroidManifest.xml with resources...");
fileDecoder.decodeManifest(inApk, "AndroidManifest.xml", out, "AndroidManifest.xml");
//删除versionName / versionCode属性(Aapt API 16)
if (!resTable.getAnalysisMode()) {
//检查resources.arsc包与AndroidManifest中列出的包之间是否不匹配
// 从清单中删除android属性 versionCode / versionName进行重建,这是一项必需的更改,以防止出现有关版本冲突的警告
//它将通过apktool.yml作为参数传递给aapt,例如“ --min-sdk-version”
adjustPackageManifest(resTable, outDir.getAbsolutePath() + File.separator + "AndroidManifest.xml");
ResXmlPatcher.removeManifestVersions(new File(
outDir.getAbsolutePath() + File.separator + "AndroidManifest.xml"));
mPackageId = String.valueOf(resTable.getPackageId());
}
} catch (DirectoryException ex) {
throw new AndrolibException(ex);
}
}
//ResFileDecoder类下的decodeManifest()
/**
* 解码清单
* @param inDir
* @param inFileName
* @param outDir
* @param outFileName
* @throws AndrolibException
*/
public void decodeManifest(Directory inDir, String inFileName,
Directory outDir, String outFileName) throws AndrolibException {
try (
InputStream in = inDir.getFileInput(inFileName);
OutputStream out = outDir.getFileOutput(outFileName)
) {
((XmlPullStreamDecoder) mDecoders.getDecoder("xml")).decodeManifest(in, out);
} catch (DirectoryException | IOException ex) {
throw new AndrolibException(ex);
}
}
发现很有意思事情,经过层层逻辑调用,解析清单文件(.xml)采用的是Pull解析法,封装了一层,直接把Android中的一些方法copy过来了。有关清单文件的二进制格式可以看下我写过的博客AndroidManifest.xml 文件格式解析有助于帮助你理解apktool源码中有关解析二进制清单文件格式(偷个懒看),解析清单文件到此为止往下看!!!!
image.png步骤4-加载系统框架资源表
文中提了不少关于框架文件这一词,框架文件是什么呢?给大家解释一下。你应该知道,Android应用程序利用了Android OS本身上的代码和资源。这些被称为框架资源,Apktool依靠它们来正确解码和构建apk。
每个Apktool版本在发布时内部都包含最新的AOSP框架。这使你可以毫无问题地解码和构建大多数apk。但是,制造商(国内的小米 华为 魅族这种定制的厂商)除了常规的AOSP文件外,还添加了自己的框架文件。要针对这些制造商apk使用apktool,必须首先安装制造商框架文件。(工作中只要不涉及到厂商应用或者系统级别应用。正常用不到这部分知识,可以作为知道部分)。
倒叙看下面代码的逻辑吧,首先我们要找到系统框架所在路径,根据所使用的操作系统,框架存储在不同的位置 ! ! !
-
Unix - $HOME/.local/share/apktool/framework/
-
Windows - %UserProfile%r\AppData\Local\apktool\framework
-
Mac - $HOME/Library/apktool/framework/
如果这些目录不存在,它会默认创建一个临时目录。你也可以利用该参数--frame-path为框架文件选择备用文件夹。
由于这些位置有时位于隐藏的目录中,因此管理这些框架成为一个麻烦事。在v2.2.1中添加一个简单的帮助命令,可以运行apktool empty-framework-dir以达到清空框架的目的。
/**
* 获取框架目录
* @return
* @throws AndrolibException
*/
public File getFrameworkDir() throws AndrolibException {
if (mFrameworkDirectory != null) {
return mFrameworkDirectory;
}
String path;
//如果在命令行上指定了框架路径,获取的就是指定本地的路径
if (apkOptions.frameworkFolderLocation != null) {
path = apkOptions.frameworkFolderLocation;
} else { //如果没有指定命令,根据不同的系统创建对应文件路径
File parentPath = new File(System.getProperty("user.home"));
if (OSDetection.isMacOSX()) {
path = parentPath.getAbsolutePath() + String.format("%1$sLibrary%1$sapktool%1$sframework", File.separatorChar);
} else if (OSDetection.isWindows()) {
//window个人电脑路径 C:\Users\Administrator\AppData\Local\apktool\framework
path = parentPath.getAbsolutePath() + String.format("%1$sAppData%1$sLocal%1$sapktool%1$sframework", File.separatorChar);
} else {
path = parentPath.getAbsolutePath() + String.format("%1$s.local%1$sshare%1$sapktool%1$sframework", File.separatorChar);
}
}
File dir = new File(path);
//下面的逻辑就是对多种可能发生的情况,抛出异常的处理。
if (!dir.isDirectory() && dir.isFile()) {
throw new AndrolibException("--frame-path is set to a file, not a directory.");
}
if (dir.getParentFile() != null && dir.getParentFile().isFile()) {
throw new AndrolibException("Please remove file at " + dir.getParentFile());
}
if (! dir.exists()) {
if (! dir.mkdirs()) {
if (apkOptions.frameworkFolderLocation != null) {
LOGGER.severe("Can't create Framework directory: " + dir);
}
throw new AndrolibException(String.format(
"Can't create directory: (%s). Pass a writable path with --frame-path {DIR}. ", dir
));
}
}
if (apkOptions.frameworkFolderLocation == null) {
if (! dir.canWrite()) {//无法写入文件
LOGGER.severe(String.format("WARNING: Could not write to (%1$s), using %2$s instead...",
dir.getAbsolutePath(), System.getProperty("java.io.tmpdir")));
LOGGER.severe("Please be aware this is a volatile directory and frameworks could go missing, " +
"please utilize --frame-path if the default storage directory is unavailable");
//获取文件的临时路径 也就是:C:\Users\Administrator\AppData\Local\Temp(我的电脑路径)
dir = new File(System.getProperty("java.io.tmpdir"));
}
}
mFrameworkDirectory = dir;
return dir;
}
框架的路径获取到了,接着继续看。frameTag是跟框架的-tag命令有关,没有指定 tag命令,frameTag就是一个null字符串。先看frameTag不为空情况,会创建一个id-tag.apk文件,然后return(通常这个逻辑很少执行)。
frameTag为空的条件下会根据id创建一个id.apk的文件,正常情况下就是id=1,至于id为什么是1上面有说过系统框架的资源id 0x01。id=1证明是系统框架,会把aapt内置的brut/androlib/android-framework.jar复制到指定路径下,例如我的路径 /Users/LongFei/Library/apktool/framework/1.apk。如果有用到厂商的框架文件,例如htc的框架就会变成2.apk,依此类推,1.apk和2.apk是共存的。
/**
* 获取系统框架apk文件
* @param id 通常是 1
* @param frameTag
* @return
* @throws AndrolibException
*/
public File getFrameworkApk(int id, String frameTag) throws AndrolibException {
File dir = getFrameworkDir(); //获取框架目录
File apk;
if (frameTag != null) { //如果设置了 tag(命令)
apk = new File(dir, String.valueOf(id) + '-' + frameTag + ".apk"); //用id_tag.apk方式创建一个文件
if (apk.exists()) { //如果文件存在返回apk文件
return apk;
}
}
apk = new File(dir, String.valueOf(id) + ".apk");//没有设置tag走这段逻辑,也就是正常情况下看到的xxx/1.apk
if (apk.exists()) {
return apk;
}
if (id == 1) { //这个条件一定会走
try (InputStream in = AndrolibResources.class.getResourceAsStream("/brut/androlib/android-framework.jar"); //获取绝对路径的文件
OutputStream out = new FileOutputStream(apk)) {
IOUtils.copy(in, out); //执行复制文件逻辑
return apk;
} catch (IOException ex) {
throw new AndrolibException(ex);
}
}
throw new CantFindFrameworkResException(id);
}
获取到的系统框架apk文件了,输出Loading resource table from file: ...... 我们熟悉的日志,最终逻辑执行到了getResPackagesFromApk(),这个方法应该不陌生在说解析resource.asrc文件中说过,不做说明。步骤3,简单说就是把关联的系统框架资源解析了一下。
/**
*加载系统框架 也就是 apktool\framework\1.apk
* @param resTable
* @param id 正常情况下基本是 1 ,因为定义0x01(16进制) 是系统资源
* @param frameTag
* @return
* @throws AndrolibException
*/
public ResPackage loadFrameworkPkg(ResTable resTable, int id, String frameTag)
throws AndrolibException {
File apk = getFrameworkApk(id, frameTag);
LOGGER.info("Loading resource table from file: " + apk);
mFramework = new ExtFile(apk);
ResPackage[] pkgs = getResPackagesFromApk(mFramework, resTable, true);
ResPackage pkg;
if (pkgs.length > 1) {
pkg = selectPkgWithMostResSpecs(pkgs);
} else if (pkgs.length == 0) {
throw new AndrolibException("Arsc files with zero or multiple packages");
} else {
pkg = pkgs[0];
}
if (pkg.getId() != id) {
throw new AndrolibException("Expected pkg of id: " + String.valueOf(id) + ", got: " + pkg.getId());
}
resTable.addPackage(pkg, false);
return pkg;
}
步骤5-规范清单文件包名
这部分没什么好说,重点看adjustPackageManifest()函数,分别将解析完成的resources.arsc和清单文件进行包名比较。如果包名一致,打印 LOGGER.info("Regular manifest package...");日志!至于为什么包名一致,跟步骤6关系有关。解析完resources.arsc和清单文件后,生成资源文件也就是res文件,资源文件要有包名才能生成资源。
/**
* 有属性引用解析清单文件核心类
* @param resTable
* @param apkFile
* @param outDir
* @throws AndrolibException
*/
public void decodeManifestWithResources(ResTable resTable, ExtFile apkFile, File outDir)
throws AndrolibException {
Duo<ResFileDecoder, AXmlResourceParser> duo = getResFileDecoder();
ResFileDecoder fileDecoder = duo.m1; //res文件解码
ResAttrDecoder attrDecoder = duo.m2.getAttrDecoder(); //res资源属性解码
attrDecoder.setCurrentPackage(resTable.listMainPackages().iterator().next());
Directory inApk, in = null, out;
try {
inApk = apkFile.getDirectory();
out = new FileDirectory(outDir);
LOGGER.info("Decoding AndroidManifest.xml with resources...");
fileDecoder.decodeManifest(inApk, "AndroidManifest.xml", out, "AndroidManifest.xml");
//删除versionName / versionCode属性(Aapt API 16)
if (!resTable.getAnalysisMode()) {
//检查resources.arsc包与AndroidManifest中列出的包之间是否不匹配
// 从清单中删除android属性 versionCode / versionName进行重建,这是一项必需的更改,以防止出现有关版本冲突的警告
//它将通过apktool.yml作为参数传递给aapt,例如“ --min-sdk-version”
adjustPackageManifest(resTable, outDir.getAbsolutePath() + File.separator + "AndroidManifest.xml");
ResXmlPatcher.removeManifestVersions(new File(
outDir.getAbsolutePath() + File.separator + "AndroidManifest.xml"));
mPackageId = String.valueOf(resTable.getPackageId());
}
} catch (DirectoryException ex) {
throw new AndrolibException(ex);
}
}
public void adjustPackageManifest(ResTable resTable, String filePath) throws AndrolibException {
//将resources.arsc包名与AndroidManifest中的包名进行比较
ResPackage resPackage = resTable.getCurrentResPackage();
String pkgOriginal = resPackage.getName();
mPackageRenamed = resTable.getPackageRenamed();
resTable.setPackageId(resPackage.getId());
resTable.setPackageOriginal(pkgOriginal);
// 1) Check if pkgOriginal === mPackageRenamed
// 2) Check if pkgOriginal is ignored via IGNORED_PACKAGES
if (pkgOriginal.equalsIgnoreCase(mPackageRenamed) || (Arrays.asList(IGNORED_PACKAGES).contains(pkgOriginal))) {
LOGGER.info("Regular manifest package...");
} else {
LOGGER.info("Renamed manifest package found! Replacing " + mPackageRenamed + " with " + pkgOriginal);
ResXmlPatcher.renameManifestPackage(new File(filePath), pkgOriginal);
}
}
步骤6和7-生成res文件
开始我有个误区一直以为decodeResourcesFull函数就是解析resources.arsc文件,通读整个解码逻辑还有多次断点分析并不是这样。
经过前面执行的步骤,清单文件还有.arsc文件都是解析好了等待被使用的,decodeResourcesFull主要还是对当前解码后资源做处理(解码后的res文件)。执行到 mAndrolib.decodeResourcesFull函数时,getResTable()此时不为空已经有个完整的ResTable数据表了(对此有怀疑的可以断点调试一下就知道)。
mAndrolib.decodeResourcesFull(mApkFile, outDir, getResTable()); //解码完整的资源。
public ResTable getResTable() throws AndrolibException {
if (mResTable == null) {
boolean hasResources = hasResources(); //是否包含arsc文件
boolean hasManifest = hasManifest(); //是否包含清单文件
if (! (hasManifest || hasResources)) { //都不存在抛异常
throw new AndrolibException(
"Apk doesn't contain either AndroidManifest.xml file or resources.arsc file");
}
mResTable = mAndrolib.getResTable(mApkFile, hasResources); //ResTable的生成和数据结构的填充主要是通过Androlib.getResTable方法。
}
return mResTable;
}
这个方法其实用到的解析类和AndroidManifest.xml的解析类是一样的,因为他们都属于arsc格式,而且资源文件也是xml格式的,这里值得注意的是,会产生一个反编译中最关键的一个文件:public.xml,这个文件是在反编译之后的res\values\public.xml
public void decodeResourcesFull(ExtFile apkFile, File outDir, ResTable resTable)
throws AndrolibException {
mAndRes.decode(resTable, apkFile, outDir);
}
public void decode(ResTable resTable, ExtFile apkFile, File outDir)
throws AndrolibException {
Duo<ResFileDecoder, AXmlResourceParser> duo = getResFileDecoder();
ResFileDecoder fileDecoder = duo.m1;
ResAttrDecoder attrDecoder = duo.m2.getAttrDecoder();
attrDecoder.setCurrentPackage(resTable.listMainPackages().iterator().next());
Directory inApk, in = null, out;
try {
out = new FileDirectory(outDir);
inApk = apkFile.getDirectory(); //获取apk文件目录
out = out.createDir("res"); //创建res目录
if (inApk.containsDir("res")) {
in = inApk.getDir("res");
}
if (in == null && inApk.containsDir("r")) {
in = inApk.getDir("r");
}
if (in == null && inApk.containsDir("R")) {
in = inApk.getDir("R");
}
} catch (DirectoryException ex) {
throw new AndrolibException(ex);
}
ExtMXSerializer xmlSerializer = getResXmlSerializer();
for (ResPackage pkg : resTable.listMainPackages()) {
attrDecoder.setCurrentPackage(pkg);
LOGGER.info("Decoding file-resources...");
for (ResResource res : pkg.listFiles()) {
fileDecoder.decode(res, in, out);
}
LOGGER.info("Decoding values */* XMLs...");
for (ResValuesFile valuesFile : pkg.listValuesFiles()) {
generateValuesFile(valuesFile, out, xmlSerializer); //生成values文件
}
generatePublicXml(pkg, out, xmlSerializer); //生成res/values/public.xml文件
}
AndrolibException decodeError = duo.m2.getFirstError();
if (decodeError != null) {
throw decodeError;
}
}
public.xml.png
而这里的id值是一个整型值,8个字节;由三部分组成的:
PackageId+TypeId+EntryId
-
PackageId:是包的Id值,Android中如果是第三方应用的话,这个值默认就是0x7F,系统应用的话就是0x01上面说过, PackageId 等于 id="0x7f010000"
-
TypeId:资源的类型ID, 资源的类型有 animator、anim、color、drawable、layout、menu、raw、string 和 xml 等等若干种,每一种都会被赋予一个 ID,而且这些类型的值是从1开始逐渐递增的,而且顺序不能改变,attr=0x01,drawable=0x02....
-
Entry ID是指每一个资源在其所属的资源类型中所出现的次序。注意,不同类型的资源的Entry ID有可能是相同的,但是由于它们的类型不同,我们仍然可以通过其资源ID来区别开来。
步骤8-解码dex文件,将dex解析成smali源码
只看完整解码.dex文件函数条件,接着看重点是看 SmaliDecoder.decode函数,这个方法主要将dex文件解析成smali源码。
mAndrolib.decodeSourcesSmali(mApkFile, outDir, "classes.dex", mBakDeb, mApi); //反编译把dex文件转换成smali文件 很关键这部分
//Androlib类下的decodeSourcesSmali函数。
/**
* 反编译把dex文件转换成smali文件
* @param apkFile
* @param outDir
* @param filename
* @param bakdeb
* @param api
* @throws AndrolibException
*/
public void decodeSourcesSmali(File apkFile, File outDir, String filename, boolean bakdeb, int api)
throws AndrolibException {
try {
File smaliDir;
if (filename.equalsIgnoreCase("classes.dex")) { //如果文件名是classes.dex
smaliDir = new File(outDir, SMALI_DIRNAME); //创建一个smali文件夹
} else {
smaliDir = new File(outDir, SMALI_DIRNAME + "_" + filename.substring(0, filename.indexOf(".")));
}
OS.rmdir(smaliDir); // 如果文件夹不存在return,存在递归删除
smaliDir.mkdirs(); //创建多层文件
LOGGER.info("Baksmaling " + filename + "...");
SmaliDecoder.decode(apkFile, smaliDir, filename, bakdeb, api); //具体执行
} catch (BrutException ex) {
throw new AndrolibException(ex);
}
}
这部分代码主要看DexFileFactory.loadDexContainer(),这里用到了第三方的dexlib2来处理dex字节码,处理完dex文件后,交给Baksmali.disassembleDexFile()同样也是第三方库api,最后生成smali文件。
// SmaliDecoder类中的decode()方法
private void decode() throws AndrolibException {
try {
final BaksmaliOptions options = new BaksmaliOptions();
// options省略.......
int jobs = Runtime.getRuntime().availableProcessors(); //返回可用处理器的Java虚拟机的数量。
if (jobs > 6) {
jobs = 6;
}
// 创建容器,loadDexContainer()加载包含1个或多个dex文件的文件
MultiDexContainer<? extends DexBackedDexFile> container = DexFileFactory.loadDexContainer(mApkFile, Opcodes.forApi(mApi)); //一个DexBackedDexFile代表着一个的dex文件,通过DexBackedDexFile可以访问所有class文件
MultiDexContainer.DexEntry<? extends DexBackedDexFile> dexEntry;
DexBackedDexFile dexFile; //处理dex字节码,处理完dex文件,再交给Baksmali处理余下逻辑
// If we have 1 item, ignore the passed file. Pull the DexFile we need.
if (container.getDexEntryNames().size() == 1) {
dexEntry = container.getEntry(container.getDexEntryNames().get(0));
} else {
dexEntry = container.getEntry(mDexFile);
}
//检查传递的参数是否存在
if (dexEntry == null) {
dexEntry = container.getEntry(container.getDexEntryNames().get(0));
}
assert dexEntry != null;
dexFile = dexEntry.getDexFile(); //获取与此条目关联的dex文件
if (dexFile.supportsOptimizedOpcodes()) {
throw new AndrolibException("Warning: You are disassembling an odex file without deodexing it.");
}
if (dexFile instanceof DexBackedOdexFile) {
options.inlineResolver = InlineMethodResolver.createInlineMethodResolver(((DexBackedOdexFile)dexFile).getOdexVersion());
}
Baksmali.disassembleDexFile(dexFile, mOutDir, jobs, options);//交给Baksmali工具处理smali文件
} catch (IOException ex) {
throw new AndrolibException(ex);
}
}
步骤9-10-11部分
这部分内容不细说了,上面已经详细介绍过了,整个逻辑已经介绍完毕。
Apktool技术总结
apktool源码可读性非常高,而且写的思路非常清晰明确。内容通俗易懂,命名非常标准,一看就能理解什么意思,对各种可能出现的问题和故障做了充分的处理。
只要熟悉resource.arsc文件,AndroidManifest.xml文件,dex文件这三种类型文件,加上断点分析基本没什么太大的难度。看完apktool的源码,最大的好处就是以后出现反编译的问题可以自己动手进行调试。对安卓原生系统生成apk 拆解apk也有个深层次的理解,同时也可以研究反调试的手段及方法。
个人总结
说一句实话,研究apktool源码消耗了很多私人时间来熟悉代码、分析代码。中间遇到过很多我不熟悉也没见过的知识,所幸我坚持的看完了解码整体的逻辑,并且通俗的写了出来,收获也是满多的。
原计划把上述说的三种文件都单独写一篇博文,然后把链接放到这篇博文上的,奈何私人时间上不允许。没有全部完成,所以选几篇大佬写的非常不错的文章放上去了。文章中涉及到解析.arsc文件和.dex后续有空我会补成我的链接。
文中很多话都用大白话,并没有用专业的术语来说,只是为了记录个人分析过程,不喜勿喷。这篇文章是我个人的总结和分析,不保证所有的东西都是对的,如有不对欢迎指出,我会及时更正!!!
结尾
做最真实的技术分享,感谢大家的支持,如有疑惑评论区见,如果文章对你有帮助,留下你的三连。