×

实现 APK 保护时常见的坑和解决方案

96
何晓杰Dev
2016.12.12 23:39 字数 2878

对 APK 进行保护是我们经常需要做的事,而且似乎也是每个公司必备的技能了。在使用如 ProGuard,DexGuard 等常见的产品之余,也有很多公司自行研发了一些保护的方案,专门来针对自家产品做出保护,比如说我司也开发了专门防止二次打包的工具。

在开发这款产品,并用于实战的过程中,也发现了很多坑,下面一一细数过来,希望对同样也希望开发一款 APK 保护类产品的人们能有所启发。

坑一: 签名校验

本来以为签名校验是一件很简单的事,不就是两个字符串比较一下么,但是事实上这么做的话,可能会被坑得家都不认识,在 Java 层校验签名自不必说,反编译后 smali 代码一改你就完了。而自作聪明把签名校验放到 JNI 层也会有问题,之前我遇到的最典型的问题是 JNI 取签名会比 Java 取出来的少一位(原因至今不明,也有一些手机实测下来两端取到的签名一样),这样的签名比较就永远无法通过。

解决方案:在两端分别取指定字节处的数值,而不是比较整个字符串,比较整个字符串也比较容易被人抓着了,内存中一个长达 1K 的字符串太容易引起注意了。

坑二:依然是签名校验

上面说了一个完整的签名字符串放在内存里面是非常不安全的,那么怎么才是安全的?

在这里我们需要用到编程语言的一些特性:

class Sig {
    private:
        string c0;
        string c1;
        string c2;
        ...
};

记得每个 string 里面其实只存一到二个字符用来校验就好了,而且也没必要把全部字符串存入,以节省校验需要的时间成本(另一方面是 string 对象的开销也较大,但是为了安全就忍了)。

恩,你问为什么不用 struct?自己试试就知道了,有一款神器叫 IDA,一试便知。

坑三:JNI 库的保护

辛辛苦苦写出一个 JNI 库,用它来校验 APK 的各种属性,这是一条不错的路子,但是万一别人把 JNI 剥离了呢? 剥离的方法很简单,直接删掉 so 文件,并且找到加载该 so 的 System.loadLibrary() 语句一并删除,最后通过编译找到闪退处,去掉调用部分的代码即可。那么如何实际防止 JNI 库被剥离?

这里我的解决方案是用一些黑科技,一方面随机生成 so 的加载代码,并插入各个类中,以实现随机的 so 加载与校验,往往当你插入的校验代码超过 100 处,而且每一处的命名与调用方法都不一样的时候,反编译的人就没啥耐心改了,甚至他会怀疑这个库是否对其他的业务也起到作用。

另一方面,加载 so 的代码使用一些变形,比如使用以下代码:

var a = "l", b = "o", c = "a", d = "d", e = "i", f = "b", g = "r", h = "y", i = "n"
var aa = "j", bb = "a", cc = "v", dd = "n", ee = "g", ff = "s", gg = "t", hh = "e", ii = "m"
var aaa = "."
var x = "$a$b$c$d${a.toUpperCase()}$e$f$r$c$g$h"
var s = Class.forName("$aa$bb$cc$bb$aaa$a$c$dd$ee$aaa${ff.toupperCase()}$h$ff$gg$hh$ii")
var ss = Class.forName("$aa$bb$cc$bb$aaa$a$c$dd$ee$aaa${ff.toUpperCase()}$gg$rr$e$i$ee")
var yy = "$ff$hh"
var v = s.getMethod(x, ss)
v.invoke(null, yy)

然后这段代码经过编译后,生成的 smali 代码是基本上不可能看懂的,就算一处看懂,还有 N 处,如果这些变量四散定义在程序各处,并且被多次调用的话,也是任何人都不敢轻易删除的,这样就直接的隐藏了 loadLibrary 的过程。

当然这只是一种做法,还有其他的做法,比如说在其他业务相关的 JNI 里也插入校验代码,甚至 JNI 之间实现相互调用,都可以尽最大可能防止 JNI 被剥离。关键还是生成的代码,其变量名称要随机,尽可能的造成混乱,否则被找出了规律就悲剧了,另外生成的代码结构也尽可能不一样,否则容易被 IDE 提示要重构(不要怀疑,大部分反编译的人在搞到代码后都会重建一个工程然后上 IDE 的),你保护的意图也就明显了。

坑四:smali 代码注入

讲到保护 APK 那必定是要修改 smali 代码的,不管以何种形式的保护,都无法避免,而我之前设计的方案,由于要注入大量类和方法,因此对 MultiDex 就有了很高的要求,单纯的往 smali 里面注入是行不通的,经常会出现一个 dex 文件超出 65535 个方法的问题。

