IL2CPP 逆向
Preface
il2cpp 将游戏 C# 代码转换为 C++ 代码,然后编译为各平台 Native 代码。
虽然游戏逻辑是以 Native 代码运行, 但依然要实现 C# 某些语言特性(如GC、反射),il2cpp将所有的 C# 中的类名、方法名、属性名、字符串等地址信息记录在 global-metadata.dat 文件。
il2cpp启动时会从这个文件读取所需要的类名、方法名、属性名、字符串等地址信息。
Unity 使用 Mono 方式打包出来的 apk,那么此类逆向我们需要先解压apk,然后利用 IL2CPPDumper 来获取主逻辑代码符号
libil2cpp.so与global-metadata.dat
想要利用上述工具,首先是要拿到这两个文件
Android
源\lib\armeabi-v8a\libil2cpp.so
源\assets\bin\Data\Managed\Metadata\global-metadata.dat
PC
UnityPlayer.dll
随后放入 input 目录
点击该.dat即可
随后我们去 output 目录查看
dump.cs
这个文件会把 C# 的 dll 代码的类、方法、字段列出来
IL2cpp.h
生成的 cpp 头文件,从头文件里可以看到相关的数据结构
script.json
以 json 格式显示类的方法信息
stringliteral.json
以 json 的格式显示所有字符串信息
DummyDll
进入该目录,可以看到很多dll,其中就有 Assembly-CSharp.dll 和我们刚刚的 dump.cs 内容是一致的
IDA TIME
当进行 IL2CPP 打包时,选择 CPU 架构可以选择 ARMv7 和 ARM64,所以相对应我们在 apk 解压后所看到的就是 arm64-v8a 和 armeabi-v7a,简单来说就是 64位 和 32位
随后放入相对应的 IDA,再按 ALT + F7 选择该文件
再选该文件
再导入头文件
经过漫长的等待就成功恢复符号了
Finding loaders for obfuscated global-metadata.dat files
https://katyscode.wordpress.com/2021/02/23/il2cpp-finding-obfuscated-global-metadata/
然而刚刚那标准的一套用 IL2CPPDumper 的套路并不完全适用,很多厂商都采取了对抗措施,接下来讲解混淆 global-metadata.dat 的思路
How do I know if global-metadata.dat is obfuscated?
当拿到我们的 metadata 文件后,放入查找十六进制的文件,如果开头魔术字依然是 AF 1B B1 FA,那么一般来说是没有被混淆的
那如果不是这个魔术字,一般来说也是被混淆了,而就像平常的逆向一样,程序运行起来肯定是会解密数据然后使用,这个思路同样使用于这,程序运行起来直接搜索该魔术字即可拿到 针对运行时解密的混淆手段
1 | function frida_Memory(pattern) |
Metadata loader code path
然而也有几种情况是上述方法解决不了的,我们得了解一下 metadata 的加载过程,因为混淆手段就有可能混入其中
而加载的调用链如下
1 | il2cpp_init |
而在我们逆向中,这些都是不带符号的,然而我们可以对着源码来找到相对应的函数(不同版本的源码有一些差别)
il2cpp_init (located in il2cpp-api.cpp, comments elided):
1 | int il2cpp_init(const char* domain_name) |
il2cpp::vm::Runtime::Init (located in vm/Runtime.cpp):
1 | bool Runtime::Init(const char* filename, const char *runtime_version) |
il2cpp::vm::MetadataCache::Initialize (located in vm/MetadataCache.cpp, comments elided):
1 | bool il2cpp::vm::MetadataCache::Initialize() |
il2cpp::vm::MetadataLoader::LoadMetadataFile (located in vm/MetadataLoader.cpp):
1 | void* il2cpp::vm::MetadataLoader::LoadMetadataFile(const char* fileName) |
有了这些源码,我们只需要在 IDA 中对照他们识别出相对应的函数,而关键是后两个函数,分别是
il2cpp::vm::MetadataLoader::LoadMetadataFile 该函数将 metadata 的文件名文件映射入内存
il2cpp::vm::MetadataCache::Initialize 该函数将映射文件的指针存储在全局变量中,然后开始从这变量读取数据结构
所然!解混淆或是解密代码一般都在这两个函数里,我们只需要样本代码与源码对比差别即可。
特别指出在引用文中的一个例子,现在看一个 IDA 中的例子
1 | char il2cpp::vm::MetadataCache::Initialize() |
对比下源码不难发现 sub_180261550 就是 il2cpp::vm::MetadataLoader::LoadMetadataFromFile
需要注意的是这个 v0 指针十分重要,因为解混淆或是解密都会调用到这个指针(一般是在加载前解密,而不是在加载后解密,这样会导致未解密的数据残余在内存中影响性能),通常来说应用程序在访问 metadata 前执行解密,或是在 il2cpp: : vm: : MetadataLoader: : LoadMetadataFromFile 之前或之中。
Finding the metadata loader: What if there is no il2cpp_init?
那么以上套路是针对我们能在 il2cpp 里找到 il2cpp_init 的函数,但是如果没有,我们就要继续往上找 UnityPlayer.dll 或者 libunity.so,而这个我们没有办法用源码对照,不过我们可以创建一个 Unity 项目生成 PDB,就可以看到名字和符号那些。
对于这种情况我们主要关心 il2cpp_init.cpp 到底是哪里被引用与调用,那么未被混淆的流程如此
UnityMainImpl 有很多函数调用,不过我们可以通过字符串来寻找
1 | winutils::DisplayErrorMessagesAndQuit("Data folder not found"); |
LoadIl2Cpp 也同样可以字符串快速寻找
1 | v2 = 1; |
InitializeIl2CppFromMain
1 | 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) |
然而有相当不同的变化在不同的版本中,他们的共同特点是对 il2cpp_init 的调用和 IL2CPP Root Domain 与 unused_application_configuration,所以通过这些我们也同样快速找到(如果没有混淆的话)。
D3Mug
0x00 Daily Check Shell
IL2CPP逆向,无壳。
于是惯用套路查找 libil2cpp.so 和 global-metadata.dat 来恢复符号,metadata未被混淆,所以可以直接恢复。
接着可以配合 Assembly-CSharp.dll 或 dump.cs 来配合查看函数信息。
0x01 Track
尝试 hook IL2CPP 的函数,可是都不行,看别人博客都是模拟器跳这个,我真机跳这个是为什么,hook 未果
那么开始静态审计,既然是音游,通关调试肯定是全部按好即可,于是在 Assembly-CSharp.dll 很容易定位到 notehit notemiss 这些函数
1 | void __fastcall GameManager__NoteHit(GameManager_o *this, float preciseTime, int32_t level, const MethodInfo *method) |
共同点就是算好分数,就直接调用 GameManager__update,而该函数往里查找可以发现调用了 d3mug.so 文件里的 update 函数
那么可以得出结论每次点击到 preciseTime 就会正确 update,而该 update 函数会把值更新到 Server:instance 里
1 | __int64 __fastcall update(char note_bit) |
而之后的分数结算的函数 ScoreScene__Start 只是检测了 Server::instance 前缀是否位 D3CTF
那么怎么点才是 preciseTime 呢,而音游落下的方法并不是随即的,而是固定的,所以我们可以找一下加载这个数据的地方
既然有符号,看到可疑的直接看即可,于是发现
1 | void __fastcall BeatScroller__ParseBeatTampoMap( |
往上找,于是发现加载路径
于是利用 AssetStudio 来提取数据
于是我们大概审一下生成地图的函数,可知后面那个就是我们要的 preciseTime
1 | // local variable allocation has failed, the output may be wrong! |
0x02 Get Flag
于是总结一下
- 程序只有一个界面入口,也就是只有这一个地图,于是我们拿到 preciseTime 的数据
- 随后程序检测我们的 notehit 是否和 preciseTime 吻合
- 最后在 ScoreScene__Start 做了个检测是否正确解密为 D3CTF
所以其中一个解密思路就是我们主动调用每次 notehit 会调用的 update 函数,将 preciseTime 数据传入,最后再读取一下解密完的数据即可
于是用下 Frida 即可
1 | function main(){ |
1 | import frida |
Get Flag!
BabyUnity3d
0x00 Daily Check Shell
IL2CPP逆向,无壳。
但是这次 metadata 混淆了,可以发现一开始的魔术字的不是原来的样子了,于是两个思路
第一就是运行后内存中直接搜索魔术字
第二就是本文刚开始写的,对比源代码找加载 metadata 的部分既然加密了,在那也许就能找到解密的地方
0x01 Deobfuscate metadata
而该题没法直接在内存中直接搜索到原本的魔术字,于是采用第二种方法,再回忆下调用链
1 | il2cpp_init |
于是直接搜 init 即可找到 il2cpp::vm::Runtime::Init
随后进入该函数后一个个查找对比源码可以发现 sub_4B5564 为 il2cpp::vm::MetadataCache::Initialize
那么看该函数可以很明显发现,global-metadata.dat 字符串变成了其他字符串,但程序运行 sub_4B5518 会自动解密回去
于是对应着源码 sub_513060 就是 il2cpp::vm::MetadataLoader::LoadMetadataFile,返回一个指针完全吻合
那么解密的 metadata 的地方就只可能是 LoadMetadataFile 该函数了,于是将该函数与原函数对比说实话看不太出来到底哪里变了,当然我们也可以一个个点进去看看哪个比较可疑,当然还有个方法找个没被混淆的对比即可,如图
很明显 sub_512FDC 就是不一样的地方(刚刚调试了一下这几个符号全恢复了??是巧合吗)
1 | char *__fastcall sub_C409CFDC(int a1, size_t a2) |
写出解密脚本
1 | import struct |
恢复了之后,魔术字还是不对,不过很明显是人为写上去的魔术字了,直接改回原来的魔术字即可
0x02 Get Flag
那么恢复了就很简单了,直接定位到关键函数
发现只是个AES加密,密钥 IV直接交叉引用找赋值给 Check_TypeInfo 即可
1 | bool __fastcall Check__CheckFlag(Check_o *this, System_String_o *input, const MethodInfo *method) |
于是搓一个解密脚本(cyberChef的AES是真不行啊)
1 | from Crypto.Cipher import AES |
Get Flag!
Reference
https://katyscode.wordpress.com/2021/02/23/il2cpp-finding-obfuscated-global-metadata/
https://cloud.tencent.com/developer/article/2216959
About this Post
This post is written by P.Z, licensed under CC BY-NC 4.0.