您當前的位置:首頁 > 遊戲

Unity遊戲菜雞玩家的制勝之路

作者:由 perhaps 發表于 遊戲時間:2017-11-07

筆者一直自認玩過不少遊戲,無奈水平太菜,日常送人頭。痛定思痛,決定衝(xie)冠(xiu)一(gai)怒(qi),經過幾次失敗的嘗試之後,終於搞定了幾款時下熱門的Unity遊戲。出於各種原因,本文以一款不具名的國外遊戲作為例項,分享筆者研究過程中的一些心得,與各位分享。

0X00 打包黨的鶸改法

首先採用最簡單的打包黨策略,演示一下如何快速修改一款單機遊戲的金幣/寶石等資源。這部分快速帶過,主要負責熟悉 Unity 遊戲結構,時至今日已經不算一種技術了。

首先將遊戲安裝 APK 解包,這裡使用 apktool 或者直接看作 zip 解包是沒有區別的,因為遊戲嚴重依賴框架,Java 層和 Manifest 等檔案價值不大。作為 Unity 遊戲的一個特徵點,可以很明顯的發現這樣一個資料夾。

Unity遊戲菜雞玩家的制勝之路

Unity遊戲菜雞玩家的制勝之路

Assembly-CSharp。dll 等幾個檔案是 Unity 遊戲最鮮明的特點。透過 file 或者 binwalk 檢視可以發現它們是 C# 位元組碼格式( IL ),這種格式如果不進行加密,可以輕鬆的還原 C#指令。

接下來在手機中安裝一次遊戲,看一下大致的遊戲邏輯,確定需要修改什麼。

Unity遊戲菜雞玩家的制勝之路

Unity遊戲菜雞玩家的制勝之路

看起來右上角的寶石不錯。。。

一般來說在遊戲裡邊寶石都是稀缺資源,這裡以其作為目標。為了快速定位,嘗試使用 diamond ,gem 等字串在 cs原始碼中進行全域性搜尋,很快就能定位到關鍵位置。

private void SetupFirebaseDefault()

{

this。Defaults。Add(“EnergyStart”, 20);

this。Defaults。Add(“InitialEnergy”, 75);

this。Defaults。Add(“EnergyFillerSecs”, 420);

this。Defaults。Add(“InitialGold”, 200);

this。Defaults。Add(“InitialGems”, 50);

this。Defaults。Add(“XpRequirement”, 75);

this。Defaults。Add(“XpReqIncremental”, 75);

this。Defaults。Add(“XpAttackBonus”, 5);

this。Defaults。Add(“BubblesRequired”, 6);

this。Defaults。Add(“StoreBubbleCostsAddOn”, 2);

this。Defaults。Add(“ReviveCost”, 100);

this。Defaults。Add(“ShopTokenCost”, 100);

this。Defaults。Add(“ShopTokenCostIncremental”, 75);

this。Defaults。Add(“ShopTokenMax”, 10);

this。Defaults。Add(“ShopFigMax”, 3);

this。Defaults。Add(“ShopKeyMax”, 5);

this。Defaults。Add(“EnergyPricesInGemsBig”, 200);

this。Defaults。Add(“EnergyPricesInGemsSmall”, 50);

this。Defaults。Add(“EnergyPackageBig”, 60);

this。Defaults。Add(“EnergyPackageSmallMin”, 5);

this。Defaults。Add(“RepeatedFigurineTokenConversion”, 7);

this。Defaults。Add(“RefreshLootCost”, 20);

。。。

}

InitialGems

欄位明顯是初始寶石數的意思,OK就它了。C# 位元組碼的修改有很多種方式,比較方便的工具是 Reflector ,這裡因為沒有這個工具,使用 ILDASM 反編譯,隨後修改, ILASM 重編譯回去的方法。這兩個工具都是微軟官方提供的,當然可以百度搜到。 ILDASM 具有圖形化介面,直接從其中 dump 出來即可,隨後修改 dump 出的 IL 檔案如下:

Unity遊戲菜雞玩家的制勝之路

Unity遊戲菜雞玩家的制勝之路

接下來使用 ILASM 命令 ilasm。exe name。il /DLL 可以將 IL 檔案回編譯成 DLL ,將其替換 APK 包中的對應 DLL ,簽名,安裝之後可以發現修改生效了。

Unity遊戲菜雞玩家的制勝之路

Unity遊戲菜雞玩家的制勝之路

