revunity


bugku上面的一道unity逆向题,花了一两天学习一下unity 的逆向

背景介绍

  1. Unity 游戏有两种打包方式 mono 与 il2cpp
  2. 在 mono 模式下,游戏 C# 代码被编译为 IL(中间代码) 并生成 dll 文件,然后 dll 被打包进最后的游戏包文件。由于 IL 非常容易被 ILSpy / .NET Reflector 等专业反编译软件分析逆向,所以在无保护情况下,游戏的安全性极差。
  3. 在 il2cpp 模式下,虽然游戏逻辑是以 Native 代码运行, 但依然要实现 C# 某些语言特性(如GC), il2cpp 将所有的 C# 中的类名/属性名/字符串等信息记录在 global-metadata.dat 文件。il2cpp 启动时会从这个文件读取所需要的类名/属性名等信息。
  4. 使用 IL2cppDumper 可以解析 global-metadata.dat 文件,并将文件里的类名等字符串信息对应到 Native 代码中去
  5. Unity使用Mono方式打出来的apk,我们可以直接从包内拿到Assembly-CSharp.dll,如果开发者没有对Assembly-CSharp.dll进行加密处理,那么我们可以很方便地使用ILSpy.exe对其进行反编译。

题目介绍 (baby unity3d)

  • 在bugku上遇到了一个unity apk需要我们来逆向,但是直接用IDA打开so文件,完全摸不着边际

  • 查阅网络上的资料,了解到这个游戏使用了il2cpp的打包方式,需要分析libil2cpp.so和global-metadata.dat文件来获取native中的类名/函数名/变量名等等。

  • 这一方面之前从来没有了解过,正好通过这道题来学习一下如何逆向unity的apk

工具环境

  1. 工欲善其事必先利其器,先搞好工具,下个 Il2cppDumper

安装参考:https://blog.csdn.net/linxinfa/article/details/116572369

image-20220427164106462

image-20220427164141958

  1. 从apk中提取文件

解压apk,从\crackme\assets\bin\Data\Managed\Metadata\中提取global-metadata.dat文件;

image-20220427165128875

从\crackme\lib\armeabi-v7a文件中提取libil2cpp.so文件;

image-20220427165111248

  1. 回到Il2CppDumper.exe所在的目录,创建input目录和output目录。

image-20220427165317242

将上述两个文件放到input文件夹中,并编写il2cpp_decompilation.bat文件

1
..\Il2CppDumper.exe libil2cpp.so global-metadata.dat ..\output

image-20220427165613842

运行bat文件,发现出错了,metadata可能被加密了

  1. 查看global-metadata.dat文件
  • 使用二进制文本查看器打开global-metadata.dat文件

image-20220427165822363

  • 发现文件开头并不是AF 1B B1 FA,说明该文件应该是修改或加密过

解密

  • 想要解密global-metadata.dat我们有两种思路,一种是dump解密结果,另一种是分析加密算法。对于第一种思路,这里有个frida脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function frida_Memory(pattern)
{
Java.perform(function ()
{
console.log("头部标识:" + pattern);
var addrArray = Process.enumerateRanges("r--");
for (var i = 0; i < addrArray.length; i++)
{
var addr = addrArray[i];
Memory.scan(addr.base, addr.size, pattern,
{
onMatch: function (address, size)
{
console.log('搜索到 ' + pattern + " 地址是:" + address.toString());
console.log(hexdump(address,
{
offset: 0,
length: 64,
header: true,
ansi: true
}
));
//0x108,0x10C如果不行,换0x100,0x104
var DefinitionsOffset = parseInt(address, 16) + 0x108;
var DefinitionsOffset_size = Memory.readInt(ptr(DefinitionsOffset));

var DefinitionsCount = parseInt(address, 16) + 0x10C;
var DefinitionsCount_size = Memory.readInt(ptr(DefinitionsCount));

//根据两个偏移得出global-metadata大小
var global_metadata_size = DefinitionsOffset_size + DefinitionsCount_size
console.log("大小:", global_metadata_size);
var file = new File("/data/data/" + get_self_process_name() + "/global-metadata.dat", "wb");
file.write(Memory.readByteArray(address, global_metadata_size));
file.flush();
file.close();
console.log('导出完毕...');
},
onComplete: function ()
{
//console.log("搜索完毕")
}
}
);
}
}
);
}
setImmediate(frida_Memory("AF 1B B1 FA")); //global-metadata.dat头部特征

