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

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

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

坑一: 簽名校驗

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

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

坑二:依然是簽名校驗

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

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

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

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

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

坑三:JNI 庫的保護

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

這里我的解決方案是用一些黑科技,一方面隨機生成 so 的加載代碼,并插入各個類中,以實現(xiàn)隨機的 so 加載與校驗,往往當你插入的校驗代碼超過 100 處,而且每一處的命名與調(diào)用方法都不一樣的時候,反編譯的人就沒啥耐心改了,甚至他會懷疑這個庫是否對其他的業(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 的過程。

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

坑四:smali 代碼注入

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

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

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

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

坑五:Magic Number

與我溝通過的人都知道,我喜歡用 Magic Number,因為這是可以最大程度讓開發(fā)者自由發(fā)揮的東西,對 Magic Number 進行校驗也是相當?shù)淖杂桑牡煤蒙踔量梢詫崿F(xiàn)如下效果:

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

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

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

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

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

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

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

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

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

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

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