开干
说干就干,提取游戏安装包,在lib/arm64-v8a路径提取出libil2cpp.so,在assets/bin/Data/Managed/Metadata路径提取出global-metadata.dat
直接打开il2cppdumper,选择这两个文件,发现报错:

原版dump报错.png (43.87 KB, 下载次数: 0)
下载附件
2025-3-2 16:55 上传
那应该是有加密的,用010Editor打开global-metadata.dat文件,发现熵值很高,很明显的加密了

原版global-metadata.png (241.24 KB, 下载次数: 0)
下载附件
2025-3-2 16:56 上传
ok了,既然安装包中的global-metadata.dat被加密了,那我直接去内存中dump到的,应该就没问题吧!
既然要从内存中获取到global-metadata.dat,那肯定要根据libil2cpp.so中的逻辑来找出加载global-metadata.dat的地方,当然也可以通过在内存中搜寻魔数头的方式来找到文件头(ps:这个例子的魔数头也被抹除了,所以只能采取分析libil2cpp.so中的逻辑了@_@;)
事情果然没这么简单,当我用IDA打开libil2cpp.so后,发现libil2cpp.so也被加固了,导出表被抹除完了

原版导出表.png (12.66 KB, 下载次数: 1)
下载附件
2025-3-2 16:58 上传
并且我看到依赖库中包含libtprt.so

依赖so.png (18.56 KB, 下载次数: 1)
下载附件
2025-3-2 16:58 上传
网上搜索得知,libtprt.so是属于某讯的加固,好吧,看来还是有难度的,继续分析吧!
既然安装包中的libil2cpp.so也被加固了,那也只能去内存中拿了,写了一个frida脚本去获取libil2cpp.so:
[JavaScript] 纯文本查看 复制代码function dump_so() {
Java.perform(function() {
var currentApplication = Java.use("android.app.ActivityThread").currentApplication();
var dir = currentApplication.getApplicationContext().getFilesDir().getPath();
var libso = Process.getModuleByName("libil2cpp.so");
var file_path = dir + "/" + libso.name + "_" + libso.base + "_" + ptr(libso.size) + ".so";
var file_handle = new File(file_path, "wb");
if (file_handle && file_handle != null) {
Memory.protect(ptr(libso.base), libso.size, 'rwx');
var libso_buffer = ptr(libso.base).readByteArray(libso.size);
file_handle.write(libso_buffer);
file_handle.flush();
file_handle.close();
console.log("[dump]:", file_path);
}
});
}
var isCalled = false;
function hookdlopen() {
var dlopen = Module.findExportByName(null, "dlopen");
Interceptor.attach(dlopen, {
onEnter: function (args) {
var path = args[0].readCString();
if (path && path.indexOf('libil2cpp.so') !== -1) {
this.path = path;
}
},
onLeave: function (retval) {
if (this.path && this.path.indexOf('libil2cpp.so') !== -1 && !isCalled) {
dump_so();
isCalled = true;
}
}
});
}
hookdlopen();
frida使用spawn模式选择脚本并打开游戏,然后根据打印出来的地址找到dump下来的so,尝试将其使用ida打开,发现报错

dump后的so报错.png (17.59 KB, 下载次数: 1)
下载附件
2025-3-2 17:00 上传
使用SoFixer工具进行修复,然后再次通过ida打开,发现导出表都正常了,终于迈出万里长征的第一步了!

正常导出表.png (159.36 KB, 下载次数: 1)
下载附件
2025-3-2 17:00 上传
想要找到global-metadata.dat的内存地址,则需要通过将ida分析出的反汇编代码和unity il2cpp的源码进行对比来快速得到结果,通过分析源码发现,加载metadata会使用一个字符串global-metadata.dat

加载metadata的源码.png (31.88 KB, 下载次数: 1)
下载附件
2025-3-2 17:00 上传
尝试在ida中搜索这个字符串