大概流程就是通过magic来定位到文件在内存中的起始地址,然后通过解析文件头来计算出文件的大小,最后进行dump。

该脚本的适用条件是global-metadata.dat在解密后必须要有正常的magic即AF 1B B1 FA来定位,同时文件头信息要正确否则无法计算文件大小。

这个脚本有一定的参考价值,然而对于这题起不到作用,脚本执行后没有找到起始地址,看来即使解密后,内存中也没有AF 1B B1 FA存在。所以这种通用的dump方式应该是不行了,只能找到global-metadata.dat的加载函数,待其解密完成后再进行dump,所以我们需要对global-metadata.dat的加载流程进行分析。

global-metadata.dat加载流程

考验英文的时候到了,要先学习了解一下如何跟踪分析global-metadata.dat:https://katyscode.wordpress.com/2021/02/23/il2cpp-finding-obfuscated-global-metadata/

简要概括下,在libil2cpp.so里面有个il2cpp_init函数是加载函数调用链中的第一个函数,整个调用链是这样的

1
2
3
4
il2cpp_init
-> il2cpp::vm::Runtime::Init
-> il2cpp::vm::MetadataCache::Initialize
-> il2cpp::vm::MetadataLoader::LoadMetadataFile

对比IDA中的函数

  • il2cpp_init

image-20220427210617395

  • il2cpp::vm::Runtime::Init

sub_4C4770

image-20220427210637275

  • il2cpp::vm::MetadataCache::Initialize (通过查看特殊的字符发现的,因为“global-metadata.dat”被加密了)

sub_4B5564

image-20220427210839581

解密“global-metadata.dat”函数

image-20220427210901754

运行一下

image-20220427210940132

确实是“global-metadata.dat”

可以看到与“global-metadata.dat”(v0)密切相关的函数是sub_513060,而后面v0就被free掉了,那么

dword_6959CC就应当对于global-metadata.dat的数据,故而要看sub_513060的返回值

sub_513060 (return v3)

image-20220427211232124

根据调用关系,解密dat的函数为sub_512FDC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
char *__fastcall sub_512FDC(int a1, size_t size)
{
char *result; // r0
size_t v5; // r2

result = (char *)malloc(size);
if ( size )
{
v5 = 0;
do
{
*(_DWORD *)&result[v5 & 0xFFFFFFFC] = *(_DWORD *)(a1 + (v5 & 0xFFFFFFFC)) ^ dword_5DCF6C[(v5 + v5 / 0x84) % 0x84];
v5 += 4;
}
while ( v5 < size );
}
return result;
}

