Unity IL2CPP 游戏分析入门
一、目标很多时候App加密本身并不难,难得是他用了一套新玩意,天生自带加密光环。例如PC时代的VB,直接ida的话,汇编代码能把你看懵。
但是要是搞明白了他的玩法,VB Decompiler一上,那妥妥的就是源码。
Unity 和 Flutter 也是如此。
最近迷上了一个小游戏 Dream Blast,今天就拿他解剖吧。
com.rovio.dream
二、步骤
侦测敌情
从apk包里面发现libil2cpp.so,就足以证明是Unity写的游戏了。
在Android下Unity有两种玩法,一种是Mono方式打包,我们可以从包内拿到Assembly-CSharp.dll,如果开发者没有对Assembly-CSharp.dll进行加密处理,那么我们可以很方便地使用ILSpy.exe对其进行反编译。这样看到的就是妥妥的C#源码了。
由于总所周知的原因,这种玩法肯定会被公司开除的。现在工作这么难找,所以大家都采取第二种玩法了,使用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 游戏二进制文件来还原C#写的代码逻辑。
目前只有编译好的windows可执行文件,所以目前只能在win下使用。(本例演示的是Arm32)
1、先把global-metadata.dat 和 libil2cpp.so 这两个文件拷贝到同一个目录。
2、运行 Il2CppDumper-x86.exe,在弹出的文件选择框里面,先选择 libil2cpp.so,然后再选择 global-metadata.dat。
Initializing metadata...Metadata Version: 27Initializing il2cpp file...Applying relocations...WARNING: find JNI_OnLoadERROR: This file may be protected.Il2Cpp Version: 27Searching...Change il2cpp version to: 27.1CodeRegistration : 205f9c8MetadataRegistration : 205ff3cDumping...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 这个脚本导入到ida里面去。
这会我们只关心 dump.cs。
存盘文件
为了 好好 玩一个游戏,除了改内存,还一个重要的方案就是改配置文件甚至改存盘文件了。
遥想当年帝国时代非得搞个200的人口上限,直接hook一下,把200改成2000他不香吗? (电脑拖崩溃了)
细心 分析了一下,这个游戏的存盘文件在
/sdcard/Android/data/com.rovio.dream/files/usesr/XXX-XXX-XXX/prefs.json
改它,改它,可是它加密了
分析
这时候显示出 dump.cs 的用处了,这可是活地图呀。
在里面搜一下 "prefs.json"
// RVA: 0x3979B8 Offset: 0x3979B8 VA: 0x3979B8public 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细嚼慢咽一下。
首先运行 Il2CppDumper-v6\ida_py3.py (低版本的ida请跑ida.py)
然后 在弹出的文件选择框里面 ,选择刚才反编译出来的script.json,最后再跑一次ida_py3.py 把stringliteral.json 也加进来。
万事俱备了,我们去分析一下 UserPrefs_Init() ,地图告诉我们它在 0xAAE690,
ida里面去到 0xAAE690, 然后Create Function, 再F5以下,代码就出来了。
代码看上去还是有点懵,它似乎 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; // r0if ( !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,又应该是哪一个呢?
Rfc2898DeriveBytes
幸亏咱还是懂点C#的,一个优秀的C#程序员,看到AesManaged和Rfc2898DeriveBytes,就知道套路了。
Rfc2898DeriveBytes的入参是一个password和salt,然后生成一组key和iv,后面就是aes做AES-128-CBC了。
目标很明确了,搞到pwd和salt。
ida双击进到 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; // r4int v11; // r5int v12; // r6int v13; // r7int v14; // r6int v15; // r0v13 = 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; // r6if ( !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 大概率是动态载入的,所以刚启动app的时候木有libil2cpp.so。
如果我们要hook的函数之后会被多次调用,那么可以延迟几秒钟来载入 setTimeout(main, 1000*3);
不过这里我们要hook的都是init和ctor之类的初始化函数,几秒钟之后可能都初始化完成了。
hook_constructor
要第一时间hook 动态载入的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.name; if (name.indexOf("call_function") >= 0){ addr_call_function = symbols.address; } else if(name.indexOf("g_ld_debug_verbosity") >=0){ addr_g_ld_debug_verbosity = symbols.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.address; } } } if(addr_async_safe_format_log){ Interceptor.attach(addr_async_safe_format_log,{ onEnter: function(args){ this.log_level= args; this.tag = ptr(args).readCString() this.fmt = ptr(args).readCString() if(this.fmt.indexOf("c-tor") >= 0 && this.fmt.indexOf('Done') < 0){ this.function_type = ptr(args).readCString(), // func_type this.so_path = ptr(args).readCString(); var strs = new Array(); //定义一数组 strs = this.so_path.split("/"); //字符分割 this.so_name = strs.pop(); this.func_offset= ptr(args).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){ } });}
这次的结果就比较完美了
rc.png
Rfc2898DeriveBytes的入参是String,可以看到String在内存中的布局, 0x0C 开始的4个字节是 字符串长度,0x10开始才是真正的字符串。
password 是存档的文件夹名称+EK
salt 是个固定的字符串
带着这个结果我们再回过头去看 UserPrefs__Init的F5的代码,重点关注那几个 System_String_Concat 就更有心得了。
三、总结
为了抵抗Il2CppDumper,敌人变狡猾了,所以作者推出了更帅的 Zygisk-Il2CppDumper
现在套路这么多,技能得不断更新才能跟的上,又要掉头发了。
变来变去的都是外围,万变不离其宗的还是arm汇编,最后的定位还是需要你的汇编功底。
网络游戏改存盘是没用的,一联服务器就把你覆盖了。
ffshow.png
富贵故如此,营营何所求
页:
[1]