IL2CPP 逆向工程:字符串提取(绕路版)

书接某位 可爱女大(本文中的🐱)的上回,在凌晨四点的一个 Twitter Space 里面,我们开始研究 IL2CPP 的字符串这个大坑。

global-metadata.dat

IL2CPP 中引用的字符串字面量都存储在 Global Metadata 文件中,并在应用运行时按需加载,要取得字符串池,就需要先获得这个文件。在元气骑士的最新版中,这个文件在 APK 中存储的版本是加密的,并且相关算法进行了保护,难以直接取得。

这里参考了 https://www.bilibili.com/read/cv25143217/ 这篇文章,大致的思路是对 vm::MetadataLoader::LoadMetadataFile 这个函数进行 Hook,从而取得实际加载进引擎的文件。

由于除了 Metadata 文件加密之外,可执行文件(libil2cpp.so)也可能进行了保护,可以选用🐱编写的 Il2CppMemoryDumper 工具一次性提取 Metadata 和可执行文件,并可以绕过部分对可执行文件内容的保护,在本次酣畅淋漓的逆向 绕圈子 之后,工具内也新增了对内存中的加密 Metadata 文件的处理(居然是用纯 Shell 编写的,太恐怖了)。

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
var finded = false
var addr;
var notFirstTime = true
var s_GlobalMetadataHeader;

setInterval(() => {
    if (finded) {
        if (addr != null) {
            let offset = 0x3A5CEB4; // 此值需要更改
            let funcOff = addr.base.add(offset);
            if (notFirstTime) {
                notFirstTime = false;
                Interceptor.attach(funcOff, {
                    onEnter: function (args) {
                        console.log("MetadataLoader::LoadMetadataFile");
                    },
                    onLeave: function (retval) {
                        console.log("s_GlobalMetadataHeader:" + retval.toString(16));
                        s_GlobalMetadataHeader = retval;
                        save(get_size())
                    }
                })
            }
        } else {
            addr = Process.findModuleByName("libil2cpp.so");
            if (addr == null) {
                return;
            }
            let offset = 0x3A5CEB4; // 此值需要更改
            let funcOff = addr.base.add(offset);
            if (notFirstTime) {
                notFirstTime = false;
                Interceptor.attach(funcOff, {
                    onEnter: function (args) {
                        console.log("MetadataLoader::LoadMetadataFile");
                    },
                    onLeave: function (retval) {
                        console.log("s_GlobalMetadataHeader:" + retval.toString(16));
                        s_GlobalMetadataHeader = retval;
                        save(get_size())
                    }
                })
            }
        }
    } else {
        try {
            addr = Process.findModuleByName("libil2cpp.so");
            finded = true;

        } catch (ex) {
        }
    }
}, 5);

function get_size() {
    const metadataHeader = s_GlobalMetadataHeader;
    let fileOffset = 0x10C;
    let lastCount = 0;
    let lastOffset = 0;
    while (true) {
        lastCount = Memory.readInt(ptr(metadataHeader).add(fileOffset));
        if (lastCount !== 0) {
            lastOffset = Memory.readInt(ptr(metadataHeader).add(fileOffset - 4));
            break;
        }
        fileOffset -= 8;
        if (fileOffset <= 0) {
            console.log("get size failed!");
            break;
        }
    }
    return lastOffset + lastCount;
}

function save(size) {
    var file = new File("/data/data/com.ChillyRoom.DungeonShooter/files/global-metadata.dump.dat", "wb");
    var contentBuffer = Memory.readByteArray(s_GlobalMetadataHeader, size);
    file.write(contentBuffer);
    file.flush();
    file.close;
    console.log("global-metadata 已导出到 /data/data/com.ChillyRoom.DungeonShooter/files/global-metadata.dump.dat")
}

对原文的脚本稍作修改,并对路径进行处理(/data/local/tmp有的时候对用户应用不可写),成功得到了解密后的 Metadata 文件。

Begin of the Rabbit Hole

剧透 这事其实没有我们想象的复杂,掉坑里去了,但还是做一下记录,以备不时之需。

把文件加载进解析工具 Il2CppDumper 之后,我们发现这玩意无论如何都跑不动。在启动视觉工作室™对 Il2CppDumper 进行了一些小修改后,可以导出以字母表顺序排序的字符串池(这一部分没有问题)。但是🐱表示希望能获得以引用地址排序的字符串池(她表示之前的版本就是这样的),以方便后续的逆向,所以还需要继续研究后面的结构。