解密脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import struct
key=[0xf83da249,0x15d12772,0x40c50697,0x984e2b6b,0x14ec5ff8,0xb2e24927,0x3b8f77ae,0x472474cd,0x5b0ce524,0xa17e1a31,0x6c60852c,0xd86ad267,0x832612b7,0x1ca03645,0x5515abc8,0xc5feff52,0xffffac00,0xfe95cb6,0x79cf43dd,0xaa48a3fb,0xe1d71788,0x97663d3a,0xf5cffea7,0xee617632,0x4b11a7ee,0x40ef0b5,0x606fc00,0xc1530fae,0x7a827441,0xfce91d44,0x8c4cc1b1,0x7294c28d,0x8d976162,0x8315435a,0x3917a408,0xaf7f1327,0xd4bfaed7,0x80d0abfc,0x63923dc3,0xb0e6b35a,0xb815088f,0x9bacf123,0xe32411c3,0xa026100b,0xbcf2ff58,0x641c5cfc,0xc4a2d7dc,0x99e05dca,0x9dc699f7,0xb76a8621,0x8e40e03c,0x28f3c2d4,0x40f91223,0x67a952e0,0x505f3621,0xbaf13d33,0xa75b61cc,0xab6aef54,0xc4dfb60d,0xd29d873a,0x57a77146,0x393f86b8,0x2a734a54,0x31a56af6,0xc5d9160,0xaf83a19a,0x7fc9b41f,0xd079ef47,0xe3295281,0x5602e3e5,0xab915e69,0x225a1992,0xa387f6b2,0x7e981613,0xfc6cf59a,0xd34a7378,0xb608b7d6,0xa9eb93d9,0x26ddb218,0x65f33f5f,0xf9314442,0x5d5c0599,0xea72e774,0x1605a502,0xec6cbc9f,0x7f8a1bd1,0x4dd8cf07,0x2e6d79e0,0x6990418f,0xcf77bad9,0xd4fe0147,0xfef4a3e8,0x85c45bde,0xb58f8e67,0xa63eb8d7,0xc69bd19b,0xda442dca,0x3c0c1743,0xe6f39d49,0x33568804,0x85eb6320,0xda223445,0x36c4a941,0xa9185589,0x71b22d67,0xf59a2647,0x3c8b583e,0xd7717ded,0xdf05699c,0x4378367d,0x1c459339,0x85133b7f,0x49800ce2,0x3666ca0d,0xaf7ab504,0x4ff5b8f1,0xc23772e3,0x3544f31e,0xf673a57,0xf40600e1,0x7e967417,0x15a26203,0x5f2e34ce,0x70c7921a,0xd1c190df,0x5bb5da6b,0x60979c75,0x4ea758a4,0x78fe359,0x1664639c,0xae14e73b,0x2070ff03]

