前言
距寫完上一篇 Apktool 源碼分析那些一定要懂的細(xì)節(jié)(上篇)、大約快接近9個月,9個月時間轉(zhuǎn)瞬即逝。記得寫上篇文章時候還在上一家公司干活。此時卻在新公司任職。
原計劃下篇文章可能會被無限擱置的,因為從源碼理解、分析和梳理,再通過文章的形式寫出來,是非常耗時耗力的。最近這半年,從職業(yè)生涯、技術(shù)和思想認(rèn)知上有了一定提升。有想要改變一下自己想法。所以決定把下篇文章寫完。在我的認(rèn)知里:程-序-人-生,程序、人生。寫程序和做人一樣的,有始有終 。不是嗎?

梳理構(gòu)建邏輯
一個正常apk反編譯(解碼)后的文件結(jié)構(gòu)和層級,如下圖所示的。apktool可以解碼,同樣也有構(gòu)建(build)的能力。構(gòu)建不難理解,把需要修改的文件,修改好后重新構(gòu)建生成 apk。那如何構(gòu)建apk的呢? 這個操作你好奇嗎?好奇就跟著我的邏輯一起梳理一下。

常規(guī)情況下在終端或者dos窗口 輸入 apktool b /Users/user/Downloads/Apktool/demo 過一會就可在dist文件夾下找到構(gòu)建好的apk。但是要注意dist/xx.apk是重新構(gòu)建后的,需要重新簽名才可以安裝在手機上的。

在終端或者dos窗口 總會看到如下日志輸出,這個日志自上到下已經(jīng)很好的解釋了構(gòu)建的邏輯,跟著日志一起來看一下源碼。 注:本文基于2.6.1版本的源碼做分析,較2.5. x邏輯上區(qū)別不大
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()看起,當(dāng)你輸入apktool b 的時候已經(jīng)執(zhí)行到cmdBuild() ,如果不指定其他命令參數(shù),可以走到最核心方法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:反編譯后根據(jù)app名稱生成同名文件夾。就是 apktool b 后面的路徑xxx/xxx/demo可以結(jié)合上圖來看。
outFile:是輸出具體位置和apk名字。你不輸入-o 參數(shù),默認(rèn)是null,后面有代碼對null進行處理邏輯。
如下圖所示,回編最核心的邏輯,為了防止大家迷糊。我畫了一份腦圖供大家參考,我會根據(jù)腦圖所畫內(nèi)容進行梳理。(邏輯順序自上而下一個個來說)

//構(gòu)建回編核心邏輯代碼展示
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(); //創(chuàng)建 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...");
}
前置準(zhǔn)備
readMetaFile: 從命名上可知,意為讀取apktool.yml文件的意思。這部分內(nèi)容較為簡單,是通過反編譯時把對應(yīng)的文件數(shù)據(jù)寫到 apktool.yml(第三方開源庫 snakeyaml),構(gòu)建時再把寫入的數(shù)據(jù)讀出來 ,在對應(yīng)的設(shè)置到MetaInfo 對象屬性里。mMinSdkVersion 是從apktool.yml讀取出來的值,最后生成dex文件時候會用到。
outFile:在上面提及過,如果你沒指定了 -O 命令參數(shù), outFile對null進行邏輯處理。既然你沒有指定apk輸出路徑,那apktool 默認(rèn)給你指定一個路徑。如果在apktool.yml讀取到的apkFileName屬性等于null,構(gòu)建后的apk會以dist/out.apk 作為文件名構(gòu)建輸出,反之dist/demo.apk(我的apkFileName屬性等于demo.apk)為 文件名構(gòu)建輸出。