解决方案只有一个,那就是设计一个比较牛X的处理类的移动的方法,先针对一个 dex 内的方法数进行判断,然后加上要注入的方法数,看是否超过 65535,若是超过,则需要将一部分注入的内容移到后续的 dex 中,甚至还需要以 smali_classes* 的形式新建一个 dex。

在这个过程中我遇到过很多坑,比如说 Android 5.0 后,可以不用 MultiDex,而是将所有的方法都压在一个 dex 文件内,这个情况下,如果你确定 SDK Target 是 21 以上,那么可以无视 dex 的要求,而若是 SDK Target 是 21 以下,那么就必须手动进行 dex 拆分。而拆分的时候又要注意,Application 类和用作 Luancher 的 Activity 必须在第一个 dex 内,于是又多出了要解析 AndroidManifest.xml 的需求,而且还要补足 Application 内缺失的代码,比如说以下的:

protected void attachBaseContext(Context base) {  
    super.attachBaseContext(base);  
    MultiDex.install(this);  
}

坑五:Magic Number

与我沟通过的人都知道,我喜欢用 Magic Number,因为这是可以最大程度让开发者自由发挥的东西,对 Magic Number 进行校验也是相当的自由,改得好甚至可以实现如下效果:


也就是 zip 格式被破坏了,无法进行解压,而 Android 系统依然可以识别这个程序。而寻找 Magic Number 的过程可谓血泪史,一开始取好的地址偏移的数值,在不同版本的 Android 上面会带来不同的解析行为,因此改 zip 头部并不是一个好主意。在反复的寻找 Magic Number 可写的偏移过程中,也并没有发现什么可循的规律,只是知道了某几个地址可写。而且也许再下个版本的 APK 就不让这么写了, 找通用的方案实在是自找麻烦。如果不是非常有信心去折腾 Magic Number,还是消停点的好。

坑六:在代码混淆的基础上继续做保护

如 Proguard 等保护类产品,会对 APP 的代码进行混淆处理,以实现反编译后代码难以读懂的效果。而若是还不放心,想在这层保护上继续保护的话,就会面临很多问题,比如说类名冲突。原本的类名经过混淆后,可能就变成了 abcd 等无意义的字符,而我们要注入的代码也是经过了人肉混淆的,很可能还是写死的,可以设想一下反编译后得到 a.java,而后又注入了一个逻辑完全不同的 a.java 会发生什么。

要解决这样的问题,首先我们要有一套算法,比如说遍历要注入的 package,分析它下面已有的类,然后动态的去生成自己要注入的类名。在这个过程中依然需要注意文件系统的问题,如果是在 Linux 下执行这些操作,你可以在遍历完大写字母后,再次遍历小写字母,而在 Mac 上干这事就不太妙了,除非你把你的 Mac 硬盘做成大小写敏感的,否则很可能要跪。另外再多提一句,有些混淆过的 APK 在 Mac 上进行反编译后会有文件缺失的情况,从而无法再进行打包,一定程度上归功于大小写不敏感的文件系统,换到 Linux 上操作就不会丢了。

光是有这种的算法还不够,如果正好你计算的类是 JNI 的加载类呢,这个时候类名一变,JNI 加载一定会失败。当然办法还是有的,比如说根据生成的类名,重新编译 JNI 库,所以通常情况下,JNI 都是最后才编译的,根据注入的代码的情况收集到一大堆信息,然后才可以弄出 so 来。

额外说几句,如果要注入完整的 kotlin 框架以帮助实现让反编译器出错,那么 kotlin 的方法数大概是 6800 左右,随着版本的更新,方法数缓慢增加,我自己是直接留了 8000 的空间,也就是说当前 dex 方法数加上 8000 是否大于 65535,若大于则直接进下一个 dex 继续运算,这个情况下还是保守一点的好,防止打包失败。

另外 Magic Number 的问题,千万不要只打一套固定的,容易被人抓了规律,大部分有经验的人一看 zip 解压失败,就知道你动了手脚了。比较好的办法是写一套算法来生成多套 Magic Number,生次打包都随机打其中一套,然后 JNI 可以通过同样的算法进行遍历校验。每次在变化的(并且找不出变化规律的)值也容易对人造成混乱。

最后的最后,一句废话:任何保护手段都是增加成本,毕竟你的程序还是要能在 Android 系统内运行,它必须符合系统的规矩,因此还是会被反编译的,只是反编译的成本,二次打包的成本,是否在技术手段下足以完成阻止而已。不要对通用的保护手段抱太大的希望,自己做一套并保持更新才是王道。

开发
Web note ad 1