f=open("global-metadata.dat","rb")
a=f.read()
with open("decrypt.dat","wb+") as fp:
i=0
length=len(a)
while(i<length):
num = struct.unpack("<I", a[i:i + 4])[0] # <:小端法 I:unsigned int
num = num ^ key[(i + i // 0x84) % 0x84]
d = struct.pack('I', num)
fp.write(d)
i = i + 4

解密完成后发现还是不能用Il2cppDumper,将解密后的文件放到010editor里发现魔数不对,改成AF 1B B1 FA就行了,原来他把魔数校验的那一步给去掉了,所以可以改魔数,这样就可以防止用前面提到的通用frida脚本来dump了。

image-20220427214423143

感动,迈出了一大步,获得了output内容

image-20220427214502610

dump.cs

:这个文件会把C#dll代码的类、方法、字段列出来

在其中,搜索checkFlag函数

image-20220427214707195

il2cpp.h

生成的cpp的头文件,从头文件里我们也可以看到相关的数据结构

image-20220427214829918

script.json

显示类的方法信息

image-20220427214925787

1
2
3
4
5
6
{
"Address": 5343780,
"Name": "Check$$CheckFlag",
"Signature": "bool Check__CheckFlag (Check_o* __this, System_String_o* input, const MethodInfo* method);",
"TypeSignature": "iiii"
}

寻找check函数

上一步知道了checkflag函数地址为0x518a24,在IDA中跳到该地址

image-20220427215417344

利用ida.py还原script.json中的数据

image-20220427221404308

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int __fastcall Check__CheckFlag(int a1, int a2)
{
int v3; // r0
int v4; // r4

if ( !byte_69C825 )
{
sub_4B82BC(1279);
byte_69C825 = 1;
}
v3 = Check_TypeInfo;
if ( (*(_BYTE *)(Check_TypeInfo + 178) & 1) != 0 && !*(_DWORD *)(Check_TypeInfo + 96) )
{
il2cpp_runtime_class_init_0(Check_TypeInfo);
v3 = Check_TypeInfo;
}
v4 = Check__AESEncrypt(
*(_DWORD *)(v3 + 80),
a2,
*(_DWORD *)(*(_DWORD *)(v3 + 80) + 4000),
*(_DWORD *)(*(_DWORD *)(v3 + 80) + 2364));
if ( (*(_BYTE *)(string_TypeInfo + 178) & 1) != 0 && !*(_DWORD *)(string_TypeInfo + 96) )
il2cpp_runtime_class_init_0(string_TypeInfo);
return System_String__op_Equality(0, v4, StringLiteral_2814);
}

o(╥﹏╥)o,终于可以看了

愉快地取出关键字符

image-20220427221631795

StringLiteral_2814: w0ZyUZAHhn16/MRWie63lK+PuVpZObu/NpQ/E/ucplc=

IV和key暂时获取不到,需要用frida来hook出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Java.perform(function(){
var soAdrr = Module.findBaseAddress("libil2cpp.so");

var ptrAESEncrypt = soAdrr.add(0x518b54);

Interceptor.attach(ptrAESEncrypt,{
onEnter: function(args){
console.log(("enter ptrAESEncrypt args[0]-> " + args[0]));
console.log(("enter ptrAESEncrypt args[1] text->\n" + hexdump(args[1])));
console.log(("enter ptrAESEncrypt args[2]-> password\n" + hexdump(args[2],{
offset: 12,
length: 12+16 * 2
})));
console.log(("enter ptrAESEncrypt args[3]-> iv\n" + hexdump(args[3],{
offset: 12,
length: 12+16 * 2
})));
},
onLeave: function(args){
}
})
})

frida hook

复习一下frida知识:https://www.jianshu.com/p/fa422d3b7148

血泪经验:frida windows版本一定要和sever版本相同,麻了,折腾一天了总算成功了

1
2
3
adb forward tcp:27043 tcp:27043
adb forward tcp:27042 tcp:27042
frida-ps -U

image-20220428190842977

frida脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Java.perform(function(){
var soAdrr = Module.findBaseAddress("libil2cpp.so");

var ptrAESEncrypt = soAdrr.add(0x518b54);

Interceptor.attach(ptrAESEncrypt,{
onEnter: function(args){
console.log(("enter ptrAESEncrypt args[0]-> " + args[0]));
console.log(("enter ptrAESEncrypt args[1] text->\n" + hexdump(args[1])));
console.log(("enter ptrAESEncrypt args[2]-> password\n" + hexdump(args[2],{
offset: 12,
length: 12+16 * 2
})));
console.log(("enter ptrAESEncrypt args[3]-> iv\n" + hexdump(args[3],{
offset: 12,
length: 12+16 * 2
})));
},
onLeave: function(args){
}
})
})

hook这里折腾了一天,模拟器是真的不行!!!!

最后用jr的真机实现的,哭了—

image-20220428190810897

pawd: 91c775fa0f6a1cba

iv:58f3a445939aeb79

在线解密一下

image-20220428192444555

不容易,获得flag:N1CTF{h4ppy_W1TH_1l2cpp}

补个解密的脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from Crypto.Cipher import AES
import base64
# Encryption
key=b"91c775fa0f6a1cba"
iv=b"58f3a445939aeb79"

encrypt_data="w0ZyUZAHhn16/MRWie63lK+PuVpZObu/NpQ/E/ucplc="
cipher_text=base64.b64decode(encrypt_data)
# Decryption
decryption_suite = AES.new(key, AES.MODE_CBC, iv)
plain_text = decryption_suite.decrypt(cipher_text)

print(plain_text)
# N1CTF{h4ppy_W1TH_1l2cpp}
参考资料

https://blog.csdn.net/qq_39268483/article/details/115833605

https://www.ashenone66.cn/2022/04/22/il2cpp-ni-xiang-global-metadata-jie-mi/


文章作者: Smile Song
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Smile Song !
 上一篇
angstromctf rev angstromctf rev
五一期间参加的angstromctf,大多数题目还是挺有意思,挺典型的。特别是最后一道weebhunter2花了2天时间才做出来,但是感觉很值得,学到了不少东西。
2022-05-05 Smile Song
本篇 
revunity revunity
bugku上面的一道unity逆向题,花了一两天学习一下unity 的逆向
2022-04-29 Smile Song
  目录