专栏名称: 吾爱破解论坛
吾爱破解论坛致力于软件安全与病毒分析的前沿,丰富的技术版块交相辉映,由无数热衷于软件加密解密及反病毒爱好者共同维护,留给世界一抹值得百年回眸的惊艳,沉淀百年来计算机应用之精华与优雅,任岁月流转,低调而奢华的技术交流与探索却
目录
相关文章推荐
51好读  ›  专栏  ›  吾爱破解论坛

某手游il2cpp逆向分析----libtprt保护

吾爱破解论坛  · 公众号  · 互联网安全  · 2025-03-08 09:50

正文

请到「今天看啥」查看全文


作者 坛账号:无问且问
最近在玩个游戏,发现是由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:

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, {        onEnterfunction (args) {            var path = args[0].readCString();            if (path && path.indexOf('libil2cpp.so') !== -1) {                this.path = path;            }        },        onLeavefunction (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的时候才进行获取指针内容

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, {        onEnterfunction (args) {            var path = args[0].readCString();            if (path && path.indexOf('libil2cpp.so') !== -1) {                this.path = path;            }        },        onLeavefunction (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这个函数的返回值看看:
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, {        onEnterfunction (args) {            var path = args[0].readCString();            if (path && path.indexOf('libil2cpp.so') !== -1) {                this.path = path;            }        },        onLeavefunction (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

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了,但是在写完这篇文章前,我已经没有在玩那个游戏了,耗费这个精力对我来说并不值得。如果评论区有知道的朋友,望不吝赐教

-官方论坛

www.52pojie.cn


👆👆👆

公众号 设置“星标”, 不会错过 新的消息通知
开放注册、精华文章和周边活动 等公告

图片







请到「今天看啥」查看全文