這就是 Unity 遊戲打包黨快速修改的過程,看起來很簡單,但是卻存在不少問題:

這個遊戲沒有做任何保護,一旦存在保護,比如IL位元組碼加密,就需要去跟IL位元組碼載入的邏輯,伺機恢復明文IL位元組碼。

加殼問題,好在加殼是針對Java層的,考查了國內幾款主流遊戲之後發現基本沒有加殼,因為殼並不能保證ELF檔案的安全,ELF檔案很可能使用其他安全策略。

重打包問題,國內遊戲是不可能讓你修改資料重打包的,特別是聯網遊戲,會有多處完整性校驗,因此修改工作必須在執行過程中進行。

綜合以上,我們雖然完成了對一款毫無安全保護的 Unity 遊戲的修改,但是為了進一步研究適用於更復雜條件下的修改策略,還需要進一步研究心得方案。

0X01 注入與hook

考慮到國內主流遊戲的安全機制,必須使用執行時修改的方式。比較理想的方式是先注入 zygote 程序。zygote 程序是 Dalvik 虛擬機器的孵化器程序。眾所周知,常規的 Android APP 是執行在 Dalvik 虛擬機器(或者其繼承者 ART )中的,虛擬機器需要載入很多執行所需的庫(如 libdvm。so ),並且初始化虛擬機器物件。這個過程費時費力,為了保證應用的啟動速度,zygote被設計為虛擬機器程序的父程序。當應用啟動時,直接從 zygote 上 fork() 出來,繼承其虛擬記憶體空間。因此,注入到 zygote 程序的好處是先於應用程式碼執行,可以有效避免注入過程被應用的 anti-ptrace 機制檢測到。

Android 平臺上的注入已經是相對成熟的一套程式碼,最初是由看雪版主古河大大發布,隨後出現了很多的更新、最佳化版本。其基本思路是利用 Linux 平臺上的跨程序控制機制 ptrace ,透過對 ptrace 的封裝實現目標程序的讀、寫,暫存器獲取、儲存、恢復,頁狀態變更、寫入一段施工程式、遠端呼叫施工程式,負責將待注入模組載入到目標記憶體中。這些內容前人之述備矣,這裡貼幾個相關連結,不在做具體展開。

[原創]發個Android平臺上的注入程式碼-『Android安全』-看雪安全論壇 libinject

android hook 框架 libinject2 簡介、編譯、執行 libinject2

Android程序的so注入——Poison(穩定注入版) - 水汐。2014 的專欄 - CSDN部落格 Poison注入框架

在完成注入之後,我們的功能程式碼即可在目標程序中執行,接下來需要在目標程序中執行 hook 過程。透過 hook 技術,可以截斷一個函式的執行流程並插入自定義的程式碼。針對 zygote 注入,這個問題稍微複雜。因為在我們注入 zygote 的時機,遊戲程序還沒有啟動,因此無法直接 hook 到目標函式。後面將會介紹到,我們的目標函式是 native 層的 c 函式。因為 zygote 程序最後會 fork 成遊戲程序,為了感知遊戲程序中目標函式的載入,可以監控該函式所在的庫的載入,那麼就需要用到 linker 中的 dlopen 函式。

void *dlopen(const char *filename, int flags);

透過 hook dlopen 函式並檢查 filename 引數確定目標庫的載入,隨後再一次進行實際的功能性 hook ,hook 目標函式達到修改目的。也就是說,透過 zygote hook 的方式 hook 一個目標函式,需要進行兩次 hook ,第一次是 hook linker 中的 dlopen 以確定目標模組的基址,第二次是在該模組中 hook 目標函式。這裡有一個小問題是由於 dlopen 函式在每一個 zygote 的子程序中都會被 hook ,導致系統性能下降,一個解決方案是定期檢視

/proc/pid/cmdline

如果自身不是目標程序那麼就解除 hook 。

針對 Unity 遊戲的 hook 思路大體如上,本節的最後再講講關於使用的 hook 框架。Hook 操作的原理可以理解為強行修改程式的程式碼段,透過修改目標地址上的位元組碼為 B ,JMP 等指令將指令流跳轉到 hook 者控制的位置執行另一段指令。當然實際實現中複雜性遠遠大於這句描述,因為指令執行完畢之後通常需要返回到 hook 前的位置,如何保證 hook 點處指令、暫存器值等各種資訊完好,是需要很大工作量的。Java 層的 hook 框架可以使用 XScript 、frida 、cydia substrate 等等,native 層筆者嘗試過的有效工具有 cydia substrate和android-inline-hook ( ele7enxxh/Android-Inline-Hook )。ARM 平臺上的 hook 工具開發有幾個坑點,一個原因是由於 ARM 有大量位置相關程式碼,如果 hook 點在這種指令上,那麼想要在異地恢復這條指令相當困難;另一個原因是 ARM 上存在 Thumb 指令集,需要考慮判斷當前指令集並執行不同的操作。

