吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 4629|回复: 97
上一主题 下一主题
收起左侧

[Android 原创] 某手游il2cpp逆向分析----libtprt保护

  [复制链接]
跳转到指定楼层
楼主
无问且问 发表于 2025-3-2 17:32 回帖奖励
最近在玩个游戏,发现是由il2cpp进行打包的,就打算用il2cppdumper来dump看看游戏内容

开干
说干就干,提取游戏安装包,在lib/arm64-v8a路径提取出libil2cpp.so,在assets/bin/Data/Managed/Metadata路径提取出global-metadata.dat

直接打开il2cppdumper,选择这两个文件,发现报错:


那应该是有加密的,用010Editor打开global-metadata.dat文件,发现熵值很高,很明显的加密了


ok了,既然安装包中的global-metadata.dat被加密了,那我直接去内存中dump到的,应该就没问题吧!

既然要从内存中获取到global-metadata.dat,那肯定要根据libil2cpp.so中的逻辑来找出加载global-metadata.dat的地方,当然也可以通过在内存中搜寻魔数头的方式来找到文件头(ps:这个例子的魔数头也被抹除了,所以只能采取分析libil2cpp.so中的逻辑了@_@;)

事情果然没这么简单,当我用IDA打开libil2cpp.so后,发现libil2cpp.so也被加固了,导出表被抹除完了

并且我看到依赖库中包含libtprt.so


网上搜索得知,libtprt.so是属于某讯的加固,好吧,看来还是有难度的,继续分析吧!

既然安装包中的libil2cpp.so也被加固了,那也只能去内存中拿了,写了一个frida脚本去获取libil2cpp.so:

[JavaScript] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
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
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打开,发现报错


使用SoFixer工具进行修复,然后再次通过ida打开,发现导出表都正常了,终于迈出万里长征的第一步了!


想要找到global-metadata.dat的内存地址,则需要通过将ida分析出的反汇编代码和unity il2cpp的源码进行对比来快速得到结果,通过分析源码发现,加载metadata会使用一个字符串global-metadata.dat


尝试在ida中搜索这个字符串


通过交叉引用获取到它的使用地址(图片中的变量名是我重命名后的,并不是原版)


F5进行反汇编分析(图片中的变量名是我重命名后的,并不是原版)


发现sub_1685100和源码中LoadMetadataFile的作用很相近,直接跟进去看看


继续跟sub_2F0


F5处理有问题,没关系,继续跟下去吧


跟到最后发现原来是调用了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导出函数


根据它的地址,偏移0x128后看看


进去看看


继续跟进去


F5看下伪c吧


这个函数大致流程就是先调用偏移为0x277DA0处的函数指针,然后根据这个函数的返回值进行if分支,了解global-metadata.dat的朋友应该知道,正常的魔数头就是AF1BB1FA,这说明0x277DA0处的函数应该就是加载metadata的函数,不然后面应该是不会判断这个魔数的,当然话不可以说的这么满,还是继续看后续代码吧,else里面是两个函数调用,大致功能为先调用sub_1BDC9C来获取需要调用的函数,然后将函数指针传递给v5,最后调用v5里存储的函数

函数大致流程分析的差不多了,先去看看0x277DA0处的函数指针吧


可以看到0x277DA0属于bss段,这是一个存储未初始化的全局和静态变量的段,查询交叉引用也没有其余调用,那么静态分析行不通,就只能通过动态分析了

写了一个frida脚本去获取0x277DA0处的函数指针,考虑到不知道它什么时候完成初始化,所以我们直接在调用sub_1BCE3C的时候才进行获取指针内容

[JavaScript] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
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
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();


运行后查看打印情况


可以明显看到,0x277DA0处的函数指针并不是libtprt内的函数,而是libil2cpp中的,将得到的地址减去libil2cpp的基址,得到0x7281F04,去ida中查看



数据并没有解析出来,我们按"C"键来将其主动转化成汇编


可以看到他跳转了一个函数,进去跟进去吧


