實(shí)現(xiàn) APK 保護(hù)時(shí)常見的坑和解決方案

對(duì) APK 進(jìn)行保護(hù)是我們經(jīng)常需要做的事,而且似乎也是每個(gè)公司必備的技能了。在使用如 ProGuard,DexGuard 等常見的產(chǎn)品之余,也有很多公司自行研發(fā)了一些保護(hù)的方案,專門來針對(duì)自家產(chǎn)品做出保護(hù),比如說我司也開發(fā)了專門防止二次打包的工具。

在開發(fā)這款產(chǎn)品,并用于實(shí)戰(zhàn)的過程中,也發(fā)現(xiàn)了很多坑,下面一一細(xì)數(shù)過來,希望對(duì)同樣也希望開發(fā)一款 APK 保護(hù)類產(chǎn)品的人們能有所啟發(fā)。

坑一: 簽名校驗(yàn)

本來以為簽名校驗(yàn)是一件很簡(jiǎn)單的事,不就是兩個(gè)字符串比較一下么,但是事實(shí)上這么做的話,可能會(huì)被坑得家都不認(rèn)識(shí),在 Java 層校驗(yàn)簽名自不必說,反編譯后 smali 代碼一改你就完了。而自作聰明把簽名校驗(yàn)放到 JNI 層也會(huì)有問題,之前我遇到的最典型的問題是 JNI 取簽名會(huì)比 Java 取出來的少一位(原因至今不明,也有一些手機(jī)實(shí)測(cè)下來兩端取到的簽名一樣),這樣的簽名比較就永遠(yuǎn)無法通過。

解決方案:在兩端分別取指定字節(jié)處的數(shù)值,而不是比較整個(gè)字符串,比較整個(gè)字符串也比較容易被人抓著了,內(nèi)存中一個(gè)長(zhǎng)達(dá) 1K 的字符串太容易引起注意了。

坑二:依然是簽名校驗(yàn)

上面說了一個(gè)完整的簽名字符串放在內(nèi)存里面是非常不安全的,那么怎么才是安全的?

在這里我們需要用到編程語(yǔ)言的一些特性:

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

記得每個(gè) string 里面其實(shí)只存一到二個(gè)字符用來校驗(yàn)就好了,而且也沒必要把全部字符串存入,以節(jié)省校驗(yàn)需要的時(shí)間成本(另一方面是 string 對(duì)象的開銷也較大,但是為了安全就忍了)。

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

坑三:JNI 庫(kù)的保護(hù)

辛辛苦苦寫出一個(gè) JNI 庫(kù),用它來校驗(yàn) APK 的各種屬性,這是一條不錯(cuò)的路子,但是萬一別人把 JNI 剝離了呢? 剝離的方法很簡(jiǎn)單,直接刪掉 so 文件,并且找到加載該 so 的 System.loadLibrary() 語(yǔ)句一并刪除,最后通過編譯找到閃退處,去掉調(diào)用部分的代碼即可。那么如何實(shí)際防止 JNI 庫(kù)被剝離?

這里我的解決方案是用一些黑科技,一方面隨機(jī)生成 so 的加載代碼,并插入各個(gè)類中,以實(shí)現(xiàn)隨機(jī)的 so 加載與校驗(yàn),往往當(dāng)你插入的校驗(yàn)代碼超過 100 處,而且每一處的命名與調(diào)用方法都不一樣的時(shí)候,反編譯的人就沒啥耐心改了,甚至他會(huì)懷疑這個(gè)庫(kù)是否對(duì)其他的業(yè)務(wù)也起到作用。

另一方面,加載 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)

然后這段代碼經(jīng)過編譯后,生成的 smali 代碼是基本上不可能看懂的,就算一處看懂,還有 N 處,如果這些變量四散定義在程序各處,并且被多次調(diào)用的話,也是任何人都不敢輕易刪除的,這樣就直接的隱藏了 loadLibrary 的過程。

當(dāng)然這只是一種做法,還有其他的做法,比如說在其他業(yè)務(wù)相關(guān)的 JNI 里也插入校驗(yàn)代碼,甚至 JNI 之間實(shí)現(xiàn)相互調(diào)用,都可以盡最大可能防止 JNI 被剝離。關(guān)鍵還是生成的代碼,其變量名稱要隨機(jī),盡可能的造成混亂,否則被找出了規(guī)律就悲劇了,另外生成的代碼結(jié)構(gòu)也盡可能不一樣,否則容易被 IDE 提示要重構(gòu)(不要懷疑,大部分反編譯的人在搞到代碼后都會(huì)重建一個(gè)工程然后上 IDE 的),你保護(hù)的意圖也就明顯了。

坑四:smali 代碼注入

講到保護(hù) APK 那必定是要修改 smali 代碼的,不管以何種形式的保護(hù),都無法避免,而我之前設(shè)計(jì)的方案,由于要注入大量類和方法,因此對(duì) MultiDex 就有了很高的要求,單純的往 smali 里面注入是行不通的,經(jīng)常會(huì)出現(xiàn)一個(gè) dex 文件超出 65535 個(gè)方法的問題。

解決方案只有一個(gè),那就是設(shè)計(jì)一個(gè)比較牛X的處理類的移動(dòng)的方法,先針對(duì)一個(gè) dex 內(nèi)的方法數(shù)進(jìn)行判斷,然后加上要注入的方法數(shù),看是否超過 65535,若是超過,則需要將一部分注入的內(nèi)容移到后續(xù)的 dex 中,甚至還需要以 smali_classes* 的形式新建一個(gè) dex。

