IL2CPP 逆向

前言

在许久之前通常unity常用Mono虚拟机来进行打包,但是因为其打包方式极其容易被逆向因此市面上邮寄基本上使用il2cpp来进行打包操作。

对于il2cpp是将游戏C#代码转换为C++代码然后在编译为各平台Native代码

虽然游戏逻辑以Native代码进行运行,但是其仍然需要实现C#的一些语言特性,于是il2cpp将所有的C#中的类名、方法名、属性名、字符串等地址信息进行记录在global-metadata.dat文件中。在il2cpp启动时便会从此文件来进行读取需要的类名、方法名、属性名、字符串等信息。

如今互联网上对于il2cpp逆向的教程也是比较多的,但基本上都指向了一个il2cppDumper

Perfare/Il2CppDumper: Unity il2cpp reverse engineer (github.com)

通过其将游戏中的libil2cpp.so(或是UnityPlayer.dll)以及global-metadata.dat进行提取后使用上述项目进行dump处理。事实上因为这个项目过于的出名,许多游戏厂商也对此采取了对应的对抗措施(如对上面两个文件进行加密等)。

前置知识

通常来说global-metadata.dat的魔术字应为AF 1B B1 FA,我们通过010可以发现其明显的被修改过,则我们需要找到对应解密global-metadata.dat的方法来进行dump处理

运行 && Dump 解密

根据大A师傅的文章,我们一般有两种思路,一种是运行起来后获取dump解密结果,另一种是分析对应的加密算法,以下提供大A师傅的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头部特征

大概流程就是通过魔术来定位到文件在内存中的起始地址,然后通过解析文件头来计算出文件的大小,最后进行dump。该脚本的适用条件是global-metadata.dat在解密后必须要有正常的魔术即AF 1B B1 FA否则定位,同时文件头信息要正确否则无法计算文件大小。

global-metadata.dat 加载流程

其加载的调用链如下:

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

而在我们逆向中,这些都是不带符号的,然而我们可以对着源码来找到相对应的函数(不同版本的源码有一些差别)

il2cpp_init (located in il2cpp-api.cpp, comments elided):

1
2
3
4
5
int il2cpp_init(const char* domain_name)
{
setlocale(LC_ALL, "");
return Runtime::Init(domain_name, "v4.0.30319");
}