可以看到有一个明显的Metadata字符串,这和源码中的LoadMetadataFile函数很类似


继续往下看,发现还有类似的字符串,如"ERROR: Could not open %s"


那看来函数应该是找对了,继续对照着看,发现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_16DCB14只调用了三个函数,我们分别对着三个函数进行分析


很明显,sub_16EC43C只是一个计算长度的,直接跳过


同样的,通过sub_165A548的返回值也能看出来并不是主要函数
那就只能是sub_165A6FC了,跟进去看看


F5分析的有问题,直接看汇编吧


果然有问题,BL指令调用完全后是会执行后续指令的,这是带LR寄存器的跳转,所以后续的那个函数也应该包含在sub_165A6FC函数里面,直接去看0x165A740+4,也就是0x165A744处的函数实现


提示栈有问题,不用管,能分析出来就行,查看逻辑,发现返回的result只与sub_F1E0B0有关,那行,跟进去看看


继续跟






又看到了熟悉的g_tprt_pfn_array_ptr_0,继续去libtprt里面去找吧,不过这次的偏移量是0xA0



跟进去,是个B跳转,继续跟,看到了一个函数





我们注意到函数内有几个判断值的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] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
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
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吧


可以看到,文件头是被抹除了的,但是基本上的内容都还在,我们用UnityMetadata.bt模板跑一遍看看


是报错了的,看来内存dump出来的还是有问题,然后我hook了最开始的sub_1684EF0函数,看看会不会在中途继续解密,结果是没有,最后返回的内容还是和之前hook的一样的

继续分析吧,我们看看Il2CppGlobalMetadataHeader是什么样子


可以看到,除了文件头的四个魔数被抹除了之外,其余的信息是全的,那么问题出在哪里呢,通过Il2CppGlobalMetadataHeader的内容我们可以看到,stringLiteralOffset的值为256,即0x100,那么表示文件内容是从0x100开始的,我们查看0x100处的内容,通过与正常的global-metadata.dat文件进行对比,可以确认这里肯定存在加密(因为正常的global-metadata.dat 0x104处的值必须为0)

那怎么办呢?我想到了查看源码,看看源码中有没有调用stringLiteralOffset的地方,通过源码来实现逆向分析。
找完整个源码,发现只有一处调用了stringLiteralOffset



如何快速定位到这个地址呢?这个函数并没有什么字符串特征,所以并不好通过字符串实现快速定位

这里参考了这位大佬的分析思路,通过il2cpp::vm::String::NewLen来找到对应的函数
https://notion-blog-wine-gamma.vercel.app/article/genshin_analyze_1



查一下他的交叉引用


一个个对比,最终定位到sub_16852F0





很好,它在调用stringLiteralOffset的时候肯定是进行解密了的,所以我们直接hook这个情况下的GlobalMetadataHeader。我尝试hook加载后的地址,遗憾的是,它并没有走这条路径,也就是说它自实现了一些解密和加载的函数,并没有选择调用原生函数,所以只能另寻出路了

这个时候其实已经很难分析了,因为它魔改了的话,对比源码已经没太大效果了。

后面我突然想到,他如果进行解密的话,肯定会访问GlobalMetadataHeader的地址,为什么不用监听内存试试呢?说干就干,我首先尝试使用frida的MemoryAccessMonitor来进行监听内存,发现还是hook不到,因为MemoryAccessMonitor原理是使用mprotect来禁止读写执行,进而触发异常被frida监听到,但是mprotect只能针对一整页的内存(大小为0x1000),数据量太大了,并不会有什么效果,所以又要换一种思路,想要单独监听一个内存地址,就只能使用调试器之类的软件了,例如GDB和LLDB,因为我之前并没有使用过这两个调试器,所以选择了我比较熟悉的pwatch,写了个frida脚本来配合pwatch

[JavaScript] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 < 10000) {
 
            }
 
            console.log("恢复线程");
        },
        onLeave:function (returnValue) {
             
        }
    })
}