buildSources
核心邏輯部分,這部分邏輯重點是處理的是smali和dex文件,這么說你可能不是很理解。接著往下看,我會詳細(xì)說明。
buildSourcesRaw
首先會判斷demo/classes.dex是否存在。常規(guī)操作(apktool b demo.apk)會直接返回false,不在執(zhí)行后續(xù)邏輯。但是你要是增加了-s 或者--no-src 參數(shù)命令(意思是不處理dex文件,此命令適用于只修改資源和清單文件的需求,會增快構(gòu)建速度)。后續(xù)邏輯還是會執(zhí)行的。
demo/classes.dex存在,說明用到了--no-src命名參數(shù)。執(zhí)行的邏輯比較簡單粗暴直接把classes.dex文件,通過流的形式寫入到demo/build/apk/文件夾下。
buildSourcesSmali
首先會判斷demo/smali文件夾是否存在,不存在返回false。正常情況(apktool b demo.apk)下smali文件夾是一定存在,存在smali文件夾就會繼續(xù)往下執(zhí)行邏輯。接著有兩個條件作為判斷(必定有一個條件滿足),第一個條件文件是否覆蓋,指的是 -f 參數(shù)命令 。第二個條件文件是否做修改。
兩個條件中有任何一個成立,執(zhí)行SmaliBuilder.build(),該方法執(zhí)行邏輯就是將smali文件夾里面的.smali文件 轉(zhuǎn)換合并成classes.dex文件。這個不往下深究,作者進行了適當(dāng)?shù)姆庋b,最終通過傳過來的參數(shù),調(diào)用第三方庫org.jf.dexlib2。
總結(jié):buildSources()簡單說 ,就是對安卓代碼進行處理,要重新生成apk,安卓代碼文件肯定是要以.dex文件形式存在。代碼無外乎.dex文件或者是smali文件夾形式存在。buildSources()分別對這兩種情況進行邏輯處理。
buildNonDefaultSources
大家看了上面buildSources(),這部分也就好說了不少。首先會遍歷demo/所有文件夾,如果找到smali_開頭,執(zhí)行buildSourcesRaw()和buildSourcesSmali(),代碼在上面說的很細(xì)我就不再啰嗦。
如果找不到smali_開頭,首先會跳過classes.dex文件,為啥?因為在buildSources()已經(jīng)把邏輯處理過了,沒必要重復(fù)處理。只要demo文件夾下有.dex后綴結(jié)尾文件,執(zhí)行buildSourcesRaw()也就是把對應(yīng)多.dex文件復(fù)制到demo/build/apk/下。
總結(jié):這部分內(nèi)容就是buildSources()升級,對多個dex文件和多個smali文件夾進行處理。你無法保證apk不會方法超限,方法超限必定會用Multidex。
buildManifestFile
這部分的邏輯很有意思。首先會判斷demo文件夾下如果存在resource.arsc文件,直接返回不繼續(xù)往下執(zhí)行邏輯。這塊是考慮到 -r或者--no-res參數(shù)命令(不反編譯資源文件,適用于只修改代碼的操作。這個命令的好處就是能增加編譯和編譯的速度)情況。
如果不存在resource.arsc文件繼續(xù)往下看嘍,先對demo文件夾下的清單文件做判斷,如果清單文件存在并且是一個文件,條件成立,如果AndroidManifest.xml.orig臨時文件存在,則刪除。
不存在將原來的清單文件復(fù)制一份,名為AndroidManifest.xml.orig文件,為了讓大家更好理解,可以看下圖所示。AndroidManifest.xml.orig留到最后我再說一下這個文件作用。