ida查询metadata字符串.png (10.83 KB, 下载次数: 1)
下载附件
2025-3-2 17:01 上传
通过交叉引用获取到它的使用地址(图片中的变量名是我重命名后的,并不是原版)

metadata交叉引用获取.png (47.17 KB, 下载次数: 1)
下载附件
2025-3-2 17:01 上传
F5进行反汇编分析(图片中的变量名是我重命名后的,并不是原版)

metadata头伪c分析.png (70.97 KB, 下载次数: 1)
下载附件
2025-3-2 17:02 上传
发现sub_1685100和源码中LoadMetadataFile的作用很相近,直接跟进去看看

sub_1685100伪c.png (11.5 KB, 下载次数: 1)
下载附件
2025-3-2 17:02 上传
继续跟sub_2F0

sub_2F0伪c.png (12.72 KB, 下载次数: 1)
下载附件
2025-3-2 17:03 上传
F5处理有问题,没关系,继续跟下去吧

0x7281F68地址汇编.png (35.57 KB, 下载次数: 1)
下载附件
2025-3-2 17:04 上传
跟到最后发现原来是调用了libtprt里面的导出函数来进行的加载metadata,这里面肯定会涉及到加密或者解密了,还是要跟进去看看
仔细观察汇编,知道最终跳转使用的是BR X2,查看X2寄存器之前的赋值记录,只有一条LDR X2, [X8,#0x128],X8寄存器又是直接赋值g_tprt_pfn_array_ptr_0这个导入函数的地址,所以最终需要分析的地址为:libtprt.so中g_tprt_pfn_array_ptr_0导出函数的地址偏移0x128后的地址
从安装包中提取出libtprt.so,使用ida打开进行分析
找到g_tprt_pfn_array_ptr_0导出函数

g_tprt_pfn_array_ptr_0导出函数.png (21.15 KB, 下载次数: 1)
下载附件
2025-3-2 17:05 上传
根据它的地址,偏移0x128后看看

偏移0x128后的地址.png (17.03 KB, 下载次数: 1)
下载附件
2025-3-2 17:06 上传
进去看看

1BDD9C地址汇编.png (16.12 KB, 下载次数: 1)
下载附件
2025-3-2 17:06 上传
继续跟进去

1BCE3C汇编.png (115.69 KB, 下载次数: 1)
下载附件
2025-3-2 17:07 上传
F5看下伪c吧

1BCE3C伪c.png (38.43 KB, 下载次数: 1)
下载附件
2025-3-2 17:07 上传
这个函数大致流程就是先调用偏移为0x277DA0处的函数指针,然后根据这个函数的返回值进行if分支,了解global-metadata.dat的朋友应该知道,正常的魔数头就是AF1BB1FA,这说明0x277DA0处的函数应该就是加载metadata的函数,不然后面应该是不会判断这个魔数的,当然话不可以说的这么满,还是继续看后续代码吧,else里面是两个函数调用,大致功能为先调用sub_1BDC9C来获取需要调用的函数,然后将函数指针传递给v5,最后调用v5里存储的函数
函数大致流程分析的差不多了,先去看看0x277DA0处的函数指针吧

277DA0汇编.png (40.08 KB, 下载次数: 1)
下载附件
2025-3-2 17:08 上传
可以看到0x277DA0属于bss段,这是一个存储未初始化的全局和静态变量的段,查询交叉引用也没有其余调用,那么静态分析行不通,就只能通过动态分析了
写了一个frida脚本去获取0x277DA0处的函数指针,考虑到不知道它什么时候完成初始化,所以我们直接在调用sub_1BCE3C的时候才进行获取指针内容
[JavaScript] 纯文本查看 复制代码function print_arg(){
var libtprtaddr = Module.findBaseAddress("libtprt.so");
console.log("libtprt基址: ",libtprtaddr);
console.log("libil2cpp基址: ",Module.findBaseAddress("libil2cpp.so"));
var function_addr = libtprtaddr.add(0x1BCE3C);
Interceptor.attach(function_addr,{
onEnter:function (args) {
console.log("0x277DA0: ",Memory.readPointer(libtprtaddr.add(0x277DA0)));
},
onLeave:function (returnValue) {
}
})
}
var isCalled = false;
function hookdlopen() {
var dlopen = Module.findExportByName(null, "dlopen");
Interceptor.attach(dlopen, {
onEnter: function (args) {
var path = args[0].readCString();
if (path && path.indexOf('libil2cpp.so') !== -1) {
this.path = path;
}
},
onLeave: function (retval) {
if (this.path && this.path.indexOf('libil2cpp.so') !== -1 && !isCalled) {
print_arg();
isCalled = true;
}
}
});
}
hookdlopen();
运行后查看打印情况

277DA0frida打印.png (15.37 KB, 下载次数: 1)
下载附件
2025-3-2 17:09 上传
可以明显看到,0x277DA0处的函数指针并不是libtprt内的函数,而是libil2cpp中的,将得到的地址减去libil2cpp的基址,得到0x7281F04,去ida中查看

原版0x7281F04.png (37.9 KB, 下载次数: 0)
下载附件
2025-3-2 17:10 上传
数据并没有解析出来,我们按"C"键来将其主动转化成汇编

反汇编后0x7281F04.png (24.19 KB, 下载次数: 0)
下载附件
2025-3-2 17:11 上传
可以看到他跳转了一个函数,进去跟进去吧

1685104伪c开头.png (79.52 KB, 下载次数: 0)
下载附件
2025-3-2 17:11 上传
可以看到有一个明显的Metadata字符串,这和源码中的LoadMetadataFile函数很类似

LoadMetadataFile源码.png (61.09 KB, 下载次数: 0)
下载附件
2025-3-2 17:11 上传
继续往下看,发现还有类似的字符串,如"ERROR: Could not open %s"

1685104伪c结尾.png (90.23 KB, 下载次数: 0)
下载附件
2025-3-2 17:12 上传
那看来函数应该是找对了,继续对照着看,发现sub_165588C和os::File::Open很类似,都是6个参数,而且v42也和error很像,那么v32就可以认为是源码中的handle了。继续对照源码,源码中只有两个地方调用了handle,分别是utils::MemoryMappedFile::Map和os::File::Close,而ida中的伪c代码也只有两处,分别是sub_16DC91C和sub_1655C7C,故而直接推论,sub_16DC91C就是utils::MemoryMappedFile::Map,那么直接跟进去看看实现

sub_16DC91C伪c.png (12.11 KB, 下载次数: 1)
下载附件
2025-3-2 17:12 上传
跟进去看看

sub_16DCB14伪c.png (32.24 KB, 下载次数: 1)
下载附件
2025-3-2 17:13 上传
如图所示,整个sub_16DCB14只调用了三个函数,我们分别对着三个函数进行分析

sub_16EC43C伪c.png (13.01 KB, 下载次数: 1)
下载附件
2025-3-2 17:13 上传
很明显,sub_16EC43C只是一个计算长度的,直接跳过

sub_165A548伪c.png (23.75 KB, 下载次数: 0)
下载附件
2025-3-2 17:14 上传
同样的,通过sub_165A548的返回值也能看出来并不是主要函数
那就只能是sub_165A6FC了,跟进去看看

sub_165A6FC伪c.png (12.14 KB, 下载次数: 0)
下载附件
2025-3-2 17:14 上传
F5分析的有问题,直接看汇编吧

sub_165A6FC汇编-1.png (57.7 KB, 下载次数: 0)
下载附件
2025-3-2 17:15 上传
果然有问题,BL指令调用完全后是会执行后续指令的,这是带LR寄存器的跳转,所以后续的那个函数也应该包含在sub_165A6FC函数里面,直接去看0x165A740+4,也就是0x165A744处的函数实现

165A744伪c.png (75.97 KB, 下载次数: 0)
下载附件
2025-3-2 17:15 上传
提示栈有问题,不用管,能分析出来就行,查看逻辑,发现返回的result只与sub_F1E0B0有关,那行,跟进去看看

sub_F1E0B0伪c.png (12.5 KB, 下载次数: 0)
下载附件
2025-3-2 17:15 上传
继续跟

off_6C9AF40汇编.png (19.22 KB, 下载次数: 0)
下载附件
2025-3-2 17:16 上传

loc_728198C汇编.png (30.19 KB, 下载次数: 0)
下载附件
2025-3-2 17:16 上传
又看到了熟悉的g_tprt_pfn_array_ptr_0,继续去libtprt里面去找吧,不过这次的偏移量是0xA0

g_tprt_pfn_array_ptr_0偏移A0.png (86.22 KB, 下载次数: 0)
下载附件
2025-3-2 17:17 上传
跟进去,是个B跳转,继续跟,看到了一个函数

sub_1BCA50-1.png (103.12 KB, 下载次数: 0)
下载附件
2025-3-2 17:17 上传

sub_1BCA50-2.png (78.01 KB, 下载次数: 0)
下载附件
2025-3-2 17:17 上传
我们注意到函数内有几个判断值的if语句:
if ( buf[0] != 0x94 )
return mmap(addr, len, prot, flags, fd, offset);
if ( buf[1] != 0x43 )
return mmap(addr, len, prot, flags, fd, offset);
if ( buf[2] != 0x72 )
return mmap(addr, len, prot, flags, fd, offset);
if ( buf[3] != 0x12 )
return mmap(addr, len, prot, flags, fd, offset);
这与我们开头看到的安装包内的global-metadata.dat的头一模一样,所以基本可以判定,这个就是解密的函数,我们直接hook这个函数的返回值看看:
[JavaScript] 纯文本查看 复制代码function print_arg(){
var libtprtaddr = Module.findBaseAddress("libtprt.so");
var libil2cppaddr = Module.findBaseAddress("libil2cpp.so");
console.log("\n");
console.log("libtprt基址:",libtprtaddr);
console.log("libil2cpp基址:",libil2cppaddr);
var function_addr = libtprtaddr.add(0x1BCA50);
var hooked = false;
Interceptor.attach(function_addr,{
onEnter:function (args) {
this.len = parseInt(this.context.x1);
},
onLeave:function (returnValue) {
if(!hooked){
hooked = true;
var currentApplication = Java.use("android.app.ActivityThread").currentApplication();
var dir = currentApplication.getApplicationContext().getFilesDir().getPath();
var file_path = dir + "/global-metadata.dat";
var file_handle = new File(file_path, "wb");
if (file_handle && file_handle != null) {
var buffer = ptr(this.context.x0).readByteArray(this.len);
file_handle.write(buffer);
file_handle.flush();
file_handle.close();
console.log("[dump]:", file_path);
}
}
}
})
}
var isCalled = false;
function hookdlopen() {
var dlopen = Module.findExportByName(null, "dlopen");
Interceptor.attach(dlopen, {
onEnter: function (args) {
var path = args[0].readCString();
if (path && path.indexOf('libil2cpp.so') !== -1) {
this.path = path;
}
},
onLeave: function (retval) {
if (this.path && this.path.indexOf('libil2cpp.so') !== -1 && !isCalled) {
print_arg();
isCalled = true;
}
}
});
}
hookdlopen();
这里需要注意,我是测试过这个函数是第一个加载global-metadata的,所以添加了个hooked变量去控制,如果不清楚是什么时候加载global-metadata的话,可以打印this.len看看,一般来说和安装包内的大小差不多,可能会有些许差距
看看内存dump出来的global-metadta吧

内存global-metadta.png (218.45 KB, 下载次数: 0)
下载附件
2025-3-2 17:18 上传
可以看到,文件头是被抹除了的,但是基本上的内容都还在,我们用UnityMetadata.bt模板跑一遍看看

UnityMetadata跑一遍.png (30.38 KB, 下载次数: 0)
下载附件
2025-3-2 17:19 上传
是报错了的,看来内存dump出来的还是有问题,然后我hook了最开始的sub_1684EF0函数,看看会不会在中途继续解密,结果是没有,最后返回的内容还是和之前hook的一样的
继续分析吧,我们看看Il2CppGlobalMetadataHeader是什么样子

Il2CppGlobalMetadataHeader模板查看.png (52.13 KB, 下载次数: 0)
下载附件
2025-3-2 17:19 上传
可以看到,除了文件头的四个魔数被抹除了之外,其余的信息是全的,那么问题出在哪里呢,通过Il2CppGlobalMetadataHeader的内容我们可以看到,stringLiteralOffset的值为256,即0x100,那么表示文件内容是从0x100开始的,我们查看0x100处的内容,通过与正常的global-metadata.dat文件进行对比,可以确认这里肯定存在加密(因为正常的global-metadata.dat 0x104处的值必须为0)
那怎么办呢?我想到了查看源码,看看源码中有没有调用stringLiteralOffset的地方,通过源码来实现逆向分析。
找完整个源码,发现只有一处调用了stringLiteralOffset

stringLiteralOffset源码调用.png (59.5 KB, 下载次数: 0)
下载附件
2025-3-2 17:20 上传
如何快速定位到这个地址呢?这个函数并没有什么字符串特征,所以并不好通过字符串实现快速定位
这里参考了这位大佬的分析思路,通过il2cpp::vm::String::NewLen来找到对应的函数
https://notion-blog-wine-gamma.vercel.app/article/genshin_analyze_1

il2cpp_string_new_len函数.png (27.23 KB, 下载次数: 0)
下载附件
2025-3-2 17:20 上传
查一下他的交叉引用

il2cpp_string_new_len交叉引用.png (96.82 KB, 下载次数: 0)
下载附件
2025-3-2 17:21 上传
一个个对比,最终定位到sub_16852F0

sub_16852F0-1.png (97.84 KB, 下载次数: 0)
下载附件
2025-3-2 17:21 上传

sub_16852F0-2.png (74.11 KB, 下载次数: 0)
下载附件
2025-3-2 17:21 上传
很好,它在调用stringLiteralOffset的时候肯定是进行解密了的,所以我们直接hook这个情况下的GlobalMetadataHeader。我尝试hook加载后的地址,遗憾的是,它并没有走这条路径,也就是说它自实现了一些解密和加载的函数,并没有选择调用原生函数,所以只能另寻出路了
这个时候其实已经很难分析了,因为它魔改了的话,对比源码已经没太大效果了。
后面我突然想到,他如果进行解密的话,肯定会访问GlobalMetadataHeader的地址,为什么不用监听内存试试呢?说干就干,我首先尝试使用frida的MemoryAccessMonitor来进行监听内存,发现还是hook不到,因为MemoryAccessMonitor原理是使用mprotect来禁止读写执行,进而触发异常被frida监听到,但是mprotect只能针对一整页的内存(大小为0x1000),数据量太大了,并不会有什么效果,所以又要换一种思路,想要单独监听一个内存地址,就只能使用调试器之类的软件了,例如GDB和LLDB,因为我之前并没有使用过这两个调试器,所以选择了我比较熟悉的pwatch,写了个frida脚本来配合pwatch
[JavaScript] 纯文本查看 复制代码function stop(){
var libtprtaddr = Module.findBaseAddress("libtprt.so");
var libil2cppaddr = Module.findBaseAddress("libil2cpp.so");
console.log("\n");
console.log("libtprt基址:",libtprtaddr);
console.log("libil2cpp基址:",libil2cppaddr);
var function_addr = libil2cppaddr.add(0x1684F68);
Interceptor.attach(function_addr,{
onEnter:function (args) {
console.log(`./arm_64 -t -b ${Process.getCurrentThreadId()} rw8 ${this.context.x0.add(0x100)}`)
console.log("开始暂停");
// 暂停当前线程 10 秒
const startTime = Date.now();
while (Date.now() - startTime
为什么hook 0x1684F68呢,因为这是在前面sub_1685100函数运行成功后的下一个地址,在刚加载完就进行hook,可以有效避免其他情况影响
frida打印为:

stop函数打印.png (20.9 KB, 下载次数: 0)
下载附件
2025-3-2 17:24 上传
pwatch打印为:

pwatch打印.png (100.65 KB, 下载次数: 0)
下载附件
2025-3-2 17:24 上传
距离tprt和il2cpp最近的地址是0x7e906848e8,减去libtprt的基址0x7e904c3000,得到0x1C18E8,直接去tprt里面看看

0x1C18E8处汇编.png (59.96 KB, 下载次数: 0)
下载附件
2025-3-2 17:25 上传
查看一下当前地址所在的函数sub_1C1884吧

sub_1C1884伪c.png (67.09 KB, 下载次数: 0)
下载附件
2025-3-2 17:25 上传
因为堆栈中显示的是lr寄存器,也就是调用的地址+4,所以可知读取stringLiteral的函数是sub_1BDB94,这样其实看伪c已经能看出来很多东西了,因为v5 + v7 + 8LL * a2这个结构,很类似于((const char*)s_GlobalMetadata + s_GlobalMetadataHeader->stringLiteralOffset) + index,进去sub_1BDB94里面看看

sub_1BDB94伪c.png (27.84 KB, 下载次数: 0)
下载附件
2025-3-2 17:26 上传
直接看跟返回值唯一有关的函数sub_1C1C48

sub_1C1C48伪c.png (18.95 KB, 下载次数: 0)
下载附件
2025-3-2 17:26 上传
终于找到解密点了,查看该函数,容易分析出参数1是加密的内容,参数2是长度,参数3是加密值,打印一下看看

sub_1C1C48参数.png (23.99 KB, 下载次数: 0)
下载附件
2025-3-2 17:27 上传
看来分析的没错,长度应该固定为8,前面解释过了,加密值怎么获取的呢?往上层分析,在sub_1BDB94中可以看到,加密值为v9 ^ a4,v9 = sub_9241C(v8, 0LL),a4则为sub_1BDB94的参数
先打印看看这两个是不是固定值,hook后发现v9为固定值,a4则为当前的偏移量,最后根据sub_1C1C20写一个相同的脚本就行了,解密出来后发现都恢复了

修复stringLiteralOffset.png (134.1 KB, 下载次数: 0)
下载附件
2025-3-2 17:27 上传
注意到sub_1C1884中,通过sub_1BDB94获取到v8后,在下面还进行了一处调用,通过对比源码,可以猜测下面的函数中包括stringLiteralData的解密函数,跟进去看了确实如此,同样写一个解密脚本进行还原即可
后记
这篇文章年前就准备写了,只是一直偷懒导致拖了许久。文章中写的都是我最开始尝试时用到的方法,其实还有很多地方可以进行优化,比如在定位解密函数时,是可以hook il2cpp_string_new_len这个导出函数通过打印堆栈来定位到的,当然,这个都是后话了,hook il2cpp_string_new_len并不如我原文中写的方法具体代表性,因为它完全可以自实现这个函数,只不过并没有罢了。文章写到这里其实是并没有完结的,此时使用il2cppdumper还是会报错,metadata里的数据并没有高熵了,那么有问题的地方应该就是il2cpp.so了,但是在写完这篇文章前,我已经没有在玩那个游戏了,耗费这个精力对我来说并不值得。如果评论区有知道的朋友,望不吝赐教