有了注入和 hook 兩種工具,就可以完成對目標函式的執行時修改。下一節探討針對 Unity 遊戲,具體修改哪些函式可以完成對遊戲邏輯的控制。

0X02 Mono載入C#位元組碼過程分析

> 可能很多人都像我一樣好奇過,Android 是一個類 Java 虛擬機器部署在 Linux 平臺上,怎麼就跑起來了微軟的 C# ?其實 C# 已經被 ECMA 組織標準化(雖然這組織和微軟淵源頗深),並且標準基礎上出現了一套執行時( Common Language Runtime , CLR )。這套執行時的具體實現是一個叫做 mono 的開源專案。

本節介紹 mono 執行 C# 位元組碼的過程。Android 上的 Unity 正是透過 mono 的 Just-in-time Compile 機制完成了從 C# 語言世界到 ARM 機器碼世界的轉化。接下來對 Mono 專案的原始碼中對 DLL 處理的邏輯做一個分析。

首先,mono 載入 DLL 檔案之後,會進行預編譯,首先呼叫

/mono/mini/mini.c

中的 mono_precompile_assemblies 函式,該函式對所有需要載入的 assembly 檔案逐個呼叫 mono_precompile_assembly 。

void mono_precompile_assemblies ()

{

GHashTable *assemblies = g_hash_table_new (NULL, NULL);

mono_assembly_foreach ((GFunc)mono_precompile_assembly, assemblies);

g_hash_table_destroy (assemblies);

}

static void

mono_precompile_assembly (MonoAssembly *ass, void *user_data)