由于游戏里面确实有一些防护,我们的第一想法是有部分加载逻辑被修改了,于是开始尝试寻找正确的文件结构。

String Literals 是如何存储的(v24.1)

在文件头 Il2CppGlobalMetadataHeader 中,有一组偏移量 metadataUsagePairs{Offset, Count} 记录了 Metadata Usage 表在文件中的位置,这张表记录了 Metadata 中的各类资源与可执行文件中的代码和其他数据结构之间的关联关系,字符串本身的存储较为简单,这里不再详述。

Metadata Usage 表由两个字段组成,描述了一对从字符串到偏移地址的对应关系。

1
2
3
4
5
6
7
8
9
10
11
typedef struct Il2CppMetadataUsagePair
{
// 该 Metadata 在可执行文件中某数据结构的索引
uint32_t destinationIndex;
// 该 Metadata 的类型和在本文件中的索引
// 对于字符串,就是在字符串池中的索引
// 其中 index = encodedSourceIndex & 0x1FFFFFFF /* 这个在 v27+ 不一样 */
// usage = (encodedSourceIndex & 0xE0000000) >> 29
// usage 取值为 1-6,0 和 7 均无效
uint32_t encodedSourceIndex;
} Il2CppMetadataUsagePair;

相对应的,在可执行文件中有如下结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct Il2CppMetadataRegistration
{
int32_t genericClassesCount;
Il2CppGenericClass* const * genericClasses;
int32_t genericInstsCount;
const Il2CppGenericInst* const * genericInsts;
int32_t genericMethodTableCount;
const Il2CppGenericMethodFunctionsDefinitions* genericMethodTable;
int32_t typesCount;
const Il2CppType* const * types;
int32_t methodSpecsCount;
const Il2CppMethodSpec* methodSpecs;

FieldIndex fieldOffsetsCount;
const int32_t** fieldOffsets;

TypeDefinitionIndex typeDefinitionsSizesCount;
const Il2CppTypeDefinitionSizes** typeDefinitionsSizes;
const size_t metadataUsagesCount; /****/
void** const* metadataUsages; /****/
} Il2CppMetadataRegistration;

这个结构体在进行代码生成时即被填入数据,存储在 .data.rel.ro 段中。metadataUsages中的指针在运行时 会被替换为指向实际字符串的指针

在 IL2CPP 生成的代码中,字符串被引用的方式可以参考 Investigating an issue about creating string literals in multiple threads in Unity 一文。每一个字符串都是一个全局变量(也就是会在 .got 中生成一项), 在 .got 中的顺序大致上与字符串在函数中引用的顺序一致,可以作为逆向的参考。

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
// Il2CppMetadataUsage.cpp  
String_t* _stringLiteral3495210533;

extern void** const g_MetadataUsages[73910] =
{
// ...
(void**)(&_stringLiteral3495210533),
// ...
};

// Il2CppMetadataRegistration.cpp
extern void** const g_MetadataUsages[];
extern const Il2CppMetadataRegistration g_MetadataRegistration =
{
33686,
s_Il2CppGenericTypes,
9827,
g_Il2CppGenericInstTable,
77179,
s_Il2CppGenericMethodFunctions,
74908,
g_Il2CppTypeTable,
80321,
g_Il2CppMethodSpecTable,
14575,
g_FieldOffsetTable,
14575,
g_Il2CppTypeDefinitionSizesTable,
73137,
g_MetadataUsages,
};

// Il2CppCodeRegistration.cpp
void s_Il2CppCodegenRegistration()
{
il2cpp_codegen_register (&g_CodeRegistration, &g_MetadataRegistration, &s_Il2CppCodeGenOptions);
}
#if RUNTIME_IL2CPP
static il2cpp::utils::RegisterRuntimeInitializeAndCleanup s_Il2CppCodegenRegistrationVariable (&s_Il2CppCodegenRegistration, NULL);
#endif

这里我发现,文件头和字符串索引表有 16 个字节的重叠,说明文件头的大小并不正确:

在尝试将每一组 OffsetCount都当成 Metadata Usage Pairs 解析后,发现都不满足该结构的条件,于是先放弃处理元数据,看看可执行文件那边是什么情况。

g_MetadataRegistration,你在哪里?

该结构体的偏移量在有符号的时候可以直接拿到,没有符号的时候 Dumper 可以进行搜索,但这里由于 Metadata 文件无法解析,采用了手动的方式。在 MetadataCache 的初始化过程中,会用到一个指向该结构体的指针,稍作对比,即可找出引用了 s_Il2CppMetadataRegistration 这个指针的代码:

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
// libil2cpp/vm/MetadataCache.cpp