il2cpp::vm::Runtime::Init (located in vm/Runtime.cpp):

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
bool Runtime::Init(const char* filename, const char *runtime_version)
{
SanityChecks();

os::Initialize();
os::Locale::Initialize();
MetadataAllocInitialize();

s_FrameworkVersion = framework_version_for(runtime_version);

os::Image::Initialize();
os::Thread::Init();
il2cpp::utils::RegisterRuntimeInitializeAndCleanup::ExecuteInitializations();

if (!MetadataCache::Initialize())
return false;
Assembly::Initialize();
gc::GarbageCollector::Initialize();

Thread::Initialize();
Reflection::Initialize();

register_allocator(il2cpp::utils::Memory::Malloc);

memset(&il2cpp_defaults, 0, sizeof(Il2CppDefaults));

const Il2CppAssembly* assembly = Assembly::Load("mscorlib.dll");

il2cpp_defaults.corlib = Assembly::GetImage(assembly);
DEFAULTS_INIT(object_class, "System", "Object");
DEFAULTS_INIT(void_class, "System", "Void");
DEFAULTS_INIT_TYPE(boolean_class, "System", "Boolean", bool);
DEFAULTS_INIT_TYPE(byte_class, "System", "Byte", uint8_t);
DEFAULTS_INIT_TYPE(sbyte_class, "System", "SByte", int8_t);
DEFAULTS_INIT_TYPE(int16_class, "System", "Int16", int16_t);
DEFAULTS_INIT_TYPE(uint16_class, "System", "UInt16", uint16_t);
DEFAULTS_INIT_TYPE(int32_class, "System", "Int32", int32_t);
DEFAULTS_INIT_TYPE(uint32_class, "System", "UInt32", uint32_t);
DEFAULTS_INIT(uint_class, "System", "UIntPtr");
DEFAULTS_INIT_TYPE(int_class, "System", "IntPtr", intptr_t);
DEFAULTS_INIT_TYPE(int64_class, "System", "Int64", int64_t);
DEFAULTS_INIT_TYPE(uint64_class, "System", "UInt64", uint64_t);
DEFAULTS_INIT_TYPE(single_class, "System", "Single", float);
DEFAULTS_INIT_TYPE(double_class, "System", "Double", double);
DEFAULTS_INIT_TYPE(char_class, "System", "Char", Il2CppChar);
DEFAULTS_INIT(string_class, "System", "String");
// ...

il2cpp::vm::MetadataCache::Initialize (located in vm/MetadataCache.cpp, comments elided):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool il2cpp::vm::MetadataCache::Initialize()
{
s_GlobalMetadata = vm::MetadataLoader::LoadMetadataFile("global-metadata.dat");
if (!s_GlobalMetadata)
return false;

s_GlobalMetadataHeader = (const Il2CppGlobalMetadataHeader*)s_GlobalMetadata;
IL2CPP_ASSERT(s_GlobalMetadataHeader->sanity == 0xFAB11BAF);
IL2CPP_ASSERT(s_GlobalMetadataHeader->version == 24);

s_TypeInfoTable = (Il2CppClass**)IL2CPP_CALLOC(s_Il2CppMetadataRegistration->typesCount, sizeof(Il2CppClass*));
s_TypeInfoDefinitionTable = (Il2CppClass**)IL2CPP_CALLOC(s_GlobalMetadataHeader->typeDefinitionsCount / sizeof(Il2CppTypeDefinition), sizeof(Il2CppClass*));
s_MethodInfoDefinitionTable = (const MethodInfo**)IL2CPP_CALLOC(s_GlobalMetadataHeader->methodsCount / sizeof(Il2CppMethodDefinition), sizeof(MethodInfo*));
s_GenericMethodTable = (const Il2CppGenericMethod**)IL2CPP_CALLOC(s_Il2CppMetadataRegistration->methodSpecsCount, sizeof(Il2CppGenericMethod*));
s_ImagesCount = s_GlobalMetadataHeader->imagesCount / sizeof(Il2CppImageDefinition);
s_ImagesTable = (Il2CppImage*)IL2CPP_CALLOC(s_ImagesCount, sizeof(Il2CppImage));
s_AssembliesCount = s_GlobalMetadataHeader->assembliesCount / sizeof(Il2CppAssemblyDefinition);
s_AssembliesTable = (Il2CppAssembly*)IL2CPP_CALLOC(s_AssembliesCount, sizeof(Il2CppAssembly));
// ...

il2cpp::vm::MetadataLoader::LoadMetadataFile (located in vm/MetadataLoader.cpp):

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
void* il2cpp::vm::MetadataLoader::LoadMetadataFile(const char* fileName)
{
std::string resourcesDirectory = utils::PathUtils::Combine(utils::Runtime::GetDataDir(), utils::StringView<char>("Metadata"));

std::string resourceFilePath = utils::PathUtils::Combine(resourcesDirectory, utils::StringView<char>(fileName, strlen(fileName)));

int error = 0;
os::FileHandle* handle = os::File::Open(resourceFilePath, kFileModeOpen, kFileAccessRead, kFileShareRead, kFileOptionsNone, &error);
if (error != 0)
{
utils::Logging::Write("ERROR: Could not open %s", resourceFilePath.c_str());
return NULL;
}

void* fileBuffer = utils::MemoryMappedFile::Map(handle);

os::File::Close(handle, &error);
if (error != 0)
{
utils::MemoryMappedFile::Unmap(fileBuffer);
fileBuffer = NULL;
return NULL;
}

return fileBuffer;
}

上述的完整代码我们可以在Unity的安装目录的Editor中找到il2cpp的源码:vm目录

在里面的GlobalMetadata.cpp中,就可以看到加载global-metadata.dat文件的逻辑。

如果开发者对global-metadata.dat文件做了加密,那么在GlobalMetadata.cpp中加载global-metadata.dat前需要实现对应的解密逻辑。

相应的我们有了这些源码,我们只需要在 IDA 中对照他们识别出相对应的函数,而关键是后两个函数,分别是

il2cpp::vm::MetadataLoader::LoadMetadataFile 该函数将 metadata 的文件名文件映射入内存

il2cpp::vm::MetadataCache::Initialize 该函数将映射文件的指针存储在全局变量中,然后开始从这变量读取数据结构

而我们解混淆或是解密代码一般都在这两个函数里,只需要样本代码与源码对比差别即可。

以一个IDA中的例子进行说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
char il2cpp::vm::MetadataCache::Initialize()
{
v0 = sub_180261550("global-metadata.dat");
*&xmmword_182B7C2D8 = v0;
if ( v0 )
{
*(&xmmword_182B7C2D8 + 1) = v0;
qword_182B7B948 = j_j__calloc_base(*(qword_182B7C2C0 + 48), 8i64);
qword_182B7B950 = j_j__calloc_base(*(*(&xmmword_182B7C2D8 + 1) + 164i64) / 0x5Cui64, 8i64);
qword_182B7B958 = j_j__calloc_base(*(*(&xmmword_182B7C2D8 + 1) + 52i64) >> 5, 8i64);
qword_182B7B968 = j_j__calloc_base(*(qword_182B7C2C0 + 64), 8i64);
dword_182B7B970 = *(*(&xmmword_182B7C2D8 + 1) + 172i64) / 0x28ui64;
qword_182B7B978 = j_j__calloc_base(dword_182B7B970, 80i64);
dword_182B7B980 = *(*(&xmmword_182B7C2D8 + 1) + 180i64) / 0x44ui64;
qword_182B7B988 = j_j__calloc_base(dword_182B7B980, 96i64);
v1 = *(&xmmword_182B7C2D8 + 1);
...

我们通过对比源码可以发现sub_180261550就是 il2cpp::vm::MetadataLoader::LoadMetadataFromFile

与此同时我们需要注意的是这个 v0 指针,因为解混淆或是解密都会调用到这个指针(一般是在加载前解密,而不是在加载后解密,这样会导致未解密的数据残余在内存中影响性能),通常来说应用程序在访问 metadata 前执行解密,或是在 il2cpp::vm::MetadataLoader::LoadMetadataFromFile之前或之中。

il2cpp_init

那么以上套路是针对我们能在il2cpp里找到il2cpp_init的函数,但是如果没有,我们就要继续往上找UnityPlayer.dll或者libunity.so,而这个我们没有办法用源码对照,那么我们需要创建创建一个 Unity 项目生成 PDB,来进行获取函数符号以及相关信息。

通常我们找不到对应il2cpp_init时存在以下几种可能性

  • 导出名称经过模糊处理/加密
  • Unity 调用其他导出来执行初始化
  • init 函数的 RVA(相对虚拟地址)在Unity中硬编码
  • Unity 调用从应用程序中检索得到的导出函数地址
  • 当操作系统加载文件来提供函数地址时,应用程序在其加载挂钩中调用Unity上的导出函数

相对于此我们更加关系其对应的调用链,在为混淆下有下图:

UnityMainImpl 有很多函数调用,不过我们可以通过字符串来寻找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  winutils::DisplayErrorMessagesAndQuit("Data folder not found");
}
DetectIL2CPPVersion();
v78.m_data = 0i64;
v78.m_size = 0i64;
v78.m_label.identifier = 68;
v78.m_internal[0] = 0;
core::StringStorageDefault<char>::assign(&v78, "GameAssembly.dll", 0x10ui64);
v27 = !LoadIl2Cpp(&v78);
if ( v78.m_data && v78.m_capacity > 0 )
operator delete(v78.m_data, v78.m_label);
if ( v27 )
winutils::DisplayErrorMessagesAndQuit("Failed to load il2cpp");
v78.m_data = 0i64;
v78.m_size = 0i64;
v78.m_label.identifier = 68;
v78.m_internal[0] = 0;
core::StringStorageDefault<char>::assign(&v78, "il2cpp_data", 0xBui64);

LoadIl2Cpp 也同样可以字符串快速寻找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
v2 = 1;
il2cpp_init = LookupSymbol(v1, "il2cpp_init", kSymbolRequired);
if ( !il2cpp_init )
{
v2 = 0;
printf_console("il2cpp: function il2cpp_init not found\n");
}
il2cpp_init_utf16 = LookupSymbol(gIl2CppModule, "il2cpp_init_utf16", kSymbolRequired);
if ( !il2cpp_init_utf16 )
{
v2 = 0;
printf_console("il2cpp: function il2cpp_init_utf16 not found\n");
}
il2cpp_shutdown = LookupSymbol(gIl2CppModule, "il2cpp_shutdown", kSymbolRequired);
if ( !il2cpp_shutdown )
{
v2 = 0;
printf_console("il2cpp: function il2cpp_shutdown not found\n");
}

InitializeIl2CppFromMain

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
char __fastcall InitializeIl2CppFromMain(const core::basic_string<char,core::StringStorageDefault<char> > *monoConfigPath, const core::basic_string<char,core::StringStorageDefault<char> > *dataPath, int argc, const char **argv)
{
v4 = argv;
v5 = argc;
v6 = dataPath;
v7 = monoConfigPath;
RegisterAllInternalCalls();
il2cpp_runtime_unhandled_exception_policy_set(IL2CPP_UNHANDLED_POLICY_LEGACY);
il2cpp_set_commandline_arguments(v5, v4, 0i64);
v8 = v7->m_data;
if ( !v7->m_data )
v8 = &v7->8;
il2cpp_set_config_dir(v8);
v9 = v6->m_data;
if ( !v6->m_data )
v9 = &v6->8;
il2cpp_set_data_dir(v9);
v10 = GetMonoDebuggerAgentOptions(&result, 0);
v11 = v10->m_data;
if ( !v10->m_data )
v11 = &v10->8;
il2cpp_debugger_set_agent_options(v11);
if ( result.m_data && result.m_capacity )
operator delete(result.m_data, result.m_label);
il2cpp_init("IL2CPP Root Domain");
il2cpp_set_config("unused_application_configuration");
profiling::ScriptingProfiler::Initialize();
return 1;
}

然而有相当不同的变化在不同的版本中,他们的共同特点是对 il2cpp_init 的调用和 IL2CPP Root Domain 与 unused_application_configuration,所以通过这些我们也同样快速找到

例子

我们以2018年的N1CTF-BabyUnity3d为例来简单的了解一下其逆向流程,我们还是一样的提取出来libil2cpp.so以及global-metadata.dat使用上述项目进行处理,但是其提醒我们出错了,可能是文件没找到或者是加密了

此题比较基础只对global-metadata.dat进行了加密处理,方便我们的学习理解

我们打开010来观察对应文件魔术字

可以很明显的发现其被加密处置了,我们采用上述提到的Frida脚本,然而对于这题起不到作用,脚本执行后没有找到起始地址,即使解密后,内存中也没有AF 1B B1 FA存在。所以这种通用的dump方式应该是不行了,只能找到global-metadata.dat的加载函数,待其解密完成后再进行dump,所以我们需要对global-metadata.dat的加载流程进行分析。

我们先尝试进行寻找il2cpp_init

sub_4C4770il2cpp::vm::Runtime::Init,我们进入该函数后一个个查找对比源码可以发现sub_4B5564il2cpp::vm::MetadataCache::Initialize

那么看该函数可以很明显发现,global-metadata.dat字符串变成了其他字符串,但程序运行sub_4B5518会自动解密回去。同样的对应着源码sub_513060就是il2cpp::vm::MetadataLoader::LoadMetadataFile,其返回值为一个指针

随后还是比对源码,我们需要找到对应修改过的地方,可以发现对应的else块被修改过

我们进入该函数便可以获取到对应global-metadata.dat加密的逻辑

我们写出对应的解密脚本

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
import struct
f = open('global-metadata.dat', 'rb')
a = ""
a = f.read()
key = [0xF83DA249, 0x15D12772, 0x40C50697, 0x984E2B6B, 0x14EC5FF8, 0xB2E24927,
0x3B8F77AE, 0x472474CD, 0x5B0CE524, 0xA17E1A31, 0x6C60852C, 0xD86AD267, 0x832612B7, 0x1CA03645, 0x5515ABC8,
0xC5FEFF52, 0xFFFFAC00, 0x0FE95CB6, 0x79CF43DD, 0xAA48A3FB, 0xE1D71788, 0x97663D3A, 0xF5CFFEA7, 0xEE617632,
0x4B11A7EE, 0x040EF0B5, 0x0606FC00, 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, 0x0C5D9160, 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, 0x0F673A57, 0xF40600E1, 0x7E967417, 0x15A26203, 0x5F2E34CE,
0x70C7921A, 0xD1C190DF, 0x5BB5DA6B, 0x60979C75, 0x4EA758A4, 0x078FE359, 0x1664639C, 0xAE14E73B, 0x2070FF03]
with open('decrypt.dat', 'wb') as fp:
n = 0
while n < len(a):
num = struct.unpack("<I", a[n:n + 4])[0]
num = num ^ key[(n + n // 0x84) % 0x84]
d = struct.pack('I', num)
fp.write(d)
n = n + 4

修改完后我们可以发现其魔术字仍然有问题,但是可以明显发现是人为修改后的,我们将其修复为AF 1B B1 FA,修复后我们再运行il2cppDump之后便可以拿到对应的函数信息

dump.cs

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

IL2cpp.h

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

script.json

以 json 格式显示类的方法信息

stringliteral.json

以 json 的格式显示所有字符串信息

DummyDll

进入该目录,可以看到很多dll,其中就有 Assembly-CSharp.dll 和我们刚刚的 dump.cs 内容是一致的

我们使用.Net反编译工具对DummyDll中的Assembly-CSharp.dll进行反编译,我们可以清晰的看到其导入的函数

其实直接看dump.cs也可以,但是个人感觉有点混乱(虽然两个差不多)

不难看出其使用了一个AES来进行Check我们的Flag,我们使用项目提供的python脚本来将libil2cpp.so中的符号进行还原,我们使用Alt+F7或者点击Script执行脚本

此处根据自己IDA使用的Python版本进行选择

之后会要求我们选择一个json以及h文件,我们选择Dump出来的script.json以及il2cpp.h即可

image-20230816064231325

之后等待一会便可以恢复出对应的符号信息,我们搜索对应CheckFlag函数

我们可以看到是Check_TypeInfo对其进行赋值,我们通过交叉引用可以找到对应的赋值点

对应加密后的Base64则通过StringLiteral_2814进行寻找

之后便可以写出对应的解密脚本,成功拿到 Flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64

def aes_decrypt(ciphertext, key):
cipher = AES.new(key, AES.MODE_CBC, b'58f3a445939aeb79')
ciphertext = base64.b64decode(ciphertext)
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
return plaintext.decode('utf-8')


key = b'91c775fa0f6a1cba'
ciphertext = 'w0ZyUZAHhn16/MRWie63lK+PuVpZObu/NpQ/E/ucplc='

decrypted_text = aes_decrypt(ciphertext, key)
print(decrypted_text)
# N1CTF{h4ppy_W1TH_1l2cpp}

参考

IL2CPP Tutorial: Finding loaders for obfuscated global-metadata.dat files | Katy’s Code (wordpress.com)

IL2CPP Reverse Engineering Part 2: Structural Overview & Finding the Metadata | Katy’s Code (wordpress.com)

IL2CPP - P.Z’s Blog (ppppz.net)

【游戏开发进阶】教你使用IL2CppDumper从Unity il2cpp的二进制文件中获取类型、方法、字段等(反编译)_林新发的博客-CSDN博客

Il2cpp逆向:global-metadata解密-腾讯云开发者社区-腾讯云 (tencent.com)


IL2CPP 逆向
https://equinox-shame.github.io/2023/08/16/IL2CPP 逆向/
作者
梓曰
发布于
2023年8月16日
许可协议