Unity IL2CPP 游戲分析入門

一、目標(biāo)

很多時候App加密本身并不難,難得是他用了一套新玩意,天生自帶加密光環(huán)。例如PC時代的VB,直接ida的話,匯編代碼能把你看懵。

但是要是搞明白了他的玩法,VB Decompiler一上,那妥妥的就是源碼。

Unity 和 Flutter 也是如此。

最近迷上了一個小游戲 Dream Blast,今天就拿他解剖吧。

com.rovio.dream

二、步驟

偵測敵情

從apk包里面發(fā)現(xiàn)libil2cpp.so,就足以證明是Unity寫的游戲了。

在Android下Unity有兩種玩法,一種是Mono方式打包,我們可以從包內(nèi)拿到Assembly-CSharp.dll,如果開發(fā)者沒有對Assembly-CSharp.dll進(jìn)行加密處理,那么我們可以很方便地使用ILSpy.exe對其進(jìn)行反編譯。這樣看到的就是妥妥的C#源碼了。

由于總所周知的原因,這種玩法肯定會被公司開除的。現(xiàn)在工作這么難找,所以大家都采取第二種玩法了,使用IL2CPP方式打包,就沒有Assembly-CSharp.dll。這樣就不會讓人輕易攻破了。

這時候就需要召喚出IL2CPP界的Decompiler了。

Il2CppDumper

https://github.com/Perfare/Il2CppDumper

Il2CppDumper 通過 assets/bin/Data/Managed/Metadata/global-metadata.dat 字符串文件 和 lib/armeabi-v7a/libil2cpp.so 游戲二進(jìn)制文件來還原C#寫的代碼邏輯。

目前只有編譯好的windows可執(zhí)行文件,所以目前只能在win下使用。(本例演示的是Arm32)

1、先把global-metadata.dat 和 libil2cpp.so 這兩個文件拷貝到同一個目錄。

2、運(yùn)行 Il2CppDumper-x86.exe,在彈出的文件選擇框里面,先選擇 libil2cpp.so,然后再選擇 global-metadata.dat。

Initializing metadata...
Metadata Version: 27
Initializing il2cpp file...
Applying relocations...
WARNING: find JNI_OnLoad
ERROR: This file may be protected.
Il2Cpp Version: 27
Searching...
Change il2cpp version to: 27.1
CodeRegistration : 205f9c8
MetadataRegistration : 205ff3c
Dumping...
Done!
Generate struct...
Done!
Generate dummy dll...
Done!
Press any key to exit...

這就算反編譯成功了。

一共會生成 DummyDll 目錄, script.json,stringliteral.json,dump.cs,il2cpp.h 等文件。

script.json和stringliteral.json是輔助ida 和ghidra 分析的,可以用 ida.py 這個腳本導(dǎo)入到ida里面去。

這會我們只關(guān)心 dump.cs。

存盤文件

為了 好好 玩一個游戲,除了改內(nèi)存,還一個重要的方案就是改配置文件甚至改存盤文件了。

遙想當(dāng)年帝國時代非得搞個200的人口上限,直接hook一下,把200改成2000他不香嗎? (電腦拖崩潰了)

細(xì)心 分析了一下,這個游戲的存盤文件在

/sdcard/Android/data/com.rovio.dream/files/usesr/XXX-XXX-XXX/prefs.json

改它,改它,可是它加密了

分析

這時候顯示出 dump.cs 的用處了,這可是活地圖呀。

在里面搜一下 "prefs.json"

[CreateAssetMenuAttribute] // RVA: 0x3979B8 Offset: 0x3979B8 VA: 0x3979B8
public class UserPrefs : UserPrefsBase, IInitializable, IInitializableInit // TypeDefIndex: 7278
{
        // Fields
        private const string EK = "8CSstq6cz1Gp9YSQpr2l";
        private const string PrefsFileName = "prefs.json";
   ....
           // RVA: 0xAAE690 Offset: 0xAAE690 VA: 0xAAE690 Slot: 42
        public void Init() { }
    ....

從這里得到兩個有用的信息,一個是存盤文件在UserPrefs類里面處理,再一個EK可能就是密鑰或者密鑰的一部分。

可以上ida了,打開libil2cpp.so細(xì)嚼慢咽一下。

首先運(yùn)行 Il2CppDumper-v6\ida_py3.py (低版本的ida請跑ida.py)

然后 在彈出的文件選擇框里面 ,選擇剛才反編譯出來的script.json,最后再跑一次ida_py3.py 把stringliteral.json 也加進(jìn)來。

萬事俱備了,我們?nèi)シ治鲆幌?UserPrefs_Init() ,地圖告訴我們它在 0xAAE690,

ida里面去到 0xAAE690, 然后Create Function, 再F5以下,代碼就出來了。

