书接某位 可爱女大(本文中的🐱)的上回 ,在凌晨四点的一个 Twitter Space 里面,我们开始研究 IL2CPP 的字符串这个大坑。
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 { uint32_t destinationIndex; 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 String_t* _stringLiteral3495210533; extern void ** const g_MetadataUsages[73910 ] = { (void **)(&_stringLiteral3495210533), }; 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, }; 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 个字节的重叠,说明文件头的大小并不正确:
在尝试将每一组 Offset
和Count
都当成 Metadata Usage Pairs 解析后,发现都不满足该结构的条件,于是先放弃处理元数据,看看可执行文件那边是什么情况。
该结构体的偏移量在有符号的时候可以直接拿到,没有符号的时候 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 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 ); 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 )
没毛病,但是 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 liefimport struct, jsonlib = 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
启动应用改成传入进程名称然后拼手速