還有一個要說的重點: fixingPublicAttrsInProviderAttributes(manifest);先說這個方法是干什么的。原文注釋中解釋的:清單文件中字符串類型是可以@string方式引用的,但是在構(gòu)建中容易中斷,從而阻止應(yīng)用程序安裝,這是來自aosp中的錯誤,公共資源不能成為provider標(biāo)簽內(nèi)屬性一部分。
fixingPublicAttrsInProviderAttributes()經(jīng)過多次的實驗和斷點,執(zhí)行的邏輯:通過Document形式讀取清單文件,找到清單文件中manifest/application/provide/authorities 屬性和 /manifest/application/activity/intent-filter/data/scheme屬性.
如果是@string形式引用的,那么會在res/value/string.xml匹配并替換掉具體的文字值。這個操作目的就是為了避免安卓系統(tǒng)自身的bug,引起的錯誤?;卮鹜戤叀?/p>
忘了補充一個知識點,也是fixingPublicAttrsInProviderAttributes()有關(guān),在2.2.2 版本 apktool有兩個致命的漏洞 。
XXE漏洞
路徑穿越漏洞
有興趣的同學(xué)可以關(guān)注一下這個知識點,什么是XXE漏洞和路徑穿越的漏洞,沒有興趣的同學(xué)繼續(xù)往下看。
//此部分為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) {
}
}
}
/**
* 檢查是否對節(jié)點進行了正確的替換。
* @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版本修復(fù)了。
修復(fù)的方式就是設(shè)置了setAttribute屬性,增加禁止外部實體訪問的設(shè)置。重點可以觀察2.6.1版本ACCESS_EXTERNAL_DTD 、ACCESS_EXTERNAL_SCHEMA 、FEATURE_LOAD_DTD 、FEATURE_DISABLE_DOCTYPE_DECL 這4個屬性。
總結(jié):其實計算機并沒有漏洞,漏洞在于人的身上(別人說過的,我認(rèn)同這個觀點)
buildResources
這部分邏輯是處理資源文件的。資源邏輯包含resource.arsc、res、manifest三類。作者考慮到了三種情況作為邏輯判斷。下列三種,會分別介紹說明。

buildResourcesRaw
判斷resource.arsc文件是否存在,不存在返回false。常規(guī)情況下,也就是沒有指定-r或者--no-res參數(shù)命令下,只會返回false不會往下執(zhí)行。說一下resource.arsc文件存在的邏輯。這部分邏輯和buildSourcesRaw內(nèi)容基本無差別。
有強制覆蓋 -f(buildOptions.forceBuildAll)參數(shù)命令或者文件未被修改,滿足其中任意一個條件,將執(zhí)行resource.arsc、res、manifest三個文件、文件夾復(fù)制到build/apk/,可參考上面buildResources.png截圖。
buildResourcesFull
先判斷res是否存在,不存在返回false。反之繼續(xù)執(zhí)行邏輯,說一下繼續(xù)執(zhí)行的邏輯(常規(guī)情況下res肯定是存在的)。buildOptions.debugMode 需要設(shè)定 -d 或者--debug 參數(shù)命令才會執(zhí)行的邏輯,說一下設(shè)定-d 命令參數(shù)邏輯。
先會判斷你是否設(shè)置了aapt2命令參數(shù),沒有設(shè)置就是用默認(rèn)的aapt,執(zhí)行的邏輯:刪除清單文件調(diào)試標(biāo)簽(android:debuggable)。設(shè)置了aapt2參數(shù)命令,執(zhí)行的邏輯:添加android:debuggable=“true”標(biāo)簽屬性,即app是否可以斷點調(diào)試。
mAndRes.aaptPackage() 我簡單概述一下。首先會區(qū)分不同系統(tǒng)(Mac 、Window、Linux)的aapt和aapt2,這個是apktool源碼中內(nèi)置的文件路徑。然后判斷是否設(shè)置了aapt2命令參數(shù)(use-aapt2),設(shè)置了執(zhí)行aapt2的命令會將res和清單文件 合并成resource.arsc(這里用到 aapt p 命令,后面參數(shù)拼接字符串 執(zhí)行生成resource.arsc),反之用aapt執(zhí)行(執(zhí)行的邏輯和aapt2一樣)。
接著說tmpDir.copyToDir(),從作者的角度考慮到應(yīng)用程序可能是沒有resource.arsc資源的情況,如果執(zhí)行復(fù)制操作可能會出問題。所以加上一段邏輯判斷,如果demo/res文件夾存在,則執(zhí)行resource.arsc、res、manifest三個文件、文件夾復(fù)制到build/apk/。res文件夾不存在則復(fù)制resource.arsc、manifest兩個文件、文件夾復(fù)制到build/apk/。 可參考上面buildResources.png截圖。
buildManifest
這部分內(nèi)容可以參考buildResourcesFull邏輯,操作沒啥區(qū)別,沒有細(xì)說的必要。唯一的不同就是單獨只考慮有清單文件情況下 ,復(fù)制操作也只是針對清單文件。(偷個懶,手動滑稽)
buildLibs
這段知識點較少,我簡單說一下。lib、libs、kotlin、META-INF/servcies、四類文件、文件夾。該類文件或者文件夾不存在直接返回,不繼續(xù)執(zhí)行邏輯。如果存在直接復(fù)制到build/apk/文件下 ,例如demo/build/apk/libs。
科普一下META-INF/servcies:第三方j(luò)ar包用到j(luò)ava的SPI機制,正常簽名時,會在META-INF文件夾下增加services文件夾及其內(nèi)容(這些都是APP運行時要用到的)
buildCopyOriginalFiles
這部分內(nèi)容很簡單,是否設(shè)置了-c 或者--copy-original參數(shù)命令。沒有設(shè)置接下來的邏輯不會執(zhí)行。設(shè)置了此命令參數(shù),執(zhí)行的邏輯將原始的清單文件和META-INF文件復(fù)制到build/apk/文件夾下。沒什么可說了?。?!
buildApk

執(zhí)行完buildLibs()邏輯后,build/apk下所有文件,滿足生成apk條件。apk本質(zhì)上就是一個zip壓縮包,干過安卓的都應(yīng)該知道。
先說一下代碼執(zhí)行邏輯,先判斷一下dist/xx.apk是否存在,存在刪除,不存在創(chuàng)建一個dist/xxx.apk文件夾,然后對assets文件夾進行判斷,不存在等于null,存在直接將路徑傳到zipPackage()里面作為參數(shù)。zipPackage() ----> ZipUtils.zipFolders() ----> processFolder()這個是調(diào)用順序。
processFolder()這里面有一個比較關(guān)鍵的屬性,ZipEntry.STORED:打包歸檔存儲,意思是僅打包歸檔存儲,文件大小基本不變。ZipEntry.DEFLATED:壓縮存儲。
processFolder()遍歷的是build/apk下的所有文件。先判斷是否是一個正常的文件,正常的文件通過zip流的形式寫入到dist/xx.apk里。如果是文件夾再次執(zhí)行processFolder()直到把所有流數(shù)據(jù)寫完(無限套娃,手動滑稽)。IOUtils.copy()看命名意思是復(fù)制,其實是對流文件讀、寫數(shù)據(jù)。
之前提到的assets文件夾如果存在,也是執(zhí)行processFolder(),無限套娃直到把數(shù)據(jù)寫到dist/xx.apk寫完為止。下圖就是執(zhí)行的效果圖。方便大家理解

總結(jié): 此部分代碼就是將build/apk所有文件 ,通過zip文件流構(gòu)建到一個新的apk里面。
buildUnknownFiles
執(zhí)行完buildApk()邏輯你以為apk就構(gòu)建完了嗎? 不不 ,還有未知文件的存在。未知文件是什么意思我在上篇已經(jīng)說過,不再復(fù)述。好了一家人就要板板正正的在一起,怎么能少了未知文件呢?。?!
copyExistingFiles:首先將dist/demo.apk重新命名成demo.apk_apktool_temp,然后新創(chuàng)建一個demo.apk的文件。把demo.apk_apktool_temp數(shù)據(jù)通過zip流的形式寫入到新創(chuàng)建的demo.apk文件中

copyUnknownFiles:將demo/unknown里面的文件遍歷讀取一遍,這里還會獲取apktool.yml里面的unknowFiles屬性。區(qū)分一下ZipEntry.STORED等于0(不壓縮)和ZipEntry.DEFLATED等于 8(壓縮)。
下面截圖是apktool.yml文件里的屬性,key就是未知文件文件名 (代碼里面處理了路徑穿越的漏洞,感興趣了可以了解一下),value就是 0或者8 ,分別對應(yīng)著壓縮和不壓縮的標(biāo)識。
最終將demo/unknown里面的所有數(shù)據(jù)通過zip流形式寫到新生成的demo.apk文件中。最終將dist/demo.apk.apktool_temp文件刪除。

總結(jié):demo.apk.apktool_temp就是做數(shù)據(jù)交換用的, 把原始數(shù)據(jù)和未知文件數(shù)據(jù),寫入到新的demo.apk中,這樣新生成的apk文件不會缺少文件。
buildUnknownFiles執(zhí)行完成候,別忘了還有AndroidManifest.xml.orig文件要處理掉的,先會判斷 清單文件是否是一個文件并且清單文件要存在,還要AndroidManifest.xml.orig存在。三個條件都滿足刪除原始的AndroidManifest.xml.,然后把AndroidManifest.xml.orig重命名為AndroidManifest.xml。到此apktool構(gòu)建流程終了。
談?wù)勎业氖斋@
當(dāng)前北京時間 2021年11月21日凌晨2點45,不知不覺寫的很晚??偹闶前盐恼聦懲炅?,也算是對自己和關(guān)注的同學(xué)有個交代吧。說說我通讀源碼后的感受和收獲
先說說作者方面,類名、方法名 ,變量名還有屬性命名非常專業(yè)規(guī)范,完全不需要任何多余的注釋就能知道作者要做的邏輯是什么。再談?wù)勎遥m然命名上也在不斷的規(guī)范,個人感覺總是少點什么。通讀apktool源碼后已經(jīng)有個一個明確的方向。我還有一個不好習(xí)慣,不管方法名是否通俗易懂都喜歡加上注釋方便別人能理解?,F(xiàn)在我對這個思想有個改觀,好的規(guī)范簡單明了一看就知道什么意思,完全不需要多余的注釋,除非是真的不好理解,才加注釋。
安全方面:理解源碼的目的就是為站在巨人的肩膀上看世界,從作者的思想角度看問題。學(xué)習(xí)理解源碼也是不斷的提升自己的見識和邏輯,從而應(yīng)用到工作中。我是搞游戲SDK方面的,我們的依賴庫中也包含了XML文件的讀寫操作,那是不是也會存在apktool源碼中路徑穿越和XXE漏洞呢? 既然文中已經(jīng)有辦法解決這個漏洞是否把這樣的操作移植到工作中(陳述句)。安全方面也是容易忽略的點,個人覺得在寫完代碼和邏輯后,也要考慮一下安全方面的隱患。
邏輯細(xì)節(jié):通讀源碼給我最直觀的感覺,作者和他的團隊的邏輯真的可怕。把任何可能發(fā)生各個細(xì)節(jié)都考慮的非常到位。聯(lián)想到自己,平時寫代碼時候是不是應(yīng)該把學(xué)到這份細(xì)節(jié)和邏輯帶到工作中,把一些可能發(fā)生的問題都處理到位。
閱讀源碼:剛剛接觸apktool源碼的時候,完全無從下手,不知道從哪里看起,跟無頭蒼蠅一樣。后來我找到一個竅門,雖然源碼看起來很龐大,核心的功能只有兩個 解碼 和構(gòu)建。那么為何不從一個切點看起,先看如果解碼apk。通篇只看解碼的流程,第一遍肯定是理解很少,多看幾遍??戳藥妆榛旧夏苣:牧私獯蟾牛缓笳乙粋€apk 下斷點一步一步的執(zhí)行和分析。多分析多執(zhí)行,你會發(fā)現(xiàn)思路和邏輯越來越清晰。而且還要問自己,為什么要這樣寫?這樣寫有什么好處。從作者的角度和思想做考慮。
全篇結(jié)尾
本文我寫的很細(xì),眾口難調(diào)也不知道能否讓各位看官滿意。希望大家喜歡,有不了解的地方。歡迎私信交流。你的點贊 支持 是我寫文的最大動力 。