代碼看上去還是有點(diǎn)懵,它似乎 System_Guid__NewGuid(v47, 0); 生成了個guid,然后再加上了EK

v43 = System_String__Concat_23810904(*(_DWORD *)(a1 + 28), StringLiteral_1313, 0);

StringLiteral_1313就是 EK。

不過好消息是 最后 它要初始化一個 CryptoUtility___ctor

int __fastcall CryptoUtility___ctor(int a1)
{
  int v2; // r6
  _DWORD *UTF8; // r0

  if ( !byte_2173DF8 )
  {
    sub_48CE2C(&System_Security_Cryptography_AesManaged_TypeInfo);
    sub_48CE2C(&System_Security_Cryptography_Rfc2898DeriveBytes_TypeInfo);
    sub_48CE2C(&StringLiteral_1149);
    byte_2173DF8 = 1;
  }
  v2 = sub_48CF00(System_Security_Cryptography_AesManaged_TypeInfo);
  System_Security_Cryptography_AesManaged___ctor(v2, 0);
  *(_DWORD *)(a1 + 16) = v2;
  System_Object___ctor(a1, 0);
  UTF8 = (_DWORD *)System_Text_Encoding__get_UTF8(0);
  if ( !UTF8 )
    sub_48CF08();
  return sub_9DB34C(*UTF8, &StringLiteral_1149, *(_DWORD *)(*UTF8 + 344), *(_DWORD *)(*UTF8 + 340));
}

很明顯,算法是 AES, 那么key是啥呢? aes還有cbc和ecb,又應(yīng)該是哪一個呢?

Rfc2898DeriveBytes

幸虧咱還是懂點(diǎn)C#的,一個優(yōu)秀的C#程序員,看到AesManaged和Rfc2898DeriveBytes,就知道套路了。

Rfc2898DeriveBytes的入?yún)⑹且粋€password和salt,然后生成一組key和iv,后面就是aes做AES-128-CBC了。

目標(biāo)很明確了,搞到pwd和salt。

ida雙擊進(jìn)到 sub_9DB34C

