對 APK 進(jìn)行保護(hù)是我們經(jīng)常需要做的事膀钠,而且似乎也是每個公司必備的技能了。在使用如 ProGuard,DexGuard 等常見的產(chǎn)品之余捏顺,也有很多公司自行研發(fā)了一些保護(hù)的方案核偿,專門來針對自家產(chǎn)品做出保護(hù)诚欠,比如說我司也開發(fā)了專門防止二次打包的工具。
在開發(fā)這款產(chǎn)品漾岳,并用于實戰(zhàn)的過程中轰绵,也發(fā)現(xiàn)了很多坑,下面一一細(xì)數(shù)過來尼荆,希望對同樣也希望開發(fā)一款 APK 保護(hù)類產(chǎn)品的人們能有所啟發(fā)左腔。
坑一: 簽名校驗
本來以為簽名校驗是一件很簡單的事,不就是兩個字符串比較一下么捅儒,但是事實上這么做的話液样,可能會被坑得家都不認(rèn)識振亮,在 Java 層校驗簽名自不必說,反編譯后 smali 代碼一改你就完了鞭莽。而自作聰明把簽名校驗放到 JNI 層也會有問題坊秸,之前我遇到的最典型的問題是 JNI 取簽名會比 Java 取出來的少一位(原因至今不明,也有一些手機(jī)實測下來兩端取到的簽名一樣)澎怒,這樣的簽名比較就永遠(yuǎn)無法通過褒搔。
解決方案:在兩端分別取指定字節(jié)處的數(shù)值,而不是比較整個字符串喷面,比較整個字符串也比較容易被人抓著了星瘾,內(nèi)存中一個長達(dá) 1K 的字符串太容易引起注意了。
坑二:依然是簽名校驗
上面說了一個完整的簽名字符串放在內(nèi)存里面是非常不安全的惧辈,那么怎么才是安全的琳状?
在這里我們需要用到編程語言的一些特性:
class Sig { private: string c0; string c1; string c2; ... };
記得每個 string 里面其實只存一到二個字符用來校驗就好了,而且也沒必要把全部字符串存入盒齿,以節(jié)省校驗需要的時間成本(另一方面是 string 對象的開銷也較大念逞,但是為了安全就忍了)。
恩边翁,你問為什么不用 struct肮柜?自己試試就知道了,有一款神器叫 IDA倒彰,一試便知审洞。
坑三:JNI 庫的保護(hù)
辛辛苦苦寫出一個 JNI 庫,用它來校驗 APK 的各種屬性待讳,這是一條不錯的路子芒澜,但是萬一別人把 JNI 剝離了呢? 剝離的方法很簡單创淡,直接刪掉 so 文件痴晦,并且找到加載該 so 的 System.loadLibrary() 語句一并刪除,最后通過編譯找到閃退處琳彩,去掉調(diào)用部分的代碼即可誊酌。那么如何實際防止 JNI 庫被剝離?
這里我的解決方案是用一些黑科技露乏,一方面隨機(jī)生成 so 的加載代碼碧浊,并插入各個類中,以實現(xiàn)隨機(jī)的 so 加載與校驗瘟仿,往往當(dāng)你插入的校驗代碼超過 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 的過程。
當(dāng)然這只是一種做法厂画,還有其他的做法凸丸,比如說在其他業(yè)務(wù)相關(guān)的 JNI 里也插入校驗代碼,甚至 JNI 之間實現(xiàn)相互調(diào)用袱院,都可以盡最大可能防止 JNI 被剝離屎慢。關(guān)鍵還是生成的代碼,其變量名稱要隨機(jī)忽洛,盡可能的造成混亂腻惠,否則被找出了規(guī)律就悲劇了,另外生成的代碼結(jié)構(gòu)也盡可能不一樣欲虚,否則容易被 IDE 提示要重構(gòu)(不要懷疑集灌,大部分反編譯的人在搞到代碼后都會重建一個工程然后上 IDE 的),你保護(hù)的意圖也就明顯了复哆。
坑四:smali 代碼注入
講到保護(hù) APK 那必定是要修改 smali 代碼的欣喧,不管以何種形式的保護(hù),都無法避免梯找,而我之前設(shè)計的方案唆阿,由于要注入大量類和方法,因此對 MultiDex 就有了很高的要求锈锤,單純的往 smali 里面注入是行不通的驯鳖,經(jīng)常會出現(xiàn)一個 dex 文件超出 65535 個方法的問題。
解決方案只有一個久免,那就是設(shè)計一個比較牛X的處理類的移動的方法浅辙,先針對一個 dex 內(nèi)的方法數(shù)進(jìn)行判斷,然后加上要注入的方法數(shù)阎姥,看是否超過 65535摔握,若是超過,則需要將一部分注入的內(nèi)容移到后續(xù)的 dex 中丁寄,甚至還需要以 smali_classes* 的形式新建一個 dex氨淌。
在這個過程中我遇到過很多坑泊愧,比如說 Android 5.0 后,可以不用 MultiDex盛正,而是將所有的方法都壓在一個 dex 文件內(nèi)删咱,這個情況下,如果你確定 SDK Target 是 21 以上豪筝,那么可以無視 dex 的要求痰滋,而若是 SDK Target 是 21 以下,那么就必須手動進(jìn)行 dex 拆分续崖。而拆分的時候又要注意敲街,Application 類和用作 Luancher 的 Activity 必須在第一個 dex 內(nèi),于是又多出了要解析 AndroidManifest.xml 的需求严望,而且還要補(bǔ)足 Application 內(nèi)缺失的代碼多艇,比如說以下的:
protected void attachBaseContext(Context base) { super.attachBaseContext(base); MultiDex.install(this); }
坑五:Magic Number
與我溝通過的人都知道,我喜歡用 Magic Number像吻,因為這是可以最大程度讓開發(fā)者自由發(fā)揮的東西峻黍,對 Magic Number 進(jìn)行校驗也是相當(dāng)?shù)淖杂桑牡煤蒙踔量梢詫崿F(xiàn)如下效果:
也就是 zip 格式被破壞了拨匆,無法進(jìn)行解壓姆涩,而 Android 系統(tǒng)依然可以識別這個程序。而尋找 Magic Number 的過程可謂血淚史惭每,一開始取好的地址偏移的數(shù)值骨饿,在不同版本的 Android 上面會帶來不同的解析行為,因此改 zip 頭部并不是一個好主意台腥。在反復(fù)的尋找 Magic Number 可寫的偏移過程中样刷,也并沒有發(fā)現(xiàn)什么可循的規(guī)律,只是知道了某幾個地址可寫览爵。而且也許再下個版本的 APK 就不讓這么寫了置鼻, 找通用的方案實在是自找麻煩。如果不是非常有信心去折騰 Magic Number蜓竹,還是消停點的好箕母。
坑六:在代碼混淆的基礎(chǔ)上繼續(xù)做保護(hù)
如 Proguard 等保護(hù)類產(chǎn)品,會對 APP 的代碼進(jìn)行混淆處理俱济,以實現(xiàn)反編譯后代碼難以讀懂的效果嘶是。而若是還不放心,想在這層保護(hù)上繼續(xù)保護(hù)的話蛛碌,就會面臨很多問題聂喇,比如說類名沖突。原本的類名經(jīng)過混淆后,可能就變成了 abcd 等無意義的字符希太,而我們要注入的代碼也是經(jīng)過了人肉混淆的克饶,很可能還是寫死的,可以設(shè)想一下反編譯后得到 a.java誊辉,而后又注入了一個邏輯完全不同的 a.java 會發(fā)生什么矾湃。
要解決這樣的問題,首先我們要有一套算法堕澄,比如說遍歷要注入的 package邀跃,分析它下面已有的類,然后動態(tài)的去生成自己要注入的類名蛙紫。在這個過程中依然需要注意文件系統(tǒng)的問題拍屑,如果是在 Linux 下執(zhí)行這些操作,你可以在遍歷完大寫字母后坑傅,再次遍歷小寫字母僵驰,而在 Mac 上干這事就不太妙了,除非你把你的 Mac 硬盤做成大小寫敏感的裁蚁,否則很可能要跪。另外再多提一句继准,有些混淆過的 APK 在 Mac 上進(jìn)行反編譯后會有文件缺失的情況枉证,從而無法再進(jìn)行打包,一定程度上歸功于大小寫不敏感的文件系統(tǒng)移必,換到 Linux 上操作就不會丟了室谚。
光是有這種的算法還不夠,如果正好你計算的類是 JNI 的加載類呢崔泵,這個時候類名一變秒赤,JNI 加載一定會失敗。當(dāng)然辦法還是有的憎瘸,比如說根據(jù)生成的類名入篮,重新編譯 JNI 庫,所以通常情況下幌甘,JNI 都是最后才編譯的潮售,根據(jù)注入的代碼的情況收集到一大堆信息,然后才可以弄出 so 來锅风。
額外說幾句酥诽,如果要注入完整的 kotlin 框架以幫助實現(xiàn)讓反編譯器出錯,那么 kotlin 的方法數(shù)大概是 6800 左右皱埠,隨著版本的更新肮帐,方法數(shù)緩慢增加,我自己是直接留了 8000 的空間边器,也就是說當(dāng)前 dex 方法數(shù)加上 8000 是否大于 65535训枢,若大于則直接進(jìn)下一個 dex 繼續(xù)運算托修,這個情況下還是保守一點的好,防止打包失敗肮砾。
另外 Magic Number 的問題诀黍,千萬不要只打一套固定的,容易被人抓了規(guī)律仗处,大部分有經(jīng)驗的人一看 zip 解壓失敗眯勾,就知道你動了手腳了。比較好的辦法是寫一套算法來生成多套 Magic Number婆誓,生次打包都隨機(jī)打其中一套吃环,然后 JNI 可以通過同樣的算法進(jìn)行遍歷校驗。每次在變化的(并且找不出變化規(guī)律的)值也容易對人造成混亂洋幻。
最后的最后郁轻,一句廢話:任何保護(hù)手段都是增加成本,畢竟你的程序還是要能在 Android 系統(tǒng)內(nèi)運行文留,它必須符合系統(tǒng)的規(guī)矩好唯,因此還是會被反編譯的,只是反編譯的成本燥翅,二次打包的成本骑篙,是否在技術(shù)手段下足以完成阻止而已。不要對通用的保護(hù)手段抱太大的希望森书,自己做一套并保持更新才是王道靶端。