前几天,我在搭建应用的壳工程的时候涉及到了 Android 应用防破解。我希望能够在之前的安全方案的基础之上(参考之前的文章《我的 Android 应用安全方案梳理》)进行优化,设计一个标准的方案出来以复用。之前的方案虽然增加应用的安全性,但是我希望能够在之前的基础上再思考一些新的验证方案。于是我想到了能否基于应用的渠道信息,在打包的过程中把一些文件的哈希值通过固定的算法转换成字符串之后写入到 APK 文件里,然后在应用的运行时读取 APK 的信息并进行校验。按照这种设计思路,如果破解方不清楚你的加密算法就无法正确地写入该字符串,从而达到防破解的目的。
这里涉及到一些 APK 打包和签名的知识。
1、应用打包和签名的方案
上面提到,我希望把这个验证字符串放进 APK 的趣道信息里。那么,通常来说,我们给 APK 包指定渠道的方式有两种,一种是在打包的过程中把渠道信息写入到 AndroidManifest.xml
. 这种方案显然是行不通的,因为,我们写入的逻辑是在 APK 打包完成之后进行的,而此时 AndroidManifest.xml
已经被编译完成。我们,需要对 AndroidManifest.xml
反编译,修改,然后再二次打包和签名,比较繁琐。而且,如果需要对 AndroidManifest.xml
本身进行签名校验,那么,这种方式就完全行不通。
所以,我们的方案是基于 APK 文件的渠道签名的方案进行的。
基于 APK 文件的渠道签名方案就是把渠道信息写入到 APK 文件上。因为 APK 文件由三大部分组成,在 v1 的签名方案中,系统在安装的过程中只会对前两部分进行校验,最后的一个部分叫做 ECOD. 我们通过把渠道信息写入到 ECOD 的 comment 里来实现多渠道签名。
考虑到 v1 签名打包和校验的效率都比较低------这种方案需要对应用的所有文件进行计算,比较耗时------所以,谷歌后来提出了 v2 签名方案。v2 签名方案中在上述 APK 三个部分里插入了一块"签名块"。
除了"签名块"其他部分都会参与到签名的计算过程中。只不过这种方案会把文件的三个部分切割成维度更大的块,这样参与计算的数量就少了,以此来提升签名校验的效率。这种签名方案中,渠道信息会被写入到"签名块"中,因此,不会影响最终的 APK 校验。
2、打包脚本修改
在之前的打包过程中,我在项目里采用的是 VasDolly 进行多渠道打包。本来我打算在它的基础上魔改一下来实现我的签名校验思路。后来,我发现它本身就支持打包到时候写入多个键值对信息。这减轻了我们的工作量。因此,我们只需要对打包过程魔改一下即可。
这里我们以 so 文件的校验为例。思路是,在打包完成之后对 APK 文件的 so 文件进行读取,获取全部的 so 文件,然后拼接成一个字符串,再对该字符串求一个哈希值,然后将其以兼职对的形式写入到文件里。最后,在应用运行的时候按照写入时指定的键读取对应的值。再通过对 APK 文件的 so 文件执行一遍上述算法,得到一个字符串。最终,通过比较两个字符串的值判断 APK 文件是否被篡改。
首先是读取 so 签名的示例算法,
java
public static String generateShieldResourceSignature(File srcApk) {
if (srcApk == null
|| !srcApk.exists()
|| !srcApk.isFile()
|| srcApk.length() == 0) {
return "";
}
try (JarFile jarFile = new JarFile(srcApk)) {
Enumeration<JarEntry> entries = jarFile.entries();
// 获取文件-数字签名映射关系
Map<String, String> signatures = new HashMap<>();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
if (entry.getName().endsWith(".so")) {
InputStream ins = jarFile.getInputStream(entry);
byte[] bytes = ins.readAllBytes();
String sha256 = EncryptUtils.sha256(bytes);
signatures.put(sha256, entry.getName());
}
}
// 对数字签名进行排序
List<String> sha256s = new ArrayList<>(signatures.keySet());
sha256s.sort(String::compareTo);
List<String> names = sha256s.stream().map(signatures::get).collect(Collectors.toList());
System.out.println("Resource shield signature so files order: " + join(names, ", "));
String connected = join(sha256s, "@@");
String signature = EncryptUtils.sha256(connected);
jarFile.close();
return signature;
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
这里的算法的含义是,读取 APK 文件的所有 so 文件,按照名称进行排序,计算其文件的 sha256 哈希值,将所有哈希值用 @@
拼接起来,再对拼接后的字符串求一个哈希值。在应用的运行过程中,使用同样的算法计算当前运行的 APK 对应的哈希值。
然后打包的时候将该哈希值和渠道信息一同写入到 APK 中,
java
Map<Integer, ByteBuffer> idValueMap = new HashMap<>();
byte[] channelBytes = channel.getBytes(ChannelConstants.CONTENT_CHARSET);
ByteBuffer channelByteBuffer = ByteBuffer.wrap(channelBytes);
//apk中所有字节都是小端模式
channelByteBuffer.order(ByteOrder.LITTLE_ENDIAN);
idValueMap.put(ChannelConstants.CHANNEL_BLOCK_ID, channelByteBuffer);
// 生成 APK 资源签名字符串
if (resourceShieldSignature != null && resourceShieldSignature.length() != 0) {
byte[] shieldSignatureBytes = resourceShieldSignature.getBytes(ChannelConstants.CONTENT_CHARSET);
ByteBuffer shieldSignatureByteBuffer = ByteBuffer.wrap(shieldSignatureBytes);
shieldSignatureByteBuffer.order(ByteOrder.LITTLE_ENDIAN);
idValueMap.put(ChannelConstants.RES_SHIELD_BLOCK_ID, shieldSignatureByteBuffer);
}
IdValueWriter.addIdValueByteBufferMap(apkSectionInfo, destApk, idValueMap);
最后,在打包完成之后,仿照 VasDolly 的渠道信息校验的形式,对签名信息进行校验,以确保写入的准确性。
java
// 2. verify resource shield info
if (ChannelReader.verifyResourceShieldByV2(destFile, resourceShieldSignature)) {
System.out.println("generatedChannelApk destFile(" + destFile + ")add resources shield info success");
} else {
throw new RuntimeException("generatedChannelApk destFile( " + destFile + ") add shield info failure");
}
3、增加打包选项
由于 so 签名的校验是在 APK 最终生成渠道包的过程中完成的。在我们日常的开发中就不需要对 so 签名进行校验。因此,我们可以通过增加编译参数来确保这一点。又因为,我是在 Native 层通过 C++ 实现的签名校验,因此,需要在 CMake 的配置中增加一些参数。
在 Gradle 中增加如下配置,
groovy
externalNativeBuild {
cmake {
if (project.hasProperty("enable_so_check")) {
println("> Build Info >>>>>> so check enabled")
arguments "-DENABLE_SO_CHECK=TRUE"
} else {
println("> Build Info >>>>>> so check disabled")
}
}
}
然后在 CMakeList.txt
中增加参数,
scss
# 添加 So 签名检查变量
if (ENABLE_SO_CHECK)
add_definitions(-DENABLE_SO_CHECK)
endif()
最后在代码中,使用条件编译来判断是否启用了 so 签名校验,
c++
#ifdef ENABLE_SO_CHECK
if (!verifySoSignature(env)) {
return JNI_ERR;
}
#endif
这样,我们只需要在打包的时候,在打包的参数中增加 enable_so_check 参数,即可启用 so 签名校验。这里值得一提的是,为了保证签名信息能够在打包和应用运行时一致,我们最好双端使用同一份代码逻辑。
4、后续的一些思考
这种签名方式可以用来对应用内指定的文件进行签名校验,防止被篡改。可能有同学会说,如果别人通过 Hook 绕过了这个逻辑怎么办?做过 Hook 的同学可能清楚,Hook 的前提是你得先找到一个 Hook 的点。因此,我建议应用中的业务逻辑可以放到 Native 层,通过 C++ 实现,然后,将校验的逻辑通过 inline 的形式嵌入到业务逻辑的方法中。inline 是一种特殊的指令,并且 Kotlin 中也实现了这个指令。它可以在编译期间通过替换的形式把调用 inline 函数的地方的代码替换成函数自身的代码。通过这个指令,也可以进一步提升我们应用的安全性。
总结
应该说 Android 文件签名的方案本身就是一个公开的"秘密"。这种方案本质上是在 Android 自身签名校验的基础上增加了一层校验逻辑。