手游防破解防外掛技術方案(一)客戶端篇
對於任何一款要長期線上運營的遊戲,防破解防外掛是必不可少的。本文總結了手游常用的防破解防外掛技術方案,這些方案都經過了筆者所在團隊和線上項目的長期考驗。很多方案來自於弱聯網手游項目,但大部分思路也同樣適用於強聯網遊戲。以Unity為例,但思路也適用於非Unity項目。筆者儘可能做到總結全面,希望能幫助大家形成一個整體的防禦思路。
強聯網遊戲的特點是很多邏輯在服務端計算,重要數據由服務端控制,客戶端多數時候著重於表現。而弱聯網遊戲因為要求玩家能在不聯網或網路環境很差的情況也能正常玩,所以客戶端可能包含了很多重要的遊戲邏輯和數據,服務端則提供一些額外的業務邏輯,比如作弊校驗,數據同步,排行榜,各種聯網活動等。如果我們信賴客戶端的邏輯和數據,那麼一旦客戶端被破解,整個遊戲就會被操控,輕者損失了部分玩家,重者會污染遊戲的整個生態環境。最麻煩的是,破解者只要有程式碼,本質上被破解就只是個成本和時間的問題。但是,我們仍有各種方式來抵禦常見的破解和外掛。對於那些根本上很難防住的破解方式,我們至少能大大增加其破解成本。
本文從兩方面來總結:客戶端和服務端。這篇先講客戶端,分為幾個章節:
– 加固
– 記憶體加密
– 程式碼混淆
– 破解apk
– 資源加密
– 玩家存檔加密
– 時間防作弊
加固
加固是對程式碼做各種形式的變換,比如加密,混淆,隱藏等,以提高程式碼逆向的難度。這是所有遊戲都通用的一個技術,有不少公司提供了成熟的解決方案,比如網易,騰訊,樂變。已有的加固技術包括:
1 加殼
目的是防止二次打包。對加殼後的apk包重簽名,進遊戲時會閃退。
加殼分兩種方式:
(1)dex加固:比較成熟,很多廠商採用的解決方案,比如樂變。
(2)so加固:比較新,網易易盾用的此方案,native層加密,更安全可靠。
2 反調試
目的是防止IDA動態調試。
這部分沒什麼需要過多考慮的,建議直接從這些成熟的解決方案中挑選一個應用於項目。
記憶體加密
網上有一些記憶體修改器可以搜索和修改記憶體數據,從而實現各種誇張的效果,比如金幣無限,血量無限,攻擊力無限等。常用的工具有八門神器,葫蘆俠,燒餅修改器。他們的使用原理都是類似的,比如,若要修改玩家當前的金幣數,先用工具在記憶體中搜索當前的金幣數值,會搜出來很多記憶體地址。然後消耗一些金幣,在之前的記憶體地址中再搜索當前的金幣數,得到較少的匹配地址。重複該步驟,直到只剩一個地址匹配,就是存放金幣的記憶體地址。最後,通過工具更改該地址存儲的數值,就能把金幣數改成一個很大的數值。
要防止這種工具的破解,就需要對記憶體數據做加密,讓工具搜索不到該數據所在的記憶體地址。最簡單的方案是:
1 準備一個key值,不要用字元串明文,得是運行期動態生成的。
2 存數據時,先把數據和一個key做異或操作,再存到記憶體。
3 讀數據時,把從記憶體讀出的數據和同樣的key做異或,返回給上層。
該方案簡單高效,能防住大部分記憶體修改器,但有一些搜索功能比較強大的工具,比如燒餅修改器有模糊搜索功能,仍能搜索到經過加密的數據。於是我們需要一個更強大的方案。
由於這些記憶體修改器都是在搜索到的記憶體地址集合里再次搜索篩查,所以只要不停地變換數據存儲的地址,就能從根本上防住這種修改器。具體做法是:
對於任何一個需要加密的數據類型:
1 分配N個同類型元素的數組,N至少為3。
2 每次存儲數據時,數組index加1,若超出數組長度則index歸零,然後將數據和一個key做異或,得到加密數據,將其存儲到該index指向的數組槽。記錄下當前的index和key。
3 讀取數據時,根據存儲的index,讀取數組槽中的數據,和key做異或,將結果返回。
實測下來,經過這樣的處理後,燒餅修改器也完全無法搜索到其記憶體地址,所以能有效防住這種類型的工具。該方案聽說在騰訊內部項目里使用了,筆者自己在Unity里實現了一套加密數據類型,可直接拿來在項目中使用,放在Github上[1]:
該程式碼實現的要點:
1 用泛型盡量精簡了程式碼。
2 實現了類型轉換的操作符,這樣能最大程度簡化已有項目的重構,比如若要將基礎數據類型更改為加密數據類型,只需要更改變數聲明處的類型,比如將int改為EncryptInt,其他的上層程式碼不需要做任何改動,自定義的類型轉換操作符會幫助編譯器處理剩下的工作。
需要注意的是,實際項目中應全面地對任何遊戲介面可見的關鍵性數據做加密,比如金幣,血量,攻擊力等。而且,所有會和關鍵性數據做運算的相關數據,也得用加密類型。比如,有一個遊戲內彈框介面,上面可以讓玩家自由選擇要購買的道具數量及對應的金幣花費,那麼此處的金幣花費的變數也應做加密。否則,玩家通過多次更改道具數量,就能用工具很容易地搜索出金幣花費對應的地址,然後將其修改為0或者負數,再進行購買,就能達到買道具不花錢或者買完金幣增加的效果。防破解這種事,百密一疏就會導致嚴重的問題,所以在防禦上要盡量考慮全面。
程式碼混淆
網上有各種工具能對Unity遊戲的dll文件做反編譯,或者對so文件做反彙編。Dll反編譯後,所有程式碼就非常可讀,毫無安全性。所以我們需要把程式碼中的各種元素,比如類名,函數名,變數名,改成無意義或很難看懂的名字,使得破解者即使反編譯了程式碼也很難讀懂,從而加大破解難度。常用的Unity程式碼混淆工具有Obfuscator,Obfuscar,CodeGuard等,這些工具大部分都是在.Net IL層修改位元組碼,不影響正常開發流程。另外,還有很多針對iOS和Android原生層的工具。
以Obfuscator插件為例,有一個名為ObfuscatorOptions的配置文件,其中很多設置會影響混淆的強度。值得注意的設置有:
1 Name mapping history
勾選,混淆時會生成符號映射文件,記錄混淆前後的名字映射關係。
2 Rename
選擇哪些被混淆。對於上層接入了lua的項目,就只勾選private和protected的函數和變數,不對public成員做混淆。因為public函數可能被lua層調用,如果做混淆,那麼lua程式碼也要相應做修改,無法方便地維護。
函數名被混淆後,會帶來一些不便:
(1)崩潰統計後台顯示的是混淆後的名字,如果是private或protected函數,就需要查符號映射表得到混淆前的名字。
(2)若接入了xlua程式碼熱修復,那麼熱修復private或protected函數時,也需要查符合映射表,調用xlua_hotfix時得傳入混淆後的函數名。
3 Fake code
勾選後會增加垃圾程式碼,通過改變一些fake相關的參數可以調整混淆的強度。需要注意fake code加得越多會導致程式碼尺寸越大,一是會增加包體,二是在IL2CPP模式下,iOS包體程式碼尺寸可能會超過蘋果規定的限制,從而導致審核上傳時被拒。
4 Unity methods
該列表中的函數不會被混淆,可根據項目自身需求刪減。除了這個列表,對於自己寫的lua層回調函數,使用了反射調用的函數,和Inspector里綁定的事件函數,還可以在函數聲明前加[SkipRename]屬性來避免被混淆。
程式碼混淆的作用除了增加破解難度以外,還能用於應付蘋果審核。蘋果對馬甲包的審核很嚴格,如果你的app和其他app在程式碼和資源上相似度很高,就會有審核被拒的風險。程式碼混淆工具就可以用來人為製造二進位包的差異化。但是,由於流行的混淆工具都是在IL層把各種名字改為隨機的類似亂碼的名字,二進位的特徵和正常app是不同的,可能會在蘋果機審階段被查出來,導致被拒。很多開發者就因為過度使用了混淆工具,收到了蘋果爸爸類似這種回信:
We discovered that your app contains obfuscated code, selector mangling, or features meant to subvert the App Review process by changing this app’s concept after approval to the App Store. The next submission of this app may require a longer review time, and this app will not be eligible for an expedited review until this issue is resolved.
所以,為了避免不必要的審核風險,建議大家不要過度依賴這些混淆工具,可以自己寫一些腳本,在源程式碼層或IL層處理字元串替換。
破解apk
破解apk包的危害很大。破解者可以把包破解後,傳到網上供人下載。對於Unity apk包,網上已經有比較統一的破解流程,這裡做一個簡單的總結。下面的方法能處理未做加固加殼處理的,若做了加固加殼,就會使得一些文件結構被修改,方法就不一定奏效了。
Unity有兩種腳本後端模式:mono和il2cpp。mono比較老,現在大部分遊戲使用了il2cpp。Apk解包後,通過裡面的文件資訊能判斷是哪一種模式:
1 如果assets/bin/Data/Managed/下有一堆dll文件,其中有Assembly-CSharp.dll,則是mono
2 如果assets/bin/Data/Managed/下有三個文件夾:etc/,Metadata/,Resources/,則是il2cpp
不管是mono或il2cpp,破解流程都大致如下:
1 解包
可用apktool運行命令解包abc.apk:
apktool d -r abc.apk
得到同名文件夾。注意用命令行解包,若把apk的後綴改為zip解壓縮,得到的文件夾中會缺少apktool.yml文件,到後面重新打包時會報錯:
brut.directory.PathNotExist: apktool.yml
2 修改程式碼
解包後根據文件資訊判斷是mono還是il2cpp。
對於mono包:
(1)Windows機器上安裝.Net Reflector和Reflexil插件,用它打開assets/bin/Data/Managed/Assembly-CSharp.dll。
(2)查看反編譯的dll程式碼,嘗試去找需要破解的邏輯,直接修改IL程式碼,或寫源程式碼然後用Reflexil編譯成IL。
(3)將修改後的程式碼導出為新的Assembly-CSharp.dll,覆蓋前面解包目錄下的同名文件。
對於il2cpp包:
(1)用il2cppDumper工具[2],根據這兩個文件:
– lib/armeabi-v7a/libil2cpp.so:包含所有可執行彙編程式碼
– assets/bin/Data/Managed/Metadata/global-metadata.dat:包含符號表資訊
運行il2cppDumper,會生成兩個文件:
– dump.cs:包含所有函數及地址資訊
– script.py或ida.py(由il2cppDumper版本決定):作為IDA的腳本後面使用
(2)查看dump.cs,嘗試去找自己感興趣的函數資訊。
(3)用IDA打開libil2cpp.so,先運行script.py或ida.py添加各種符號的可讀資訊,若是ida.py,還需要選擇script.json。這時各種類和函數都具有了可讀的字元串名字。找到需要破解的邏輯地址,修改彙編程式碼。
(4)將修改後的程式碼導出為新的libil2cpp.so,覆蓋解包目錄下的同名文件。
3 重簽名打包
(1)運行命令:
keytool -genkey -keystore mykey.keystore -keyalg RSA -validity 10000 -alias mykey
得到mykey.keystore文件。
(2)運行命令:
apktool b abc
得到abc.apk文件,位於目錄abc/dist/。
(3)運行命令簽名打包:
jarsigner -digestalg SHA1 -sigalg MD5withRSA -verbose -keystore mykey.keystore -signedjar abc_signed.apk abc/dist/abc.apk mykey
得到新包abc_signed.apk。
網上有些教程里會加上-tsa參數,測試下來會導致報錯:
jarsigner error: java.lang.NullPointerException
上述破解方式的關鍵還是在於讀懂反編譯或反彙編的程式碼,找到關鍵邏輯程式碼做修改。破解者可能會搜索user,level,coin這種常見的關鍵字,進而很容易就找到關鍵邏輯。所以,我們可以盡量混淆這些關鍵類名,函數名,變數名等,改成一些難讀懂甚至具有誤導性的名字,就能增加破解的難度。但是,如前面所說,這些都只是增加了破解難度,只要有程式碼,破解就只是時間和成本問題。
針對這種破解方式,有些安全方案對這些靜態文件做了保護。mono模式下,對Assembly-CSharp.dll做加密,改變了PE文件格式,使得反編譯工具無法識別。il2cpp模式下,可對so文件做加密,或對global-metadata.dat符號文件做保護,使得工具無法還原出符號資訊,也增加了破解難度。
資源加密
普通的未加密的ipa和apk包,我們可以用工具解包,很容易得到資源的明文形式。對於Unity包,可以用資源查看工具(比如AssetStudio)解出Resources目錄下的資源和各種AssetBundle資源。所以我們需要對資源做加密,以保證至少無法用工具簡單地解包。
一般Unity項目的很多資源都打成了AssetBundle,所以需要對AssetBundle做加密。很容易想到的方式是:
1 構建打AssetBundle包時,對資源做對稱加密
2 運行期載入時,先把AssetBundle載入到記憶體,用key解密,得到解密後的AssetBundle記憶體
3 調用AssetBundle.LoadFromMemory(Async)介面從記憶體中載入資源,初始化對象
這一切看起來很清晰完美。但不幸的是,用AssetBundle.LoadFromMemory(Async)載入資源,會導致記憶體使用量暴增。一份資源通過該介面載入,會在記憶體里出現三份拷貝,除了資源本身在系統層或GPU層有一份,還會在Native層和託管層里各有一份。如果是LZMA格式,會先解壓縮再存儲,記憶體消耗比資源原始資源尺寸更大。所以,官方其實不推薦使用該介面[3]。
那麼,還有更簡單的方式嗎?也有,UWA提供了一個加密方式[4],通過給AssetBundle文件內容加一個偏移,就能做到無法用資源查看工具直接讀取其內容。該方案的優點是簡單高效,不耗額外記憶體,但缺點也很明顯,它的防護強度很弱。
除了AssetBundle,ScriptableObject資源也沒有簡便的加密方式。所以,Unity在設計上就沒有很好地支援資源加密,可能是因為國外沒有我們中國市場的一些困擾。Unity中國團隊針對我們的國情,出了個Unity增強版,介面上直接支援了AssetBundle的加密,使用起來很簡單[5]。是否合適好用就由大家各自判斷了。
除了Unity格式的資源,對於通用格式的資源,比如csv,json,xml,lua文件等,可能也包含非常重要的資訊,並且文件尺寸通常不大。就可以用前面提到的方式,打包時做對稱加密,運行期先讀到記憶體做解密,然後載入初始化。
需要注意的是,不管加密什麼格式的資源,加密的密鑰務必要隱藏好,至少不要用明文字元串,應在運行期用演算法動態生成,然後儘可能讓這個函數不容易被發現和讀懂。每發布一次版本,都可以更換一次密鑰,使得破解者用老版本的密鑰無法破解新版本的資源。
另外,網上有VirBox Protector這種加固工具,也包含了資源加密的功能。
玩家存檔加密
重要的數據都需要加密。和資源一樣,玩家存檔本質也是一種重要的數據,會序列化成文件,所以加密思路和資源加密類似。不同的是存檔數據由玩家玩的時候動態生成,而且可能在不同程式碼版本間流通,需要考慮兼容性。對於強聯網遊戲,玩家存檔數據中重要的部分都存儲在服務端,只要設計得當,客戶端無論如何怎麼修改數據,都不會導致嚴重的後果。但對於弱聯網遊戲,玩家在沒聯網的情況也能玩,就不得不以客戶端的數據為主導,防破解的難度很大。
存檔可存放在自定義的文件中,這種情況下加密方式可以和資源加密一樣。對於Unity包,本地存檔常放在PlayerPrefs中,本質上是鍵值對,我們無法對PlayerPrefs整個文件操作,就可以對鍵和值分別做加密,或只對值做加密。和資源加密一樣,注意保護好加密密鑰。如果要更換密鑰,需要處理數據的前後兼容問題。除了文件加密外,玩家存檔在記憶體中的數據應做記憶體加密。
一種破解方式是,玩家把自己的存檔文件傳到網上,其他玩家下載下來複制到本地,實現存檔轉移。比如有些遊戲淘寶上就有賣家將高進度或破解後的個人存檔出售。為了防禦這種情況,可以讓一個玩家的存檔包含了自己的標識符資訊,使得在另一個玩家的設備上無法打開。一個簡單的方案是,存檔的加密密鑰有玩家UDID或設備ID參與,比如用原始密鑰和UDID做異或拼接等操作,或者原始密鑰和UDID的MD5做異或操作。
時間防作弊
很多遊戲功能依賴於系統時間,比如體力恢復,建築升級,各種CD時間。對於強聯網遊戲,所有時間都由服務端控制,比較好處理。弱聯網遊戲則相對比較麻煩。如果完全信任本地時間,那麼玩家可通過修改本地系統時間來達到很多目的。所以,整體思路是,聯網的時候完全信任網路時間。沒聯網的時候,就用系統本地時間。等到聯網後再對時間做校正,以及做作弊判定。
網路時間可通過NTP協議或自己的服務端獲取。NTP其實不太可靠,有時會連不上,建議使用自己的服務端。注意由於網路傳輸的延時及不穩定性,獲取到的網路時間會在真實時間值附近波動,所以在作弊判定時,應留有足夠的閾值。
iOS或Android原生層都有介面可獲取設備開機到現在的流逝時間,比如在Android上,介面是SystemClock.elapsedRealtime()。該數值不會受到玩家修改本地時間而影響,所以是一個更值得信賴的數值。但該介面的問題是設備重啟後,這個數值會重新從零開始計算。
藉助這個設備啟動流逝時間的機制,可設計一個聯網時完全可靠的時間獲取邏輯,不受玩家調整本地時間的影響。方案如下:
1 遊戲啟動後開啟協程獲取網路時間,若沒網路或沒獲取到就隔一段時間再觸發,直到獲取成功。
2 獲取到網路時間時,記錄獲取到的網路時間為N1,記錄此刻設備重啟後流逝的時間D1。
3 以後任意時刻要獲取當前的時間,就先獲取此時設備重啟後流逝的時間D2,計算當前時間為:
Tn = N1 + (D2 – D1)
N1,D1,D2都是完全可信賴的,所以任意時刻的Tn也是準確的。
由於訪問原生層介面可能會有一定性能消耗,如果時間獲取調用頻率很高,就可以優化為每幀只訪問一次原生層介面,快取該值,該幀的後續操作都訪問快取的值,直到下一幀再調用原生層介面。
沒聯網的時候,就使用系統本地時間。再次聯網時,對時間做校正,以及作弊判定。要判定玩家是否修改了系統本地時間來作弊,有如下方式:
1 正常情況下,玩家的本地時間和聯網時間可能有一定差值。但只要玩家不調本地時間,該差值應幾乎在某一固定值附近波動。如果檢測到該差值有很大變化,就可以判定為作弊。
2 正常情況下,玩家的本地時間會一直往前走。如果檢測到本地時間有後退的情況,就可以判定為作弊。
判定為作弊後,如何懲罰玩家,就取決於業務需求了。
有一種時間外掛叫加速齒輪,可以加速本地時間的流逝。這個也可以通過聯網時本地時間和聯網時間的差值來判定,如果該差值呈現一個穩定線性遞增的模式,就可以判定為使用了時間加速功能。
參考