在這個(gè)過程中我遇到過很多坑,比如說 Android 5.0 后,可以不用 MultiDex,而是將所有的方法都?jí)涸谝粋€(gè) dex 文件內(nèi),這個(gè)情況下,如果你確定 SDK Target 是 21 以上,那么可以無視 dex 的要求,而若是 SDK Target 是 21 以下,那么就必須手動(dòng)進(jìn)行 dex 拆分。而拆分的時(shí)候又要注意,Application 類和用作 Luancher 的 Activity 必須在第一個(gè) dex 內(nèi),于是又多出了要解析 AndroidManifest.xml 的需求,而且還要補(bǔ)足 Application 內(nèi)缺失的代碼,比如說以下的:

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

坑五:Magic Number

與我溝通過的人都知道,我喜歡用 Magic Number,因?yàn)檫@是可以最大程度讓開發(fā)者自由發(fā)揮的東西,對(duì) Magic Number 進(jìn)行校驗(yàn)也是相當(dāng)?shù)淖杂?,改得好甚至可以?shí)現(xiàn)如下效果:

也就是 zip 格式被破壞了,無法進(jìn)行解壓,而 Android 系統(tǒng)依然可以識(shí)別這個(gè)程序。而尋找 Magic Number 的過程可謂血淚史,一開始取好的地址偏移的數(shù)值,在不同版本的 Android 上面會(huì)帶來不同的解析行為,因此改 zip 頭部并不是一個(gè)好主意。在反復(fù)的尋找 Magic Number 可寫的偏移過程中,也并沒有發(fā)現(xiàn)什么可循的規(guī)律,只是知道了某幾個(gè)地址可寫。而且也許再下個(gè)版本的 APK 就不讓這么寫了, 找通用的方案實(shí)在是自找麻煩。如果不是非常有信心去折騰 Magic Number,還是消停點(diǎn)的好。

坑六:在代碼混淆的基礎(chǔ)上繼續(xù)做保護(hù)

如 Proguard 等保護(hù)類產(chǎn)品,會(huì)對(duì) APP 的代碼進(jìn)行混淆處理,以實(shí)現(xiàn)反編譯后代碼難以讀懂的效果。而若是還不放心,想在這層保護(hù)上繼續(xù)保護(hù)的話,就會(huì)面臨很多問題,比如說類名沖突。原本的類名經(jīng)過混淆后,可能就變成了 abcd 等無意義的字符,而我們要注入的代碼也是經(jīng)過了人肉混淆的,很可能還是寫死的,可以設(shè)想一下反編譯后得到 a.java,而后又注入了一個(gè)邏輯完全不同的 a.java 會(huì)發(fā)生什么。

要解決這樣的問題,首先我們要有一套算法,比如說遍歷要注入的 package,分析它下面已有的類,然后動(dòng)態(tài)的去生成自己要注入的類名。在這個(gè)過程中依然需要注意文件系統(tǒng)的問題,如果是在 Linux 下執(zhí)行這些操作,你可以在遍歷完大寫字母后,再次遍歷小寫字母,而在 Mac 上干這事就不太妙了,除非你把你的 Mac 硬盤做成大小寫敏感的,否則很可能要跪。另外再多提一句,有些混淆過的 APK 在 Mac 上進(jìn)行反編譯后會(huì)有文件缺失的情況,從而無法再進(jìn)行打包,一定程度上歸功于大小寫不敏感的文件系統(tǒng),換到 Linux 上操作就不會(huì)丟了。

光是有這種的算法還不夠,如果正好你計(jì)算的類是 JNI 的加載類呢,這個(gè)時(shí)候類名一變,JNI 加載一定會(huì)失敗。當(dāng)然辦法還是有的,比如說根據(jù)生成的類名,重新編譯 JNI 庫(kù),所以通常情況下,JNI 都是最后才編譯的,根據(jù)注入的代碼的情況收集到一大堆信息,然后才可以弄出 so 來。

額外說幾句,如果要注入完整的 kotlin 框架以幫助實(shí)現(xiàn)讓反編譯器出錯(cuò),那么 kotlin 的方法數(shù)大概是 6800 左右,隨著版本的更新,方法數(shù)緩慢增加,我自己是直接留了 8000 的空間,也就是說當(dāng)前 dex 方法數(shù)加上 8000 是否大于 65535,若大于則直接進(jìn)下一個(gè) dex 繼續(xù)運(yùn)算,這個(gè)情況下還是保守一點(diǎn)的好,防止打包失敗。

另外 Magic Number 的問題,千萬不要只打一套固定的,容易被人抓了規(guī)律,大部分有經(jīng)驗(yàn)的人一看 zip 解壓失敗,就知道你動(dòng)了手腳了。比較好的辦法是寫一套算法來生成多套 Magic Number,生次打包都隨機(jī)打其中一套,然后 JNI 可以通過同樣的算法進(jìn)行遍歷校驗(yàn)。每次在變化的(并且找不出變化規(guī)律的)值也容易對(duì)人造成混亂。

最后的最后,一句廢話:任何保護(hù)手段都是增加成本,畢竟你的程序還是要能在 Android 系統(tǒng)內(nèi)運(yùn)行,它必須符合系統(tǒng)的規(guī)矩,因此還是會(huì)被反編譯的,只是反編譯的成本,二次打包的成本,是否在技術(shù)手段下足以完成阻止而已。不要對(duì)通用的保護(hù)手段抱太大的希望,自己做一套并保持更新才是王道。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容