手遊防破解防外掛技術方案(一)客戶端篇

nichos1983發表於2020-07-23
對於任何一款要長期線上運營的遊戲,防破解防外掛是必不可少的。本文總結了手遊常用的防破解防外掛技術方案,這些方案都經過了筆者所在團隊和線上專案的長期考驗。很多方案來自於弱聯網手遊專案,但大部分思路也同樣適用於強聯網遊戲。以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和安卓原生層的工具。
 
以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或安卓原生層都有介面可獲取裝置開機到現在的流逝時間,比如在安卓上,介面是SystemClock.elapsedRealtime()。該數值不會受到玩家修改本地時間而影響,所以是一個更值得信賴的數值。但該介面的問題是裝置重啟後,這個數值會重新從零開始計算。
 
藉助這個裝置啟動流逝時間的機制,可設計一個聯網時完全可靠的時間獲取邏輯,不受玩家調整本地時間的影響。方案如下:
1 遊戲啟動後開啟協程獲取網路時間,若沒網路或沒獲取到就隔一段時間再觸發,直到獲取成功。
2 獲取到網路時間時,記錄獲取到的網路時間為N1,記錄此刻裝置重啟後流逝的時間D1。
3 以後任意時刻要獲取當前的時間,就先獲取此時裝置重啟後流逝的時間D2,計算當前時間為:
Tn = N1 + (D2 - D1)
N1,D1,D2都是完全可信賴的,所以任意時刻的Tn也是準確的。
由於訪問原生層介面可能會有一定效能消耗,如果時間獲取呼叫頻率很高,就可以優化為每幀只訪問一次原生層介面,快取該值,該幀的後續操作都訪問快取的值,直到下一幀再呼叫原生層介面。
 
沒聯網的時候,就使用系統本地時間。再次聯網時,對時間做校正,以及作弊判定。要判定玩家是否修改了系統本地時間來作弊,有如下方式:
1 正常情況下,玩家的本地時間和聯網時間可能有一定差值。但只要玩家不調本地時間,該差值應幾乎在某一固定值附近波動。如果檢測到該差值有很大變化,就可以判定為作弊。
2 正常情況下,玩家的本地時間會一直往前走。如果檢測到本地時間有後退的情況,就可以判定為作弊。
判定為作弊後,如何懲罰玩家,就取決於業務需求了。
 
有一種時間外掛叫加速齒輪,可以加速本地時間的流逝。這個也可以通過聯網時本地時間和聯網時間的差值來判定,如果該差值呈現一個穩定線性遞增的模式,就可以判定為使用了時間加速功能。
 
 
參考
 

相關文章