static const Il2CppCodeRegistration * s_Il2CppCodeRegistration;
static const Il2CppMetadataRegistration * s_Il2CppMetadataRegistration;
static const Il2CppCodeGenOptions* s_Il2CppCodeGenOptions;
static CustomAttributesCache** s_CustomAttributesCaches;

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);

// Pre-allocate these arrays so we don't need to lock when reading later.
// These arrays hold the runtime metadata representation for metadata explicitly
// referenced during conversion. There is a corresponding table of same size
// in the converted metadata, giving a description of runtime metadata to construct.
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));

// ...
}

于是使用 Frida 动态调试,找出实际的偏移量:

1
2
new NativePointer(Process.findModuleByName("libil2cpp.so").base.add(0x9CFF4C8)).readPointer().sub(Process.findModuleByName("libil2cpp.so").base)
// 0x93e29d8

没毛病,但是 metadataUsagesCount 怎么是 0 呢?

于是开始在源码里寻找用到了上面提到的这些数据的代码,找到了 il2cpp::vm::MetadataCache::IntializeMethodMetadataRange 这个函数,里面有一个标志性的switch,在翻看 xref 的时候很容易注意到:

加载字符串的实际逻辑:

由于 IDA 对 switch 的处理比较苦手,此处没有 F5 源代码可供参考,但是看起来和公开的源码没有很大的出入。

小丑竟是我自己

为啥不能用呢?这个时候我决定动调看看:

1
frida-trace -U  'Soul Knight' -a 'libil2cpp.so!0x3A5D078'
1
2
3
4
5
6
7
8
9
10
{
onEnter(log, args, state) {
log(`sub_3a5d078(${args[0].sub(Process.findModuleByName("libil2cpp.so").base)}, ${args[1]}, ${args[2]})`);
state.ptr = args[0];
log(`==> ${state.ptr.readPointer()}`);
},
onLeave(log, retval, state) {
log(`<== ${state.ptr.readPointer()}`);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
1167 ms  sub_3a5d078(0x99124d0, 0x1, 0xb400007d85d38900)
1167 ms 0x2000f3eb
1168 ms 0x7d143167c0
1168 ms sub_3a5d078(0x9967768, 0x1, 0x0)
1168 ms 0xc0070c03
1168 ms 0x7d1431b8a0
1168 ms sub_3a5d078(0x9a04f30, 0x1, 0xb400007d85abb798)
1168 ms 0xa00154fb
1168 ms 0x7d3e8e27d0
1168 ms sub_3a5d078(0x9a04f38, 0x1, 0x32)
1168 ms 0xa00154fd
1168 ms 0x7d3e8e2780

注意到第一个参数是个指针,而且指向 ELF 中的区域,并且指针的目标数据在调用前后发生了变化。

查看 ELF 中的对应区域(也就是输出中调用前的值),发现全部都满足 Usage Pairs 的结构条件,也就是说我们苦苦寻找的数据区段,并不在 Metadata 里面。这个时候我想起来 Il2CppDumper 代码中,对 v27+ 的 Metadata 文件,并没有读取相关区段,而是在 ELF 中进行查找,就开始觉得不对劲了,难不成版本不对?

先继续进行处理,注意到每一项在 .got 中都有引用,而 .got 中的顺序是有意义的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.data:00000000099124D8 qword_99124D8   DCQ 0x2003077F          ; DATA XREF: il2cpp:000000000514623C↑r
.data:00000000099124D8 ; .got:off_96975A0↑o
.data:00000000099124E0 qword_99124E0 DCQ 0x20030781 ; DATA XREF: il2cpp:0000000005145D28↑r
.data:00000000099124E0 ; .got:off_9694B70↑o
.data:00000000099124E8 qword_99124E8 DCQ 0x2000F3F1 ; DATA XREF: il2cpp:0000000003B9C92C↑r
.data:00000000099124E8 ; il2cpp:0000000003B9D4C0↑r ...
.data:00000000099124F0 qword_99124F0 DCQ 0x20030783 ; DATA XREF: il2cpp:loc_3C7DF80↑r
.data:00000000099124F0 ; .got:off_9625870↑o
.data:00000000099124F8 qword_99124F8 DCQ 0x20030785 ; DATA XREF: il2cpp:loc_8D2E62C↑r
.data:00000000099124F8 ; .got:off_9710F08↑o
.data:0000000009912500 qword_9912500 DCQ 0x20030787 ; DATA XREF: il2cpp:0000000008F12634↑r
.data:0000000009912500 ; .got:off_9718F28↑o
.data:0000000009912508 qword_9912508 DCQ 0x2000F3FD ; DATA XREF: il2cpp:loc_85BDDF8↑r
.data:0000000009912508 ; il2cpp:00000000085BDE1C↑r ...
.data:0000000009912510 qword_9912510 DCQ 0x20030789 ; DATA XREF: il2cpp:0000000004F4B3A4↑r
.data:0000000009912510 ; il2cpp:loc_4F4CBD0↑r ...

于是编写脚本,以 .got 中的顺序输出字符串引用:

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
import lief
import struct, json

lib = lief.ELF.parse("libil2cpp.so")
raw_lib = open("libil2cpp.so", "rb").read()
out_strings = []

with open('stringliteral.json', 'rb') as f:
    strings = json.load(f,)

missing_strings = set(range(len(strings)))

for rel in lib.relocations:
    target = raw_lib[rel.addend:rel.addend+4]
    if len(target) != 4:
        continue

    encoded,  = struct.unpack('<I', target)
    usage = (encoded & 0xE0000000) >> 29
    index = (encoded & 0x1FFFFFFE) >> 1

    if usage == 5 and index < len(strings):
        missing_strings.discard(index)
        out_strings.append(strings[index])

print("Refs:",  len(out_strings))
print("Not referenced in GOT", missing_strings)

with open('strings.json', 'w', encoding='utf-8') as f:
    json.dump(out_strings, f, indent=4, ensure_ascii=False)

细心的朋友可能注意到了,在解析 index 时使用的公式,和上文并不一致,这是因为…

String Literals 是如何存储的(v27+)

🥳我们找错版本啦!

上面我们逆向分析出来的加载机制,实际上和 v27 和以上版本的加载机制完全一致:字符串是全局变量放在 .got 里面,GOT 指向 Usage Pairs 的位置,IntializeMethodMetadataRange函数接收指向 Usage Pair 的指针,在 Metadata 中查找相关的字符串或其他结构,并分配内存,用该结构的指针覆盖 Usage Pair。

IL2CPP 中对 global-metadata.dat 的版本号和 Magic Nuber 的检测使用 IL2CPP_ASSERT 宏进行,这个宏在 Release 版本中会被替换成void (0),不起任何作用,因此事实上对于 Release 版本而言,只要文件的内容和实际运行的版本一致,即使文件头缺失或版本号不正常,也不会对加载产生影响。

尝试将 global-metadata.dat 中的版本号直接由 0x18 改为 0x1D,不做任何其他处理,并使用 Il2CppDumper 的最新版(注意之前的版本对 v29 的处理有问题,这里也卡了很久),配合🐱从内存中获取的解除保护的 Dump 文件,终于成功解析了所有内容,绕了个巨大的圈子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
> Il2CppDumper.exe Z:/7452280000_com.ChillyRoom.DungeonShooter_libil2cpp.so 'Z:/global-metadata.dat'
Initializing metadata...
Metadata Version: 29
Initializing il2cpp file...
Applying relocations...
Il2Cpp Version: 29
Detected this may be a dump file.
Input il2cpp dump address or input 0 to force continue:
7452280000
Searching...
Change il2cpp version to: 29.1
CodeRegistration : 745b332f20
MetadataRegistration : 745b6629d8
Dumping...
Done!
Generate struct...
Done!
Generate dummy dll...
Done!
Press any key to exit...

比较可惜的是,使用工具导出的字符串表,依旧是以 A-Z 的顺序排序的,无法满足最早的需求,所以或许之前的那个巨大的 Rabbit Hole,还是必须跳的呢?

总结

  • 逆向工程用的工具,尽量还是用最新版(至少不要“不小心”用到旧版)
  • 某些场景下,一些表象是障眼法的可能性比真的花大力气做了处理的可能性大
  • Frida 的可用性真的是玄学,🐱换了两台机器都没跑起来脚本,我自己在处理的时候也经常需要从好好的 -f 启动应用改成传入进程名称然后拼手速

IL2CPP 逆向工程:字符串提取(绕路版)

https://reimu.moe/2024/06/09/IL2Cpp-String-Literals/

作者

Midori Kochiya

发布于

2024-06-09

更新于

2024-06-09

许可协议