void __fastcall sub_9DB34C(
        int a1,
        _DWORD *a2,
        int a3,
        int (__fastcall *a4)(int, _DWORD),
        int a5,
        int a6,
        int a7,
        int a8,
        int a9,
        int a10)
{
  int v10; // r4
  int v11; // r5
  int v12; // r6
  int v13; // r7
  int v14; // r6
  int v15; // r0

  v13 = a4(v12, *a2);
  v14 = sub_48CF00(System_Security_Cryptography_Rfc2898DeriveBytes_TypeInfo);
  v15 = System_Security_Cryptography_Rfc2898DeriveBytes___ctor(v14, v11, v13, 0);
  if ( !v14 )
    sub_48CF08(v15);
  ...

真相只有一個,hook 這個 System_Security_Cryptography_Rfc2898DeriveBytes___ctor 就可以拿到 pwd和salt了。 a2是pwd,a3是 salt。

Tip:

https://github.com/microsoft/referencesource/blob/master/mscorlib/system/security/cryptography/rfc2898derivebytes.cs

int __fastcall System_Security_Cryptography_Rfc2898DeriveBytes___ctor_17396484(int a1, int a2, int a3, int a4)
{
  int v8; // r6

  if ( !byte_2176D99 )
  {
    sub_48CE2C((int)&System_Security_Cryptography_HMACSHA1_TypeInfo);
    byte_2176D99 = 1;
  }
  System_Security_Cryptography_DeriveBytes___ctor(a1, 0);
  System_Security_Cryptography_Rfc2898DeriveBytes__set_Salt(a1, a3);
  System_Security_Cryptography_Rfc2898DeriveBytes__set_IterationCount(a1, a4);
  *(_DWORD *)(a1 + 20) = a2;
  v8 = sub_48CF00(System_Security_Cryptography_HMACSHA1_TypeInfo);
  System_Security_Cryptography_HMACSHA1___ctor_22256684(v8, a2, 0);
  *(_DWORD *)(a1 + 16) = v8;
  return System_Security_Cryptography_Rfc2898DeriveBytes__Initialize(a1);
}

說干就干

var libxx = Process.getModuleByName("libil2cpp.so");
console.log("*****************************************************");
console.log("name: " +libxx.name);
console.log("base: " +libxx.base);
console.log("size: " +ptr(libxx.size));

Interceptor.attach(ptr(libxx.base).add(0x1097304),{
    onEnter: function(args){
        console.log("=== pwd");
        console.log(TAG + hexdump(ptr(this.context.r1), { offset: 0, length: 128, header: true, ansi: true }) );

        console.log("=== salt ");
        console.log(TAG + hexdump(ptr(this.context.r2), { offset: 0, length: 64, header: true, ansi: true }) );


    },
    onLeave:function(retval){
    }
});

這就尷尬了

Error: unable to find module 'libil2cpp.so'

libil2cpp.so 大概率是動態(tài)載入的,所以剛啟動app的時候木有l(wèi)ibil2cpp.so。

如果我們要hook的函數(shù)之后會被多次調(diào)用,那么可以延遲幾秒鐘來載入 setTimeout(main, 1000*3);

不過這里我們要hook的都是init和ctor之類的初始化函數(shù),幾秒鐘之后可能都初始化完成了。

hook_constructor

要第一時間hook 動態(tài)載入的so,就需要從so的加載開始搞

function hook_constructor0() {
    if (Process.pointerSize == 4) {
        var linker = Process.findModuleByName("linker");
    } else {
        var linker = Process.findModuleByName("linker64");
    }

    var addr_call_function =null;
    var addr_g_ld_debug_verbosity = null;
    var addr_async_safe_format_log = null;
    if (linker) {
        var symbols = linker.enumerateSymbols();
        for (var i = 0; i < symbols.length; i++) {
            var name = symbols[i].name;
            if (name.indexOf("call_function") >= 0){
                addr_call_function = symbols[i].address;
            }
            else if(name.indexOf("g_ld_debug_verbosity") >=0){
                addr_g_ld_debug_verbosity = symbols[i].address;

                ptr(addr_g_ld_debug_verbosity).writeInt(2);

            } else if(name.indexOf("async_safe_format_log") >=0 && name.indexOf('va_list') < 0){

                addr_async_safe_format_log = symbols[i].address;

            }

        }
    }
    if(addr_async_safe_format_log){
        Interceptor.attach(addr_async_safe_format_log,{
            onEnter: function(args){
                this.log_level  = args[0];
                this.tag = ptr(args[1]).readCString()
                this.fmt = ptr(args[2]).readCString()
                if(this.fmt.indexOf("c-tor") >= 0 && this.fmt.indexOf('Done') < 0){
                    this.function_type = ptr(args[3]).readCString(), // func_type
                    this.so_path = ptr(args[5]).readCString();
                    var strs = new Array(); //定義一數(shù)組
                    strs = this.so_path.split("/"); //字符分割
                    this.so_name = strs.pop();
                    this.func_offset  = ptr(args[4]).sub(Module.findBaseAddress(this.so_name))


                    if(this.so_name == "libil2cpp.so") {

                var targetSo = Module.findBaseAddress(this.so_name);

                console.log(TAG +' so_name:',this.so_name);
                console.log(TAG +' ptr:',ptr(targetSo));

                hookDbg(targetSo);
                    }

                }
            },
            onLeave: function(retval){

            }
        })
    }
}

function hookDbg(targetSo){
    Interceptor.attach(targetSo.add(0xAAE690),{
        onEnter: function(args){
            console.log(" UserPrefs_ctor *****************************************************");

        },
        onLeave:function(retval){
        }
    });


    Interceptor.attach(ptr(targetSo).add(0x1097304),{
        onEnter: function(args){
            console.log("=== pwd");
            console.log(TAG + hexdump(ptr(this.context.r1), { offset: 0, length: 128, header: true, ansi: true }) );

            console.log("=== salt ");
            console.log(TAG + hexdump(ptr(this.context.r2), { offset: 0, length: 64, header: true, ansi: true }) );
        },
        onLeave:function(retval){
        }
    });

}

這次的結(jié)果就比較完美了

rc.png

Rfc2898DeriveBytes的入?yún)⑹荢tring,可以看到String在內(nèi)存中的布局, 0x0C 開始的4個字節(jié)是 字符串長度,0x10開始才是真正的字符串。

password 是存檔的文件夾名稱+EK

salt 是個固定的字符串

帶著這個結(jié)果我們再回過頭去看 UserPrefs__Init的F5的代碼,重點(diǎn)關(guān)注那幾個 System_String_Concat 就更有心得了。

三、總結(jié)

為了抵抗Il2CppDumper,敵人變狡猾了,所以作者推出了更帥的 Zygisk-Il2CppDumper

現(xiàn)在套路這么多,技能得不斷更新才能跟的上,又要掉頭發(fā)了。

變來變?nèi)サ亩际峭鈬f變不離其宗的還是arm匯編,最后的定位還是需要你的匯編功底。

網(wǎng)絡(luò)游戲改存盤是沒用的,一聯(lián)服務(wù)器就把你覆蓋了。

ffshow.png

富貴故如此,營營何所求

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,505評論 6 533
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,556評論 3 418
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,463評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,009評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,778評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,218評論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,281評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,436評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,969評論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,795評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,993評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,537評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,229評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,659評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,917評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,687評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,990評論 2 374

推薦閱讀更多精彩內(nèi)容