前言
距写完上一篇 Apktool 源码分析那些一定要懂的细节(上篇)、大约快接近9个月,9个月时间转瞬即逝。记得写上篇文章时候还在上一家公司干活。此时却在新公司任职。
原计划下篇文章可能会被无限搁置的,因为从源码理解、分析和梳理,再通过文章的形式写出来,是非常耗时耗力的。最近这半年,从职业生涯、技术和思想认知上有了一定提升。有想要改变一下自己想法。所以决定把下篇文章写完。在我的认知里:程-序-人-生,程序、人生。写程序和做人一样的,有始有终 。不是吗?
梳理构建逻辑
一个正常apk反编译(解码)后的文件结构和层级,如下图所示的。apktool可以解码,同样也有构建(build)的能力。构建不难理解,把需要修改的文件,修改好后重新构建生成 apk。那如何构建apk的呢? 这个操作你好奇吗?好奇就跟着我的逻辑一起梳理一下。
常规情况下在终端或者dos窗口 输入 apktool b /Users/user/Downloads/Apktool/demo 过一会就可在dist文件夹下找到构建好的apk。但是要注意dist/xx.apk是重新构建后的,需要重新签名才可以安装在手机上的。
在终端或者dos窗口 总会看到如下日志输出,这个日志自上到下已经很好的解释了构建的逻辑,跟着日志一起来看一下源码。 注:本文基于2.6.1版本的源码做分析,较2.5. x逻辑上区别不大
I: Using Apktool 2.6.1-dirty
I: Checking whether sources has changed...
I: Smaling smali folder into classes.dex...
I: Checking whether sources has changed...
I: Smaling smali_classes2 folder into classes2.dex...
I: Checking whether resources has changed...
I: Building resources...
I: Copying libs... (/lib)
I: Copying libs... (/kotlin)
I: Building apk file...
I: Copying unknown files/dir...
I: Built apk...
先从cmdBuild()看起,当你输入apktool b 的时候已经执行到cmdBuild() ,如果不指定其他命令参数,可以走到最核心方法new Androlib(buildOptions).build();
private static void cmdBuild(CommandLine cli) {
String[] args = cli.getArgs();
String appDirName = args.length < 2 ? "." : args[1];
File outFile;
BuildOptions buildOptions = new BuildOptions();
// check for build options
if (cli.hasOption("f") || cli.hasOption("force-all")) {
buildOptions.forceBuildAll = true;
}
if (cli.hasOption("d") || cli.hasOption("debug")) {
buildOptions.debugMode = true;
}
if (cli.hasOption("v") || cli.hasOption("verbose")) {
buildOptions.verbose = true;
}
if (cli.hasOption("a") || cli.hasOption("aapt")) {
buildOptions.aaptPath = cli.getOptionValue("a");
}
if (cli.hasOption("c") || cli.hasOption("copy-original")) {
System.err.println("-c/--copy-original has been deprecated. Removal planned for v3.0.0 (#2129)");
buildOptions.copyOriginalFiles = true;
}
if (cli.hasOption("p") || cli.hasOption("frame-path")) {
buildOptions.frameworkFolderLocation = cli.getOptionValue("p");
}
if (cli.hasOption("nc") || cli.hasOption("no-crunch")) {
buildOptions.noCrunch = true;
}
// Temporary flag to enable the use of aapt2. This will transform in time to a use-aapt1 flag, which will be
// legacy and eventually removed.
if (cli.hasOption("use-aapt2")) {
buildOptions.useAapt2 = true;
}
if (cli.hasOption("api") || cli.hasOption("api-level")) {
buildOptions.forceApi = Integer.parseInt(cli.getOptionValue("api"));
}
if (cli.hasOption("o") || cli.hasOption("output")) {
outFile = new File(cli.getOptionValue("o"));
} else {
outFile = null;
}
// try and build apk
try {
if (cli.hasOption("a") || cli.hasOption("aapt")) {
buildOptions.aaptVersion = AaptManager.getAaptVersion(cli.getOptionValue("a"));
}
new Androlib(buildOptions).build(new File(appDirName), outFile);
} catch (BrutException ex) {
System.err.println(ex.getMessage());
System.exit(1);
}
}
appDirName:反编译后根据app名称生成同名文件夹。就是 apktool b 后面的路径xxx/xxx/demo可以结合上图来看。
outFile:是输出具体位置和apk名字。你不输入-o 参数,默认是null,后面有代码对null进行处理逻辑。
如下图所示,回编最核心的逻辑,为了防止大家迷糊。我画了一份脑图供大家参考,我会根据脑图所画内容进行梳理。(逻辑顺序自上而下一个个来说)
//构建回编核心逻辑代码展示
public void build(ExtFile appDir, File outFile) throws BrutException {
LOGGER.info("Using Apktool " + Androlib.getVersion());
MetaInfo meta = readMetaFile(appDir); //读取apktool.yml文件
buildOptions.isFramework = meta.isFrameworkApk;
buildOptions.resourcesAreCompressed = meta.compressionType;
buildOptions.doNotCompress = meta.doNotCompress;
mAndRes.setSdkInfo(meta.sdkInfo);
mAndRes.setPackageId(meta.packageInfo);
mAndRes.setPackageRenamed(meta.packageInfo);
mAndRes.setVersionInfo(meta.versionInfo);
mAndRes.setSharedLibrary(meta.sharedLibrary);
mAndRes.setSparseResources(meta.sparseResources);
if (meta.sdkInfo != null && meta.sdkInfo.get("minSdkVersion") != null) {
String minSdkVersion = meta.sdkInfo.get("minSdkVersion");
mMinSdkVersion = mAndRes.getMinSdkVersionFromAndroidCodename(meta, minSdkVersion);
}
if (outFile == null) {
String outFileName = meta.apkFileName;
outFile = new File(appDir, "dist" + File.separator + (outFileName == null ? "out.apk" : outFileName)); //outFileName为空 输出文件为dist/out.apk ,反之dist/demo.apk
}
new File(appDir, APK_DIRNAME).mkdirs(); //创建 build/apk 文件夹
File manifest = new File(appDir, "AndroidManifest.xml");
File manifestOriginal = new File(appDir, "AndroidManifest.xml.orig");
buildSources(appDir);
buildNonDefaultSources(appDir);
buildManifestFile(appDir, manifest, manifestOriginal);
buildResources(appDir, meta.usesFramework);
buildLibs(appDir);
buildCopyOriginalFiles(appDir);
buildApk(appDir, outFile);
buildUnknownFiles(appDir, outFile, meta);
if (manifest.isFile() && manifest.exists() && manifestOriginal.isFile()) {
try {
if (new File(appDir, "AndroidManifest.xml").delete()) {
FileUtils.moveFile(manifestOriginal, manifest);
}
} catch (IOException ex) {
throw new AndrolibException(ex.getMessage());
}
}
LOGGER.info("Built apk...");
}
前置准备
readMetaFile: 从命名上可知,意为读取apktool.yml文件的意思。这部分内容较为简单,是通过反编译时把对应的文件数据写到 apktool.yml(第三方开源库 snakeyaml),构建时再把写入的数据读出来 ,在对应的设置到MetaInfo 对象属性里。mMinSdkVersion 是从apktool.yml读取出来的值,最后生成dex文件时候会用到。
outFile:在上面提及过,如果你没指定了 -O 命令参数, outFile对null进行逻辑处理。既然你没有指定apk输出路径,那apktool 默认给你指定一个路径。如果在apktool.yml读取到的apkFileName属性等于null,构建后的apk会以dist/out.apk 作为文件名构建输出,反之dist/demo.apk(我的apkFileName属性等于demo.apk)为 文件名构建输出。
buildSources
核心逻辑部分,这部分逻辑重点是处理的是smali和dex文件,这么说你可能不是很理解。接着往下看,我会详细说明。
buildSourcesRaw
首先会判断demo/classes.dex是否存在。常规操作(apktool b demo.apk)会直接返回false,不在执行后续逻辑。但是你要是增加了-s 或者–no-src 参数命令(意思是不处理dex文件,此命令适用于只修改资源和清单文件的需求,会增快构建速度)。后续逻辑还是会执行的。
demo/classes.dex存在,说明用到了–no-src命名参数。执行的逻辑比较简单粗暴直接把classes.dex文件,通过流的形式写入到demo/build/apk/文件夹下。
buildSourcesSmali
首先会判断demo/smali文件夹是否存在,不存在返回false。正常情况(apktool b demo.apk)下smali文件夹是一定存在,存在smali文件夹就会继续往下执行逻辑。接着有两个条件作为判断(必定有一个条件满足),第一个条件文件是否覆盖,指的是 -f 参数命令 。第二个条件文件是否做修改。
两个条件中有任何一个成立,执行SmaliBuilder.build(),该方法执行逻辑就是将smali文件夹里面的.smali文件 转换合并成classes.dex文件。这个不往下深究,作者进行了适当的封装,最终通过传过来的参数,调用第三方库org.jf.dexlib2。
总结:buildSources()简单说 ,就是对安卓代码进行处理,要重新生成apk,安卓代码文件肯定是要以.dex文件形式存在。代码无外乎.dex文件或者是smali文件夹形式存在。buildSources()分别对这两种情况进行逻辑处理。
buildNonDefaultSources
大家看了上面buildSources(),这部分也就好说了不少。首先会遍历demo/所有文件夹,如果找到smali_开头,执行buildSourcesRaw()和buildSourcesSmali(),代码在上面说的很细我就不再啰嗦。
如果找不到smali_开头,首先会跳过classes.dex文件,为啥?因为在buildSources()已经把逻辑处理过了,没必要重复处理。只要demo文件夹下有.dex后缀结尾文件,执行buildSourcesRaw()也就是把对应多.dex文件复制到demo/build/apk/下。
总结:这部分内容就是buildSources()升级,对多个dex文件和多个smali文件夹进行处理。你无法保证apk不会方法超限,方法超限必定会用Multidex。
buildManifestFile
这部分的逻辑很有意思。首先会判断demo文件夹下如果存在resource.arsc文件,直接返回不继续往下执行逻辑。这块是考虑到 -r或者–no-res参数命令(不反编译资源文件,适用于只修改代码的操作。这个命令的好处就是能增加编译和编译的速度)情况。
如果不存在resource.arsc文件继续往下看喽,先对demo文件夹下的清单文件做判断,如果清单文件存在并且是一个文件,条件成立,如果AndroidManifest.xml.orig临时文件存在,则删除。
不存在将原来的清单文件复制一份,名为AndroidManifest.xml.orig文件,为了让大家更好理解,可以看下图所示。AndroidManifest.xml.orig留到最后我再说一下这个文件作用。
还有一个要说的重点: fixingPublicAttrsInProviderAttributes(manifest);先说这个方法是干什么的。原文注释中解释的:清单文件中字符串类型是可以@string方式引用的,但是在构建中容易中断,从而阻止应用程序安装,这是来自aosp中的错误,公共资源不能成为provider标签内属性一部分。
fixingPublicAttrsInProviderAttributes()经过多次的实验和断点,执行的逻辑:通过Document形式读取清单文件,找到清单文件中manifest/application/provide/authorities 属性和 /manifest/application/activity/intent-filter/data/scheme属性.
如果是@string形式引用的,那么会在res/value/string.xml匹配并替换掉具体的文字值。这个操作目的就是为了避免安卓系统自身的bug,引起的错误。回答完毕。
忘了补充一个知识点,也是fixingPublicAttrsInProviderAttributes()有关,在2.2.2 版本 apktool有两个致命的漏洞 。
-
XXE漏洞
-
路径穿越漏洞
有兴趣的同学可以关注一下这个知识点,什么是XXE漏洞和路径穿越的漏洞,没有兴趣的同学继续往下看。
//此部分为apktool_2.2.2版本的代码
package brut.androlib.res.xml;
import brut.androlib.AndrolibException;
import java.io.File;
import java.io.IOException;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import org.w3c.dom.Document;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
public final class ResXmlPatcher {
public static void removeApplicationDebugTag(File file) throws AndrolibException {
if (file.exists()) {
try {
Document doc = loadDocument(file);
NamedNodeMap attr = doc.getElementsByTagName("application").item(0).getAttributes();
if (attr.getNamedItem("android:debuggable") != null) {
attr.removeNamedItem("android:debuggable");
}
saveDocument(file, doc);
} catch (IOException | ParserConfigurationException | TransformerException | SAXException e) {
}
}
}
public static void fixingPublicAttrsInProviderAttributes(File file) throws AndrolibException {
Node provider;
String replacement;
Node provider2;
String replacement2;
boolean saved = false;
if (file.exists()) {
try {
Document doc = loadDocument(file);
NodeList nodes = (NodeList) XPathFactory.newInstance().newXPath().compile("/manifest/application/provider").evaluate(doc, XPathConstants.NODESET);
for (int i = 0; i < nodes.getLength(); i++) {
NamedNodeMap attrs = nodes.item(i).getAttributes();
if (!(attrs == null || (provider2 = attrs.getNamedItem("android:authorities")) == null || (replacement2 = pullValueFromStrings(file.getParentFile(), provider2.getNodeValue())) == null)) {
provider2.setNodeValue(replacement2);
saved = true;
}
}
NodeList nodes2 = (NodeList) XPathFactory.newInstance().newXPath().compile("/manifest/application/activity/intent-filter/data").evaluate(doc, XPathConstants.NODESET);
for (int i2 = 0; i2 < nodes2.getLength(); i2++) {
NamedNodeMap attrs2 = nodes2.item(i2).getAttributes();
if (!(attrs2 == null || (provider = attrs2.getNamedItem("android:scheme")) == null || (replacement = pullValueFromStrings(file.getParentFile(), provider.getNodeValue())) == null)) {
provider.setNodeValue(replacement);
saved = true;
}
}
if (saved) {
saveDocument(file, doc);
}
} catch (IOException | ParserConfigurationException | TransformerException | XPathExpressionException | SAXException e) {
}
}
}
public static String pullValueFromStrings(File directory, String key) throws AndrolibException {
if (key == null || !key.contains("@")) {
return null;
}
File file = new File(directory, "/res/values/strings.xml");
String key2 = key.replace("@string/", "");
if (file.exists()) {
try {
Object result = XPathFactory.newInstance().newXPath().compile("/resources/string[@name=\"" + key2 + "\"]/text()").evaluate(loadDocument(file), XPathConstants.STRING);
if (result != null) {
return (String) result;
}
} catch (IOException | ParserConfigurationException | XPathExpressionException | SAXException e) {
}
}
return null;
}
public static void removeManifestVersions(File file) throws AndrolibException {
if (file.exists()) {
try {
Document doc = loadDocument(file);
NamedNodeMap attr = doc.getFirstChild().getAttributes();
Node vCode = attr.getNamedItem("android:versionCode");
Node vName = attr.getNamedItem("android:versionName");
if (vCode != null) {
attr.removeNamedItem("android:versionCode");
}
if (vName != null) {
attr.removeNamedItem("android:versionName");
}
saveDocument(file, doc);
} catch (IOException | ParserConfigurationException | TransformerException | SAXException e) {
}
}
}
public static void renameManifestPackage(File file, String packageOriginal) throws AndrolibException {
try {
Document doc = loadDocument(file);
doc.getFirstChild().getAttributes().getNamedItem("package").setNodeValue(packageOriginal);
saveDocument(file, doc);
} catch (IOException | ParserConfigurationException | TransformerException | SAXException e) {
}
}
private static Document loadDocument(File file) throws IOException, SAXException, ParserConfigurationException {
return DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file);
}
private static void saveDocument(File file, Document doc) throws IOException, SAXException, ParserConfigurationException, TransformerException {
Transformer transformer = TransformerFactory.newInstance().newTransformer();
transformer.setOutputProperty("indent", "yes");
transformer.setOutputProperty("standalone", "yes");
transformer.transform(new DOMSource(doc), new StreamResult(file));
}
}
//此部分的代码是2.6.1版本的
package brut.androlib.res.xml;
import brut.androlib.AndrolibException;
import org.w3c.dom.Document;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.xpath.*;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.logging.Logger;
public final class ResXmlPatcher {
/**
* Removes "debug" tag from file
*
* @param file AndroidManifest file
* @throws AndrolibException Error reading Manifest file
*/
public static void removeApplicationDebugTag(File file) throws AndrolibException {
if (file.exists()) {
try {
Document doc = loadDocument(file);
Node application = doc.getElementsByTagName("application").item(0);
// load attr
NamedNodeMap attr = application.getAttributes();
Node debugAttr = attr.getNamedItem("android:debuggable");
// remove application:debuggable
if (debugAttr != null) {
attr.removeNamedItem("android:debuggable");
}
saveDocument(file, doc);
} catch (SAXException | ParserConfigurationException | IOException | TransformerException ignored) {
}
}
}
/**
* Sets "debug" tag in the file to true
*
* @param file AndroidManifest file
*/
public static void setApplicationDebugTagTrue(File file) {
if (file.exists()) {
try {
Document doc = loadDocument(file);
Node application = doc.getElementsByTagName("application").item(0);
// load attr
NamedNodeMap attr = application.getAttributes();
Node debugAttr = attr.getNamedItem("android:debuggable");
if (debugAttr == null) {
debugAttr = doc.createAttribute("android:debuggable");
attr.setNamedItem(debugAttr);
}
// set application:debuggable to 'true
debugAttr.setNodeValue("true");
saveDocument(file, doc);
} catch (SAXException | ParserConfigurationException | IOException | TransformerException ignored) {
}
}
}
/**
* Any @string reference in a provider value in AndroidManifest.xml will break on
* build, thus preventing the application from installing. This is from a bug/error
* in AOSP where public resources cannot be part of an authorities attribute within
* a provider tag.
*
* This finds any reference and replaces it with the literal value found in the
* res/values/strings.xml file.
*
* @param file File for AndroidManifest.xml
*/
public static void fixingPublicAttrsInProviderAttributes(File file) {
boolean saved = false;
if (file.exists()) {
try {
Document doc = loadDocument(file);
XPath xPath = XPathFactory.newInstance().newXPath();
XPathExpression expression = xPath.compile("/manifest/application/provider");
Object result = expression.evaluate(doc, XPathConstants.NODESET);
NodeList nodes = (NodeList) result;
for (int i = 0; i < nodes.getLength(); i++) {
Node node = nodes.item(i);
NamedNodeMap attrs = node.getAttributes();
if (attrs != null) {
Node provider = attrs.getNamedItem("android:authorities");
if (provider != null) {
saved = isSaved(file, saved, provider);
}
}
}
// android:scheme
xPath = XPathFactory.newInstance().newXPath();
expression = xPath.compile("/manifest/application/activity/intent-filter/data");
result = expression.evaluate(doc, XPathConstants.NODESET);
nodes = (NodeList) result;
for (int i = 0; i < nodes.getLength(); i++) {
Node node = nodes.item(i);
NamedNodeMap attrs = node.getAttributes();
if (attrs != null) {
Node provider = attrs.getNamedItem("android:scheme");
if (provider != null) {
saved = isSaved(file, saved, provider);
}
}
}
if (saved) {
saveDocument(file, doc);
}
} catch (SAXException | ParserConfigurationException | IOException |
XPathExpressionException | TransformerException ignored) {
}
}
}
/**
* 检查是否对节点进行了正确的替换。
* @param file File we are searching for value
* @param saved boolean on whether we need to save
* @param provider Node we are attempting to replace
* @return boolean
*/
private static boolean isSaved(File file, boolean saved, Node provider) {
String reference = provider.getNodeValue();
String replacement = pullValueFromStrings(file.getParentFile(), reference);
if (replacement != null) {
provider.setNodeValue(replacement);
saved = true;
}
return saved;
}
/**
* Finds key in strings.xml file and returns text value
*
* @param directory Root directory of apk
* @param key String reference (ie @string/foo)
* @return String|null
*/
public static String pullValueFromStrings(File directory, String key) {
if (key == null || ! key.contains("@")) {
return null;
}
File file = new File(directory, "/res/values/strings.xml");
key = key.replace("@string/", "");
if (file.exists()) {
try {
Document doc = loadDocument(file);
XPath xPath = XPathFactory.newInstance().newXPath();
XPathExpression expression = xPath.compile("/resources/string[@name=" + '"' + key + "\"]/text()");
Object result = expression.evaluate(doc, XPathConstants.STRING);
if (result != null) {
return (String) result;
}
} catch (SAXException | ParserConfigurationException | IOException | XPathExpressionException ignored) {
}
}
return null;
}
/**
* Finds key in integers.xml file and returns text value
*
* @param directory Root directory of apk
* @param key Integer reference (ie @integer/foo)
* @return String|null
*/
public static String pullValueFromIntegers(File directory, String key) {
if (key == null || ! key.contains("@")) {
return null;
}
File file = new File(directory, "/res/values/integers.xml");
key = key.replace("@integer/", "");
if (file.exists()) {
try {
Document doc = loadDocument(file);
XPath xPath = XPathFactory.newInstance().newXPath();
XPathExpression expression = xPath.compile("/resources/integer[@name=" + '"' + key + "\"]/text()");
Object result = expression.evaluate(doc, XPathConstants.STRING);
if (result != null) {
return (String) result;
}
} catch (SAXException | ParserConfigurationException | IOException | XPathExpressionException ignored) {
}
}
return null;
}
/**
* Removes attributes like "versionCode" and "versionName" from file.
*
* @param file File representing AndroidManifest.xml
*/
public static void removeManifestVersions(File file) {
if (file.exists()) {
try {
Document doc = loadDocument(file);
Node manifest = doc.getFirstChild();
NamedNodeMap attr = manifest.getAttributes();
Node vCode = attr.getNamedItem("android:versionCode");
Node vName = attr.getNamedItem("android:versionName");
if (vCode != null) {
attr.removeNamedItem("android:versionCode");
}
if (vName != null) {
attr.removeNamedItem("android:versionName");
}
saveDocument(file, doc);
} catch (SAXException | ParserConfigurationException | IOException | TransformerException ignored) {
}
}
}
/**
* Replaces package value with passed packageOriginal string
*
* @param file File for AndroidManifest.xml
* @param packageOriginal Package name to replace
*/
public static void renameManifestPackage(File file, String packageOriginal) {
try {
Document doc = loadDocument(file);
// Get the manifest line
Node manifest = doc.getFirstChild();
// update package attribute
NamedNodeMap attr = manifest.getAttributes();
Node nodeAttr = attr.getNamedItem("package");
nodeAttr.setNodeValue(packageOriginal);
saveDocument(file, doc);
} catch (SAXException | ParserConfigurationException | IOException | TransformerException ignored) {
}
}
/**
*
* @param file File to load into Document
* @return Document
* @throws IOException
* @throws SAXException
* @throws ParserConfigurationException
*/
private static Document loadDocument(File file)
throws IOException, SAXException, ParserConfigurationException {
DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance();
docFactory.setFeature(FEATURE_DISABLE_DOCTYPE_DECL, true);
docFactory.setFeature(FEATURE_LOAD_DTD, false);
try {
docFactory.setAttribute(ACCESS_EXTERNAL_DTD, " ");
docFactory.setAttribute(ACCESS_EXTERNAL_SCHEMA, " ");
} catch (IllegalArgumentException ex) {
LOGGER.warning("JAXP 1.5 Support is required to validate XML");
}
DocumentBuilder docBuilder = docFactory.newDocumentBuilder();
// Not using the parse(File) method on purpose, so that we can control when
// to close it. Somehow parse(File) does not seem to close the file in all cases.
try (FileInputStream inputStream = new FileInputStream(file)) {
return docBuilder.parse(inputStream);
}
}
/**
*
* @param file File to save Document to (ie AndroidManifest.xml)
* @param doc Document being saved
* @throws IOException
* @throws SAXException
* @throws ParserConfigurationException
* @throws TransformerException
*/
private static void saveDocument(File file, Document doc)
throws IOException, SAXException, ParserConfigurationException, TransformerException {
TransformerFactory transformerFactory = TransformerFactory.newInstance();
Transformer transformer = transformerFactory.newTransformer();
DOMSource source = new DOMSource(doc);
StreamResult result = new StreamResult(file);
transformer.transform(source, result);
}
private static final String ACCESS_EXTERNAL_DTD = "http://javax.xml.XMLConstants/property/accessExternalDTD";
private static final String ACCESS_EXTERNAL_SCHEMA = "http://javax.xml.XMLConstants/property/accessExternalSchema";
private static final String FEATURE_LOAD_DTD = "http://apache.org/xml/features/nonvalidating/load-external-dtd";
private static final String FEATURE_DISABLE_DOCTYPE_DECL = "http://apache.org/xml/features/disallow-doctype-decl";
private static final Logger LOGGER = Logger.getLogger(ResXmlPatcher.class.getName());
}
我说一下XXE这部分漏洞,上面分别展示了2.2.2版本和最新版本代码,为方便大家观察,我用了贴源码形式而非截图,大家可以作为参考。XXE漏洞我记得是2.2.3版本修复了。
修复的方式就是设置了setAttribute属性,增加禁止外部实体访问的设置。重点可以观察2.6.1版本ACCESS_EXTERNAL_DTD 、ACCESS_EXTERNAL_SCHEMA 、FEATURE_LOAD_DTD 、FEATURE_DISABLE_DOCTYPE_DECL 这4个属性。
总结:其实计算机并没有漏洞,漏洞在于人的身上(别人说过的,我认同这个观点)
buildResources
这部分逻辑是处理资源文件的。资源逻辑包含resource.arsc、res、manifest三类。作者考虑到了三种情况作为逻辑判断。下列三种,会分别介绍说明。
buildResourcesRaw
判断resource.arsc文件是否存在,不存在返回false。常规情况下,也就是没有指定-r或者–no-res参数命令下,只会返回false不会往下执行。说一下resource.arsc文件存在的逻辑。这部分逻辑和buildSourcesRaw内容基本无差别。
有强制覆盖 -f(buildOptions.forceBuildAll)参数命令或者文件未被修改,满足其中任意一个条件,将执行resource.arsc、res、manifest三个文件、文件夹复制到build/apk/,可参考上面buildResources.png截图。
buildResourcesFull
先判断res是否存在,不存在返回false。反之继续执行逻辑,说一下继续执行的逻辑(常规情况下res肯定是存在的)。buildOptions.debugMode 需要设定 -d 或者–debug 参数命令才会执行的逻辑,说一下设定-d 命令参数逻辑。
先会判断你是否设置了aapt2命令参数,没有设置就是用默认的aapt,执行的逻辑:删除清单文件调试标签(android:debuggable)。设置了aapt2参数命令,执行的逻辑:添加android:debuggable=“true”标签属性,即app是否可以断点调试。
mAndRes.aaptPackage() 我简单概述一下。首先会区分不同系统(Mac 、Window、Linux)的aapt和aapt2,这个是apktool源码中内置的文件路径。然后判断是否设置了aapt2命令参数(use-aapt2),设置了执行aapt2的命令会将res和清单文件 合并成resource.arsc(这里用到 aapt p 命令,后面参数拼接字符串 执行生成resource.arsc),反之用aapt执行(执行的逻辑和aapt2一样)。
接着说tmpDir.copyToDir(),从作者的角度考虑到应用程序可能是没有resource.arsc资源的情况,如果执行复制操作可能会出问题。所以加上一段逻辑判断,如果demo/res文件夹存在,则执行resource.arsc、res、manifest三个文件、文件夹复制到build/apk/。res文件夹不存在则复制resource.arsc、manifest两个文件、文件夹复制到build/apk/。 可参考上面buildResources.png截图。
buildManifest
这部分内容可以参考buildResourcesFull逻辑,操作没啥区别,没有细说的必要。唯一的不同就是单独只考虑有清单文件情况下 ,复制操作也只是针对清单文件。(偷个懒,手动滑稽)
buildLibs
这段知识点较少,我简单说一下。lib、libs、kotlin、META-INF/servcies、四类文件、文件夹。该类文件或者文件夹不存在直接返回,不继续执行逻辑。如果存在直接复制到build/apk/文件下 ,例如demo/build/apk/libs。
科普一下META-INF/servcies:第三方jar包用到java的SPI机制,正常签名时,会在META-INF文件夹下增加services文件夹及其内容(这些都是APP运行时要用到的)
buildCopyOriginalFiles
这部分内容很简单,是否设置了-c 或者–copy-original参数命令。没有设置接下来的逻辑不会执行。设置了此命令参数,执行的逻辑将原始的清单文件和META-INF文件复制到build/apk/文件夹下。没什么可说了!!!
buildApk
执行完buildLibs()逻辑后,build/apk下所有文件,满足生成apk条件。apk本质上就是一个zip压缩包,干过安卓的都应该知道。
先说一下代码执行逻辑,先判断一下dist/xx.apk是否存在,存在删除,不存在创建一个dist/xxx.apk文件夹,然后对assets文件夹进行判断,不存在等于null,存在直接将路径传到zipPackage()里面作为参数。zipPackage() ----> ZipUtils.zipFolders() ----> processFolder()这个是调用顺序。
processFolder()这里面有一个比较关键的属性,ZipEntry.STORED:打包归档存储,意思是仅打包归档存储,文件大小基本不变。ZipEntry.DEFLATED:压缩存储。
processFolder()遍历的是build/apk下的所有文件。先判断是否是一个正常的文件,正常的文件通过zip流的形式写入到dist/xx.apk里。如果是文件夹再次执行processFolder()直到把所有流数据写完(无限套娃,手动滑稽)。IOUtils.copy()看命名意思是复制,其实是对流文件读、写数据。
之前提到的assets文件夹如果存在,也是执行processFolder(),无限套娃直到把数据写到dist/xx.apk写完为止。下图就是执行的效果图。方便大家理解
总结: 此部分代码就是将build/apk所有文件 ,通过zip文件流构建到一个新的apk里面。
buildUnknownFiles
执行完buildApk()逻辑你以为apk就构建完了吗? 不不 ,还有未知文件的存在。未知文件是什么意思我在上篇已经说过,不再复述。好了一家人就要板板正正的在一起,怎么能少了未知文件呢!!!
copyExistingFiles:首先将dist/demo.apk重新命名成demo.apk_apktool_temp,然后新创建一个demo.apk的文件。把demo.apk_apktool_temp数据通过zip流的形式写入到新创建的demo.apk文件中
copyUnknownFiles:将demo/unknown里面的文件遍历读取一遍,这里还会获取apktool.yml里面的unknowFiles属性。区分一下ZipEntry.STORED等于0(不压缩)和ZipEntry.DEFLATED等于 8(压缩)。
下面截图是apktool.yml文件里的属性,key就是未知文件文件名 (代码里面处理了路径穿越的漏洞,感兴趣了可以了解一下),value就是 0或者8 ,分别对应着压缩和不压缩的标识。
最终将demo/unknown里面的所有数据通过zip流形式写到新生成的demo.apk文件中。最终将dist/demo.apk.apktool_temp文件删除。
总结:demo.apk.apktool_temp就是做数据交换用的, 把原始数据和未知文件数据,写入到新的demo.apk中,这样新生成的apk文件不会缺少文件。
buildUnknownFiles执行完成候,别忘了还有AndroidManifest.xml.orig文件要处理掉的,先会判断 清单文件是否是一个文件并且清单文件要存在,还要AndroidManifest.xml.orig存在。三个条件都满足删除原始的AndroidManifest.xml.,然后把AndroidManifest.xml.orig重命名为AndroidManifest.xml。到此apktool构建流程终了。
谈谈我的收获
当前北京时间 2021年11月21日凌晨2点45,不知不觉写的很晚。总算是把文章写完了,也算是对自己和关注的同学有个交代吧。说说我通读源码后的感受和收获
-
先说说作者方面,类名、方法名 ,变量名还有属性命名非常专业规范,完全不需要任何多余的注释就能知道作者要做的逻辑是什么。再谈谈我,虽然命名上也在不断的规范,个人感觉总是少点什么。通读apktool源码后已经有个一个明确的方向。我还有一个不好习惯,不管方法名是否通俗易懂都喜欢加上注释方便别人能理解。现在我对这个思想有个改观,好的规范简单明了一看就知道什么意思,完全不需要多余的注释,除非是真的不好理解,才加注释。
-
安全方面:理解源码的目的就是为站在巨人的肩膀上看世界,从作者的思想角度看问题。学习理解源码也是不断的提升自己的见识和逻辑,从而应用到工作中。我是搞游戏SDK方面的,我们的依赖库中也包含了XML文件的读写操作,那是不是也会存在apktool源码中路径穿越和XXE漏洞呢? 既然文中已经有办法解决这个漏洞是否把这样的操作移植到工作中(陈述句)。安全方面也是容易忽略的点,个人觉得在写完代码和逻辑后,也要考虑一下安全方面的隐患。
-
逻辑细节:通读源码给我最直观的感觉,作者和他的团队的逻辑真的可怕。把任何可能发生各个细节都考虑的非常到位。联想到自己,平时写代码时候是不是应该把学到这份细节和逻辑带到工作中,把一些可能发生的问题都处理到位。
-
阅读源码:刚刚接触apktool源码的时候,完全无从下手,不知道从哪里看起,跟无头苍蝇一样。后来我找到一个窍门,虽然源码看起来很庞大,核心的功能只有两个 解码 和构建。那么为何不从一个切点看起,先看如果解码apk。通篇只看解码的流程,第一遍肯定是理解很少,多看几遍。看了几遍基本上能模糊的了解大概,然后找一个apk 下断点一步一步的执行和分析。多分析多执行,你会发现思路和逻辑越来越清晰。而且还要问自己,为什么要这样写?这样写有什么好处。从作者的角度和思想做考虑。
全篇结尾
本文我写的很细,众口难调也不知道能否让各位看官满意。希望大家喜欢,有不了解的地方。欢迎私信交流。你的点赞 支持 是我写文的最大动力 。