为什么hook 0x1684F68呢,因为这是在前面sub_1685100函数运行成功后的下一个地址,在刚加载完就进行hook,可以有效避免其他情况影响

frida打印为:


pwatch打印为:


距离tprt和il2cpp最近的地址是0x7e906848e8,减去libtprt的基址0x7e904c3000,得到0x1C18E8,直接去tprt里面看看


查看一下当前地址所在的函数sub_1C1884吧


因为堆栈中显示的是lr寄存器,也就是调用的地址+4,所以可知读取stringLiteral的函数是sub_1BDB94,这样其实看伪c已经能看出来很多东西了,因为v5 + v7 + 8LL * a2这个结构,很类似于((const char*)s_GlobalMetadata + s_GlobalMetadataHeader->stringLiteralOffset) + index,进去sub_1BDB94里面看看



直接看跟返回值唯一有关的函数sub_1C1C48


终于找到解密点了,查看该函数,容易分析出参数1是加密的内容,参数2是长度,参数3是加密值,打印一下看看


看来分析的没错,长度应该固定为8,前面解释过了,加密值怎么获取的呢?往上层分析,在sub_1BDB94中可以看到,加密值为v9 ^ a4,v9 = sub_9241C(v8, 0LL),a4则为sub_1BDB94的参数

先打印看看这两个是不是固定值,hook后发现v9为固定值,a4则为当前的偏移量,最后根据sub_1C1C20写一个相同的脚本就行了,解密出来后发现都恢复了



注意到sub_1C1884中,通过sub_1BDB94获取到v8后,在下面还进行了一处调用,通过对比源码,可以猜测下面的函数中包括stringLiteralData的解密函数,跟进去看了确实如此,同样写一个解密脚本进行还原即可


后记

这篇文章年前就准备写了,只是一直偷懒导致拖了许久。文章中写的都是我最开始尝试时用到的方法,其实还有很多地方可以进行优化,比如在定位解密函数时,是可以hook il2cpp_string_new_len这个导出函数通过打印堆栈来定位到的,当然,这个都是后话了,hook il2cpp_string_new_len并不如我原文中写的方法具体代表性,因为它完全可以自实现这个函数,只不过并没有罢了。文章写到这里其实是并没有完结的,此时使用il2cppdumper还是会报错,metadata里的数据并没有高熵了,那么有问题的地方应该就是il2cpp.so了,但是在写完这篇文章前,我已经没有在玩那个游戏了,耗费这个精力对我来说并不值得。如果评论区有知道的朋友,望不吝赐教

免费评分

参与人数 33吾爱币 +33 热心值 +29 收起 理由
zipkey + 1 + 1 谢谢@Thanks!
nullsci + 1 谢谢@Thanks!
安尼大大 + 1 + 1 我很赞同!
海水很咸 + 1 + 1 我很赞同!
RF52PJ + 1 用心讨论,共获提升!
Sydyanlei0 + 1 + 1 用心讨论,共获提升!
zklkk + 1 + 1 我很赞同!
chukr11 + 1 + 1 鼓励转贴优秀软件安全工具和文档!
zbsex + 1 我很赞同!
devil2334 + 1 用心讨论,共获提升!
Jerry187 + 1 用心讨论,共获提升!
freeeeeG + 1 + 1 用心讨论,共获提升!
InfiniteBoy + 1 + 1 用心讨论,共获提升!
ww5500231 + 1 谢谢@Thanks!
SanCaiOjisang + 1 用心讨论,共获提升!
huanghui9969 + 1 + 1 我很赞同!
LinJue22 + 1 + 1 我很赞同!
RikimaruMarlon + 1 + 1 厉害~~~
1amfree + 1 + 1 我很赞同!
sujifei + 1 + 1 我很赞同!
lst13145920 + 1 + 1 谢谢@Thanks!
CrazyNut + 3 + 1 膜拜大佬
jackyyue_cn + 1 + 1 用心讨论,共获提升!
umbella + 1 + 1 用心讨论,共获提升!
流年 + 1 + 1 用心讨论,共获提升!
Weirdo + 1 + 1 谢谢@Thanks!
Yao2903 + 1 + 1 谢谢@Thanks!
A_DUST + 1 + 1 谢谢@Thanks!
allspark + 1 + 1 用心讨论,共获提升!
Minesa + 1 + 1 用心讨论,共获提升!
helian147 + 1 + 1 热心回复!
ngiokweng + 2 + 1 谢谢@Thanks!
iamshy520 + 1 + 1 用心讨论,共获提升!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
tugang0731 发表于 2025-3-25 14:21
在使用010Editor查看文件熵值时,可以通过以下几种方式具体判断熵值是否很高:

1. 打开010Editor,加载需要分析的文件(如global-metadata.dat)。
在菜单栏中选择“分析”(Analysis)选项,然后选择“熵值分析”(Entropy Analysis)。
软件会生成一个熵值图表,同时会显示文件的整体熵值。
熵值的范围和含义:
熵值范围:熵值的取值范围是0到8(对于二进制文件)。0表示数据完全有序,8表示数据完全随机。
高熵值的判断:
如果熵值接近8(例如7.5以上),则表明文件数据高度随机,很可能经过加密或压缩。
如果熵值较低(例如低于3),则表明文件数据具有一定的规律性,通常是未加密的普通文件。
2. 熵值图表
010Editor生成的熵值图表可以直观地显示文件不同区域的熵值变化:

图表显示:熵值图表通常是一个折线图或柱状图,横轴表示文件的偏移量(位置),纵轴表示熵值。
高熵值区域:
如果图表中大部分区域的熵值都很高(接近8),则表明文件整体是高度随机的。
如果图表中某些区域熵值较低,而其他区域熵值很高,可能表明文件中部分数据未加密,而部分数据经过加密。
3. 对比未加密文件的熵值
为了更准确地判断熵值是否很高,可以对比已知未加密的类似文件的熵值:

找到一个未加密的同类型文件(例如另一个游戏的global-metadata.dat文件)。
使用010Editor对未加密文件进行熵值分析。
对比两个文件的熵值:
如果加密文件的熵值明显高于未加密文件(例如未加密文件熵值为3,加密文件熵值为7.5),则可以判断加密文件的熵值很高。
推荐
 楼主| 无问且问 发表于 2025-3-19 19:48 |楼主
jy3399 发表于 2025-3-18 22:18
有时候真好奇大佬们的脑袋怎么长的,同样都吃了这么多年饭,感觉自己跟个白痴一样!

我还是建议直接实战的,但是难度要一点一点提上来,不能好高鹭远,建议先往java层的方向实战,然后再往native层进军,有不懂的可以问AI,但是思路是要自己实践出来的,AI大部分情况只能用作分析,最好把AI的分析多琢磨琢磨,然后形成一套具体的思路
沙发
mscsky 发表于 2025-3-4 09:22
3#
dengchang 发表于 2025-3-4 09:34
支持,学习下
4#
Poorwood 发表于 2025-3-4 10:41
一旦这种对抗起来,就很耗时间了。
5#
.KK 发表于 2025-3-4 11:21
牛的 分析流程很细节~
6#
jackyyue_cn 发表于 2025-3-4 12:48
静态动态手段都用上了值得学习

加密保护就是各种混淆、隐藏
调试分析又得各种猜测、还原

唉 程序员何苦要为难程序员
7#
a527573171 发表于 2025-3-4 15:57

牛的 分析流程很细节~
8#
 楼主| 无问且问 发表于 2025-3-4 17:09 |楼主
mscsky 发表于 2025-3-4 09:22
il2cppdumper更新到最新版了吗

是最新版的
9#
jianguo85 发表于 2025-3-4 18:01


牛的 分析流程很细节~
10#
AIRForce 发表于 2025-3-4 18:31
逆向大佬
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2025-4-22 08:48

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表