{

。。。

for (i = 0; i < mono_image_get_table_rows (image, MONO_TABLE_METHOD); ++i) {

method = mono_get_method (image, MONO_TOKEN_METHOD_DEF | (i + 1), NULL);

mono_compile_method (method);

if (strcmp (method->name, “Finalize”) == 0) {

invoke = mono_marshal_get_runtime_invoke (method, FALSE);

mono_compile_method (invoke);

}

。。。

}

這裡摘取了 mono_precompile_assembly 函式的關鍵步驟。該函式中針對當前需要處理的的 assembly ,對其中每一個函式呼叫 mono_compile_method 進行編譯,同時編譯 invoke 。這個 invoke 是對應函式的一個包裝器,當 mono最終呼叫函式時,會透過包裝器呼叫而不是直接呼叫。因此在函式 compile 完成之後,會生成並編譯 invoke 函式。

接下來分析的關鍵是 mono_compile_method 函式,真正的編譯過程發生在這個函式中。該函式不是唯一的,因為 mono 同時支援 AOT( ahead of time )編譯,未來也可能新增其他功能。因此這個函式這裡為一個函式指標,在 JIT 編譯環境下執行的是 mono_jit_compile_method 函式。

gpointer

mono_jit_compile_method (MonoMethod *method)

{

MonoException *ex = NULL;

gpointer code;

code = mono_jit_compile_method_with_opt (method, mono_get_optimizations_for_method (method, default_opt), &ex);

if (!code) {

g_assert (ex);

mono_raise_exception (ex);

}

return code;

}

這個函式呼叫了 mono_jit_compile_method_with_opt 函式做具體操作,注意這裡返回的是 gpointer 指標,其實這個指標指向的就是 DLL 指令碼最終編譯成彙編所在的地址,後續如果我們需要修改生成的彙編程式碼,修改這個指標即可。接下來我們稍微深入跟進一些。

static gpointer

mono_jit_compile_method_with_opt (MonoMethod *method, guint32 opt, MonoException **ex)

{

。。。

target_domain = mono_get_root_domain ();

info = lookup_method (target_domain, method); //先查表判斷是否已經編譯

if (info) {

/* We can‘t use a domain specific method in another domain */

if (! ((domain != target_domain) && !info->domain_neutral)) {

MonoVTable *vtable;

MonoException *tmpEx;

mono_jit_stats。methods_lookups++;

vtable = mono_class_vtable (domain, method->klass);

g_assert (vtable);

tmpEx = mono_runtime_class_init_full (vtable, ex == NULL);

if (tmpEx) {

*ex = tmpEx;

return NULL;

}

return mono_create_ftnptr (target_domain, info->code_start);

}

}

code = mono_jit_compile_method_inner (method, target_domain, opt, ex);//實際編譯點

···

p = mono_create_ftnptr (target_domain, code);

···

return p;

}

這裡隱去了編譯 invoke 函式的程式碼和一些細枝末節的 check 。可以看到, mono_jit_compile_method_with_opt 函式的主要流程是首先查表看當前要編譯的函式是否已經編譯,如果已經編譯,則直接返回編譯好的結果;否則,呼叫 mono_jit_compile_method_inner 函式實際編譯並註冊到 target_domain 中,隨後透過 mono_create_ftnptr 函式獲取函式指標。因為這部分程式碼是複用的,除了首次載入 DLL 之外的一些情景也會呼叫該函式,其中存在一些函式已經編譯的情況。 mono_jit_compile_method_inner 函式以下是一些與機器相關的具體機器碼生成過程,對虛擬機器感興趣的朋友可以進一步學習,這裡就不繼續深究了,簡單把整個呼叫過程整理一下:

graph TD;

start——>mono_precompile_assemblies

mono_precompile_assemblies——>|foreach|mono_precompile_assembly

mono_precompile_assembly——>|函式體|mono_jit_compile_method

mono_precompile_assembly——>|invoke|mono_marshal_get_runtime_invoke

mono_marshal_get_runtime_invoke——>mono_jit_compile_method

mono_jit_compile_method——>mono_jit_compile_method_with_opt

mono_jit_compile_method_with_opt——>|已經編譯過|mono_create_ftnptr

mono_jit_compile_method_with_opt——>|沒有編譯過|mono_jit_compile_method_inner

mono_jit_compile_method_inner——>mini_method_compile

mini_method_compile——>mono_codegen

mono_codegen——>mono_create_ftnptr

mono_create_ftnptr——>finish

至此我們的分析完成了,儘管虛擬機器可以使用花樣繁多的語言開發,但是最終在執行前一需要恢復成本地機器碼去執行。這就給了我們下 hook 的機會,下一節介紹透過修改 mono 編譯出來的彙編函式邏輯,完成對遊戲流程的動態修改。

0X03 透過修改虛擬機器生成的彙編指令修改遊戲邏輯

接下來我們嘗試利用前面兩節介紹的知識,修改遊戲的執行邏輯。

private void MainButtonClicked()

{

。。。

case UI_ConfirmationPopup。ScreenType。BuyCoins:

{

int num = Tuning。ShopCoinPackagesPrices[this。coinIndex];

int num2 = Tuning。ShopCoinPackages[this。coinIndex];

if (UserProfile。Gems >= num)

{

UserProfile。Gold += num2;

UserProfile。Gems -= num;

this。purchasedAmount = num2;

this。screenType = UI_ConfirmationPopup。ScreenType。CoinsPurchased;

Events。Instance。UI_MARKET_PURCHASED();

GeneralManager。Analytics。ReportGoldPurchased(this。coinIndex, num);

this。CallItQuits();

}

else

{

this。DisableAssets(true);

this。LaunchOutOfGems();

}

break;

}

。。。

}

我們選擇 MainButtonClicked 這個函式作為目標,其中的購買金幣分支會檢測當前鑽石數量,如果數量夠則進行購買,否則不進行購買。在 mono_jit_compile_method_with_opt 函式上下鉤子,檢查第一個引數 method 的 name 欄位是否包含“ MainButtonClicked ”,在包含這個欄位時,將 gpointer 指向的函式 dump 出來。

if(!strstr(name, “MainButtonClicked”)) return target(arg1, arg2, arg3);

LOGE(“find MainButtonClicked”);

void* funcptr = target(arg1, arg2, arg3);

LOGE(“function MainButtonClicked base is: %0lx”, funcptr);

int fd = open(“/data/local/tmp/dump”, O_WRONLY | O_CREAT);

if(fd == -1){

LOGE(“open error: %s”, strerror(errno));

exit(-1);

}

if(write(fd, funcptr, 0x1000 * 0x1000) == -1){

LOGE(“write error: %s”, strerror(errno));

exit(-1);

}

如上述程式碼所示,這次 hook 在 mono_jit_compile_method_with_opt 函式每次編譯 C# 函式點進行判斷,當被編譯的函式是我們的目標 MainButtonClicked 時,對記憶體進行 dump ,將編譯成機器碼的 MainButtonClicked 輸出出來,接下來,使用 IDA 對該函式進行分析。

在載入該函式時需要注意,由於 dump 出來的是部分記憶體,不像標準的 elf 檔案一樣有各種配置能夠載入,識別為 binary file ,需要手動指定處理器架構,這裡是 ARM 。另外需要指定硬碟檔案偏移和程式在記憶體中偏移的對映關係,注意上邊程式碼中第四行輸出了程式在記憶體中的地址,IDA 能夠利用 file_offset+memory_base 計算出相當一部分的跳轉指令的跳轉地址(當然,由於我們只 dump 了很小一部分記憶體,仍然有很多依賴相對偏移定址的跳轉目標無法恢復,但對程式結構的分析無太大影響)。

透過 IDA 載入後,可以看出函式明顯是一個 switch-case 結構:

Unity遊戲菜雞玩家的制勝之路

Unity遊戲菜雞玩家的制勝之路

這個結構與 MainButtonClicked 函式原始形式一致,透過分析二者關係可以定位到金幣購買時點選確定按鍵對應到的 case :

case UI_ConfirmationPopup。ScreenType。BuyCoins:

{

int num = Tuning。ShopCoinPackagesPrices[this。coinIndex];

int num2 = Tuning。ShopCoinPackages[this。coinIndex];

if (UserProfile。Gems >= num)

{

UserProfile。Gold += num2;

UserProfile。Gems -= num;

this。purchasedAmount = num2;

this。screenType = UI_ConfirmationPopup。ScreenType。CoinsPurchased;

Events。Instance。UI_MARKET_PURCHASED();

GeneralManager。Analytics。ReportGoldPurchased(this。coinIndex, num);

this。CallItQuits();

}

else

{

this。DisableAssets(true);

this。LaunchOutOfGems();

}

break;

}

Unity遊戲菜雞玩家的制勝之路

Unity遊戲菜雞玩家的制勝之路

途中的兩個分支就是 C# 中的 if-else 。由於高階語言資料結構比較複雜,反應在機器碼層面取資料涉及問題較多,因此修改取資料比較困難。但是可以看到,上面的 block 中 R5 是最後取出的當前剩餘寶石,當與其進行比較之後,如果寶石充足,則會跳轉到紅色分支開始購買,增加金幣扣除寶石。因此應當修改的邏輯是圖中 1 處,透過 nop(mov r0, r0)掉跳轉強制執行購買流程。為了在修改金幣的同時不減少寶石,將2處寶石運算改為 add r0 , r0 , r5 。

確定了修改點之後將上述兩條命令彙編,使用之前的 hook 稍作修改,當執行到 MainButtonClicked 編譯時修改程式機器碼( mono 已經很貼心的 mprotect 過了),完成對遊戲的修改,接下來嘗試購買寶石,哇,奇蹟發生了。

0X04 後記

本文從一個簡單小遊戲的破解出發,介紹了 Unity3D 引擎使用 mono 進行 C# JIT 編譯的思路,並設計了實驗性質的 hook 方案。其實針對這款簡單的小遊戲,更簡單的破解方式還有很多種,牛刀殺雞是為了以後更容易殺牛。因為在實際的環境中,分析遊戲面臨著過反除錯、脫殼、對抗去符號、DLL 解密等多重挑戰。限於篇幅不可能對這些技術一一介紹,感興趣的朋友可以自行百度/谷歌。另外,由於使用了 AOT 機制,文章中介紹的 hook 思路可能會更適用於 iOS ,條件所限沒有嘗試。攻擊是為了更好的防禦,使用的例項,介紹的工具都為了更好的說明技術本身,請不要用違法的目的。

參考文獻

[1]: Mono為何能跨平臺?聊聊CIL(MSIL) - 慕容小匹夫 - 部落格園 “Mono為何能跨平臺?聊聊CIL(MSIL)”

[2]: Welcome to Ecma International “EMCA官網”

[3]: ele7enxxh/Android-Inline-Hook “Android Inline Hook”

[4]: mono/mono “Mono Project on Github”

[5]: [原創]發個Android平臺上的注入程式碼-『Android安全』-看雪安全論壇 “古河大大libinject”

標簽: mono  method  hook  函式  compile