前几天,我在搭建运用的壳工程的时分触及到了 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 里来完成多途径签名。

根据途径信息的 APK 文件校验计划

考虑到 v1 签名打包和校验的效率都比较低——这种计划需求对运用的一切文件进行核算,比较耗时——所以,谷歌后来提出了 v2 签名计划。v2 签名计划中在上述 APK 三个部分里插入了一块“签名块”。

根据途径信息的 APK 文件校验计划

除了“签名块”其他部分都会参加到签名的核算进程中。只不过这种计划会把文件的三个部分切割成维度更大的块,这样参加核算的数量就少了,以此来提高签名校验的效率。这种签名计划中,途径信息会被写入到“签名块”中,因而,不会影响终究的 APK 校验。

2、打包脚本修改

在之前的打包进程中,我在项目里选用的是 VasDolly 进行多途径打包。本来我打算在它的根底上魔改一下来完成我的签名校验思路。后来,我发现它本身就支撑打包到时分写入多个键值对信息。这减轻了咱们的工作量。因而,咱们只需求对打包进程魔改一下即可。

这儿咱们以 so 文件的校验为例。思路是,在打包完结之后对 APK 文件的 so 文件进行读取,获取全部的 so 文件,然后拼接成一个字符串,再对该字符串求一个哈希值,然后将其以兼职对的方法写入到文件里。最后,在运用运转的时分依照写入时指定的键读取对应的值。再经过对 APK 文件的 so 文件执行一遍上述算法,得到一个字符串。终究,经过比较两个字符串的值判别 APK 文件是否被篡改。

首先是读取 so 签名的示例算法,

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 中,

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 的途径信息校验的方法,对签名信息进行校验,以确保写入的准确性。

// 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 中添加如下装备,

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 中添加参数,

# 添加 So 签名检查变量
if (ENABLE_SO_CHECK)
    add_definitions(-DENABLE_SO_CHECK)
endif()

最后在代码中,运用条件编译来判别是否启用了 so 签名校验,

#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 本身签名校验的根底上添加了一层校验逻辑。