一、目標(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:
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é)果就比較完美了
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ù)器就把你覆蓋了。
富貴故如此,營營何所求