记录下分析资源混淆工具AndResGuard的过程,分析过程中思路可能会有些混乱,有时间会对文章结构做优化调整。
AndResGuard 插件的工作原理,就是创建了一个资源混淆打包任务,该任务会先调用默认的打包任务,在默认打包工作结束后,会解压打好的 apk 包,识别解析包里的 resources.arsc 资源表,然后再混淆 res 文件夹下面的所有资源文件,同时相对应的修改资源表,最后将修改后的资源重新打包签名,生成新的 apk 包。
这是我觉的这个项目比较好的一个点,不会侵入打包流程,而是在最后做这些工作。这个在作者的方案演进里也有讲。
参考资料
项目地址:github.com/shwenzhang/...
项目原理:mp.weixin.qq.com/s?__biz=MzA...
插件分析
groovy
private static void createTask(Project project, variantName) {
def taskName = "resguard${variantName}"
if (project.tasks.findByPath(taskName) == null) {
def task = project.task(taskName, type: AndResGuardTask)
if (variantName != USE_APK_TASK_NAME) {
task.dependsOn "assemble${variantName}"
}
}
}
创建了一系列"resguard变体"的任务,依赖于原始"assemble变体"任务。
除此之外,还创建了一个"resguardUseApk"任务。
gradle任务
具体任务细节逻辑位于AndResGuardTask。
- 构造函数,创建相关BuildInfo。
groovy
buildConfigs << new BuildInfo(
//原始变体apk
outputFile,
//签名信息
variantInfo.signingConfig,
//包名
applicationId,
//buildType
variant.buildType.name,
//productflavor
variant.productFlavors,
variantName,
variant.mergedFlavor.minSdkVersion.apiLevel,
variant.mergedFlavor.targetSdkVersion.apiLevel,
)
buildType只能有一个,productFlavor可以有多个。
- 构造InputParam
这里根据BuildInfo和自定义扩展AndResGuardExtension的交集扩展出InputParam。
groovy
if (targetSDKVersion >= 30) {
// Targeting R+ (version 30 and above) requires the resources.arsc of installed APKs
// to be stored uncompressed and aligned on a 4-byte boundary
this.compressFilePattern.remove(Configuration.ASRC_FILE);
System.out.printf("[Warning] Remove resources.arsc from the compressPattern. (%s)\n",
this.compressFilePattern);
}
如果targetSdkVersion在30以上,resource.arsc就不能被压缩。
接下来任务就转到AndResGuard-core模块做进一步处理。
3.Main
(1)将原始apk解压到temp目录
unziping apk to D:\android_workspace\AndResGuard\AndResGuard-example\app\build\outputs\apk\aaaBlue\release\AndResGuard_app-aaa-blue-release\temp
(2)如keepRoot参数设置为false,那么最终输出的混淆资源目录根目录由res变更为r。
在ensureFilePath这个方法中,勾兑了一些相关的文件和目录,具体逻辑在后面处理。
(3)资源混淆及重组,触及了这个项目的灵魂。
在继续往下分析之前,需要转过头了解下resource.arsc相关的背景,否则阅读代码会有点费劲。
resource.arsc解析
框架的关键在于对resource.arsc的处理,所以有必要了解下resource.arsc到底是个什么东东。
简单来说,构建会生成R.java(或一步到位R.class),资源都生成了对应索引,而resource.arsc就是管理这些的一个二进制文件,能够根据索引找到具体对应的目标资源文件。
1. 运行时的资源管理
运行时我们一般会使用Resource类,其底层对应AssetManager。
app运行时会将resource.arsc解析后读入native层AssetManager中。
c++
// /framework/base/include/androidfw/AssetManager.h
class AssetManager: public AAssetManager {
mutable ResTable* mResources;
}
整个resource.arsc由一个个ResChunk组成,每个ResChunk都有自己的header,里面告知该Chunk的类型和大小。
resources.arsc一共有五种chunk类型,分别为TYPETABLE,TYPEPACKAGE,TYPE_STRING ,TYPETYPE,TYPECONFIG。
一般来说,使用aapt打出来的包里只有一个package,这个package里包含了App中的所有资源信息。
前面说过,R.java(或R.class)会为每个资源生成一个资源id,它是一个32位int,可以表示为0xPPTTEEEE,PP为package id,TT为typeid,EEEE为entry id。
package id,对应用来说一般是0x7f。
type id表示资源的类型,定义在type字符串池中,如图。
TYPECONFIG为type下以资源维度分类的所有资源,每个config下有多个entry,表示该config下的具体资源文件。
需要注意的是,typeid从1开始,entryid从0开始。
2. 资源的分类
资源文件分为3类,一类是以xml格式编码的,一类是正常的资源文件,最后一类就是value目录下的各种资源。
对于文件资源来说,编译后aapt可能不会对文件做任何操作,或者会对文件进行压缩。
对于xml格式资源来说,编译后该文件会二进制化。
对于value目录的资源来说,它们的信息编入resource.arsc,文件本身就没必要存在了。
这里我根据资源type进行分类,列出各类型资源具体使用的文件类型:
3.resource.arsc的三个字符串池。
以一个简单的资源为例:
xml
<resources>
<string name="app_name">ResourceDemo</string>
</resources>
拆解后分别保存在三个字符串池中:
1.table stringblock:保存ResourceDemo。
2.specsname stringblock:保存app_name。
3.typename stringblock:保存string。
4.resource.arsc文件的解构。
想了解一个二进制文件内部的结构,有几个方法。
(1)使用010editor类似的软件直接阅读该二进制文件。
(2)阅读或编写该二进制文件的编解码程序。这里也有一些优秀的工程代码供我们参考,比如matrix-arscutil模块,android-trunk-utils项目,AndResGuard项目模块,aosp工程等。
这里我们祭出这张神图,并简单列出该二进制文件中我们需要关注的重点(网络上该文件解析文章非常多):
(1)以面向对象的视角看待文件中的模块,所有模块都有一些通用的头部字段,将模块抽象为ResChunk。
(2)解析总表Resource Table,可以获取到package count,前面我们讲过,正常的应用一般只有一个Package。
(3)解析Global String Pool,这里对应我们前面的字符串池1。可以解析出一个字符串list和一个字符串map,map以索引为key。
(4)解析Package。读取另外两个字符串池的偏移位置并顺手解析之。接下来解析所有type及其所属的所有资源。
(5)一个Type解析分为两部分,
TypeSpec:该类型对应基本信息,如该type下资源数量。
ConfigList:该类型某一config下资源信息。
TypeSpec和ConfigList是一对多的关系。
(6)一个config下会有多个资源项,使用ResEntry封装。根据资源项名称从字符串池2中解析出对应资源的key名称。
如FLAG_COMPLEX为0(非bag类型),则解析为ResValue,及解析真正资源的值,如果为string,则从字符串池1中根据索引解析出对应的资源值。
如FLAG_COMPLEX为1(bag类型),则解析为List<ResMapValue>,ResMapValue中对应一个资源的name和一个ResValue,类似下面的资源表示是bag类型资源:
xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="custom_orientation">
<enum name="custom_vertical" value="100" />
<enum name="custom_horizontal" value="200" />
</attr>
</resources>
资源混淆
对resource.arsc有了基本的了解之后,可以回过头接着分析代码了。
AndResGuard对resource.arsc解析后,使用两个数据结构:
ResPackage,代表资源package。
ResType,代表资源类型,如anim,drawable等。
ResguardStringBuilder对资源名称进行替换的相关工作,reset方法准备好了一系列混淆后的名称,存入mReplaceStringBuffer这个list中。
proguardFileName方法对资源目录的名称进行替换:
java
for (int i = 0; i < resFiles.length; i++) {
// 这里也要用linux的分隔符,如果普通的话,就是r
mOldFileName.put("res" + "/" + resFiles[i].getName(),
TypedValue.RES_FILE_PATH + "/" + mResguardBuilder.getReplaceString()
);
}
这一步结束后,res/drawable转化为res/d目录。 同时将这一部分的转换写入mapping文件中,对应这一部分:
当把目录转化为混淆后值,下一步就是将目录中资源文件名称转化为混淆后值,这里由于我们定义的混淆后值名称为类似"a0""jv"等单词,混淆前资源本身就有可能是该类命名。
因此在RawARSCDecoder类中对resource.arsc进行了预读取,并将读到的字符串从mResguardBuilder的混淆后字符串集合进行了移除,移除之后的都是可用的名字了。
java
private void initResGuardBuild(int resTypeId) {
// we need remove string from resguard candidate list if it exists in white list
HashSet<Pattern> whiteListPatterns = getWhiteList(mType.getName());
// init resguard builder
mResguardBuilder.reset(whiteListPatterns);
mResguardBuilder.removeStrings(RawARSCDecoder.getExistTypeSpecNameStrings(resTypeId));
// 如果是保持mapping的话,需要去掉某部分已经用过的mapping
reduceFromOldMappingFile();
}
通过读取arsc文件中
1.遍历每一个type
2.遍历每一个config
3.遍历每一个entry
这样就寻找到了对应的资源项,可以对其进行混淆名称替换。同时生成映射表的第二部分。
java
private void generalResIDMapping(
String packageName, String typename, String specName, String replace) throws IOException {
mMappingWriter.write(" "
+ packageName
+ ".R."
+ typename
+ "."
+ specName
+ " -> "
+ packageName
+ ".R."
+ typename
+ "."
+ replace);
mMappingWriter.write("\n");
mMappingWriter.flush();
}
到这里为止,我们只是将目录名和资源项进行了替换,但实际文件的操作并没有进行,这一系列替换的证据留存在内存中,有几个数据结构记录下了这一切:
java
//ARSCDecoder
//key为替换前目录名 value为替换后目录名
private final Map<String, String> mOldFileName;
//ResPackage
//key为resource id(0xpptteeee),value为资源项替换后名称
private final Map<Integer, String> mSpecNamesReplace;
//ResType
//该type下所有资源项被替换后名称
private final HashSet<String> specNames;
最后需要混淆的是具体资源文件名,这一步在readValue中,需要满足如下的一系列条件才会进行替换:
java
//这里面有几个限制,一对于string ,id, array我们是知道肯定不用改的,第二看要那个type是否对应有文件路径
if (mPkg.isCanResguard()
&& flags
&& type == TypedValue.TYPE_STRING
&& mShouldResguardForType
&& mShouldResguardTypeSet.contains(mType.getName())) {
...
}
1.从global stringpool中获取资源字符串值。
2.将字符串值中的目录部分替换为混淆后值,这是因为目录的混淆转化已经完成。 3.文件名替换为资源项名混淆后值。
java
String compatibaleraw = new String(raw);
String compatibaleresult = new String(result);
File resRawFile = new File(mApkDecoder.getOutTempDir().getAbsolutePath() + File.separator + compatibaleraw);
File resDestFile = new File(mApkDecoder.getOutDir().getAbsolutePath() + File.separator + compatibaleresult);
前后对应混淆前的资源文件地址和混淆后输出的资源文件地址。
这里有将混淆前的资源文件拷贝到混淆后的资源文件保存目录,即如下图所示:
再进行一步收尾,将源资源文件目录没有进行混淆的资源文件也拷贝到目标资源文件目录中。
java
//把没有纪录在resources.arsc的资源文件也拷进dest目录
copyOtherResFiles();
ARSCDecoder.write(apkFile.getDirectory().getFileInput("resources.arsc"), this, pkgs);
最后生成变换后的resource.arsc文件。
这一步产生变化的部分可以看作者的总结:
分析到这里,我也对android的资源管理有了一个比较清晰的认识,在开始看到资源混淆这个方案时,我还在想是不是要对代码和xml中的资源引进行一系列修改呢,其实完全不用。因为它们使用的完全就是R文件中id,或者内联后的int值,这也是resource.arsc索引文件存在的意义了。
重新组装apk
进行了一系列变换之后,混淆之后的resource.arsc和资源文件都已经准备好,加上原始apk中不需要进行资源混淆的部分,如代码,so等,需要重新打包回apk。
具体逻辑位于方法buildApk中。
由于一般apk签名已经使用v2或v3,因此走下面的流程:
java
switch (signatureType) {
case SchemaV1:
builder.buildApkWithV1sign(decoder.getCompressData());
break;
case SchemaV2:
case SchemaV3:
builder.buildApkWithV2V3Sign(decoder.getCompressData(), minSDKVersion, signatureType);
break;
}
这个方法的第一参数是一个map,总结了原始apk的每一个entry的压缩模式,是DEFLATED(压缩)还是STORED(不压缩)。
通过上图可以直观的看出最终生成的apk文件。
- 生成app-aaa-blue-release_unsigned.apk
java
private void generalUnsignApk(HashMap<String, Integer> compressData) throws IOException, InterruptedException {
//1
for (File f : unzipFiles) {
String name = f.getName();
if (name.equals("res") || name.equals("resources.arsc")) {
continue;
} else if (name.equals(config.mMetaName)) {
addNonSignatureFiles(collectFiles, f);
continue;
}
collectFiles.add(f);
}
//2
File destResDir = new File(mOutDir.getAbsolutePath(), "res");
//添加修改后的res文件
if (!config.mKeepRoot && FileOperation.getlist(destResDir) == 0) {
destResDir = new File(mOutDir.getAbsolutePath(), TypedValue.RES_FILE_PATH);
}
//这个需要检查混淆前混淆后,两个res的文件数量是否相等
collectFiles.add(destResDir);
//3
File rawARSCFile = new File(mOutDir.getAbsolutePath() + File.separator + "resources.arsc");
collectFiles.add(rawARSCFile);
//4ddffdfd
FileOperation.zipFiles(collectFiles, tempOutDir, mUnSignedApk, compressData);
}
(1)采集所有非混淆文件,即非resource.arsc和非res目录的其他文件。
(2)采集res目录中文件。 (3)采集resource.arsc。 (4)压缩为unsign apk。
2.生成app-aaa-blue-release_7zip_unsigned.apk
java
private boolean use7zApk(HashMap<String, Integer> compressData, File originalAPK, File outputAPK)
throws IOException, InterruptedException {
//1
System.out.printf("use 7zip to repackage: %s, will cost much more time\n", outputAPK.getName());
FileOperation.unZipAPk(originalAPK.getAbsolutePath(), m7zipOutPutDir.getAbsolutePath());
//首先一次性生成一个全部都是压缩的安装包
generalRaw7zip(outputAPK);
//对于不压缩的要update回去
addStoredFileIn7Zip(storedFiles, outputAPK);
}
(1)将app-aaa-blue-release_unsigned.apk解压至out_7zip目录,通过7zip重新压缩成app-aaa-blue-release_7zip_unsigned.apk。
(2)将不能压缩的文件重新还原,即resource.arsc。
3.生成app-aaa-blue-release_7zip_aligned_unsigned.apk
这一步很简单,直接调用zipalign即可。这里需要注意的是先进行zipalign,再重签名。
4.生成app-aaa-blue-release_7zip_aligned_signed.apk
java
private void signWithV2V3Sign(File unSignedApk, File signedApk, int minSDKVersion, InputParam.SignatureType signatureType) throws Exception {
String[] params = new String[] {
"sign",
"--ks",
config.mSignatureFile.getAbsolutePath(),
"--ks-pass",
"pass:" + config.mStorePass,
"--min-sdk-version",
String.valueOf(minSDKVersion),
"--ks-key-alias",
config.mStoreAlias,
"--key-pass",
"pass:" + config.mKeyPass,
"--v3-signing-enabled",
String.valueOf(signatureType == SchemaV3),
"--out",
signedApk.getAbsolutePath(),
unSignedApk.getAbsolutePath()
};
ApkSignerTool.main(params);
}
可以看到,就是准备了一系列签名的命令,签名所需的相关文件是一开始就从相关变体的配置中读取准备好的:
接下来就是调用android的api去进行签名,不再详述。
至此,结果资源混淆的release apk就已经生成了。