1. 项目概述:为什么我们要关注il2cpp字符串加密?
在Unity游戏开发,尤其是移动端和PC端游戏发布时,很多开发者会选择使用il2cpp作为后端,将C#代码编译成C++,再编译为原生机器码。这么做的好处显而易见:提升运行效率、增加代码的混淆程度,从而在一定程度上保护知识产权。然而,对于安全研究人员、逆向工程师或者希望学习高级游戏机制的开发者来说,il2cpp编译后的二进制文件就成了一个必须攻克的堡垒。在这个堡垒里,字符串信息往往是第一个突破口。
游戏中的字符串包含了大量的关键信息:资源加载路径、网络通信地址、调试日志信息、UI文本、配置表键名,甚至是核心逻辑的判断条件。如果这些字符串以明文形式存在于最终的二进制文件中,那么使用简单的字符串搜索工具就能轻易定位到关键函数和逻辑点,大大降低了逆向分析的难度。因此,对字符串进行加密,就成了游戏加固方案中一项基础且至关重要的措施。
“Unity il2cpp字符串加密算法识别”这个主题,正是站在逆向分析的角度,去拆解和应对这种保护措施。我们的目标不是去破解某个具体的游戏,而是掌握一套方法论:当面对一个经过加固的Unity游戏时,如何快速识别其使用的字符串加密方式,并找到相应的解密方法,从而还原出可读的字符串信息,为后续的深入分析铺平道路。这就像侦探办案,加密的字符串是上锁的日记本,我们的任务就是识别锁的类型并找到钥匙。
2. 常见字符串加密方式深度解析
在Unity il2cpp的世界里,字符串加密的实现方式五花八门,但归根结底,其核心思想都是在编译或打包阶段,对C#源代码中的字符串常量进行变换,然后在运行时(即游戏执行时)通过一个特定的解密函数进行还原。下面我们来拆解几种最常见、最具代表性的加密模式。
2.1 异或(XOR)加密:简单高效的“入门款”
异或加密因其实现简单、运算速度快,成为最基础的字符串加密手段,常见于一些自定义的、轻量级的保护方案中。
核心原理: 异或运算有一个非常有趣的特性:A XOR B XOR B = A。这意味着,如果你用一个密钥(Key)对原始字符串的每个字节进行异或操作得到密文,那么再用同一个密钥对密文异或一次,就能得到原始字符串。在实现上,密钥可以是一个固定的字节(如0xAA),也可以是一个字节数组,甚至是一个根据字符串索引变化的动态值。
il2cpp中的典型实现: 开发者通常会编写一个静态的C#方法,例如DecryptString(string encrypted)。在il2cpp编译后,这个函数会变成C++代码。原始的字符串常量在C#代码中就已经被预先异或加密了,变成了一个字节数组。运行时,DecryptString函数接收这个字节数组,进行异或运算后,返回System.String对象。
识别特征:
- 静态解密函数:在逆向工具(如IDA, Ghidra, Il2CppDumper配合的二进制分析)中,你会找到一个函数,其内部逻辑主要是循环和异或操作。
- 固定的密钥模式:函数里通常有一个明显的常量作为密钥。在汇编或反编译的C代码中,你可能会看到类似
byte ^ 0x8F这样的指令。 - 字符串数据形态:在二进制文件的只读数据段(如
.rodata),原本应该是可读ASCII/UTF-8字符串的地方,变成了一堆看似随机的、不可打印的字节序列。
注意:单纯的单字节异或加密非常脆弱,通过频率分析或已知明文攻击很容易破解。因此,稍复杂的方案会使用长密钥或与索引进行运算。
2.2 Base64变种与自定义编码:伪装与混淆
严格来说,Base64是一种编码而非加密,但其结果不可直接阅读,因此常被用作一种简单的“加密”手段,或者作为其他加密算法输出结果的再包装。
核心原理: Base64将二进制数据(如加密后的字节数组)转换成由64个字符(A-Z, a-z, 0-9, +, /)组成的字符串。在il2cpp字符串保护中,流程通常是:原始字符串 -> 某种加密(如XOR)-> 字节数组 -> Base64编码 -> 存储在二进制中。运行时则反向操作:读取Base64字符串 -> 解码为字节数组 -> 解密 -> 得到原字符串。
变种与混淆: 为了增加识别难度,开发者可能会:
- 自定义码表:不使用标准的Base64码表,而是打乱顺序或完全自定义一套64字符的映射表。
- 插入干扰字符:在Base64字符串中定期插入无意义的字符(如
-,_),需要在解码前过滤。 - 多重变换:先进行Base64解码,得到的结果可能还需要再进行一次不同的解密操作。
识别特征:
- 字符串形态:数据段中存在大量仅由Base64字符集构成的字符串,长度通常是4的倍数。
- 存在解码函数:在代码中能找到调用
System.Convert.FromBase64String或功能类似的自定义解码函数的引用。 - 组合模式:常与一个解密函数(如XOR)成对出现,先解码再解密,或者先解密再解码。
2.3 流加密与块加密的轻量级应用
对于安全性要求更高的场景,开发者可能会引入更标准的加密算法,如RC4(流加密)或AES(块加密)。在il2cpp环境下,通常使用的是其简化模式或ECB(电子密码本)模式,因为CBC等模式需要初始化向量(IV),管理起来更复杂。
RC4(ARCFOUR)流加密: RC4的特点是算法简单、速度快。在字符串加密中,通常会用一个固定的密钥初始化RC4的S盒,然后利用这个S盒对字符串字节流进行加密/解密。
识别特征:
- S盒初始化:在解密函数开头,会有一个256字节的数组(S盒)的初始化过程,包含一个密钥调度算法(KSA),代码中会有明显的循环
for (i = 0; i < 256; ++i)。 - 伪随机生成算法(PRGA):初始化后,会有另一个循环,根据S盒生成密钥流,并与密文字节进行异或。虽然核心操作还是异或,但其密钥流是随机的。
- 固定密钥:密钥通常硬编码在函数中。
AES加密: 在il2cpp中实现完整的AES(如AES-128)相对较重,但确实存在。更常见的是使用AES的S盒进行一种简单的查表混淆,或者实现一个简化版的AES-ECB。
识别特征:
- 查找表(Look-up Tables):代码中会定义几个大的常量数组(通常是256字节),这就是AES的S盒和逆S盒。这是最明显的标志。
- 轮函数结构:如果实现了完整AES,你会看到
SubBytes,ShiftRows,MixColumns,AddRoundKey等步骤的循环。但在字符串加密中,很可能只进行1-2轮简化操作。 - 数据块处理:由于字符串长度不定,使用AES时可能需要填充(如PKCS#7)。你会看到处理数据块(16字节)的循环。
实操心得:在实际的Unity游戏逆向中,遇到完整标准AES加密字符串的情况并不多。更多时候,开发者只是借鉴了这些加密算法的某些部件(如S盒)来增加混淆的复杂性,而非实现一个完整的加密/解密流程。识别出这些部件,就能猜到其加密思路。
2.4 字符串池(String Pool)与动态解密
这是一种更偏向于工程化的保护方式,而不仅仅是密码学上的加密。
核心原理: 游戏在启动时或某个模块初始化时,集中解密一大批字符串,将它们放入一个全局的字典或列表中(即“字符串池”)。之后,程序其他地方需要使用字符串时,不再直接使用字符串常量,而是通过一个索引(ID)或哈希值(Hash)到这个池子里去取。这些索引/哈希值在il2cpp二进制中就是一些普通的整型常量,对字符串搜索完全免疫。
识别特征:
- 缺失的字符串:用字符串搜索工具扫不到任何有价值的UI文本或路径。
- 大量的整型常量:在引用字符串的地方,看到的不是字符串地址,而是一些数字,比如
getStringFromPool(0x1234)。 - 初始化函数:存在一个明显的初始化函数,内部有循环,调用了大量的解密例程,并将结果存储到某个全局结构体中。
- 哈希函数:如果使用哈希值(如FNV-1a, MurmurHash)作为键,在代码中能找到对应的哈希计算函数。
分析方法: 这种方法的关键在于找到那个“字符串池”的全局变量和初始化函数。一旦定位,就可以在内存dump时,在初始化完成后直接提取这个池子,或者动态调试时在这个池子里下访问断点。
3. 逆向分析实战:方法论与工具链
识别加密方式不能只靠猜,需要一套系统的分析方法。下面结合静态分析和动态调试,介绍一套实用的流程。
3.1 静态分析:从入口点顺藤摸瓜
静态分析是第一步,目标是在不运行游戏的情况下,通过反汇编工具了解程序结构,找到可疑的加密/解密函数。
工具准备:
- Il2CppDumper:这是分析il2cpp游戏的基石。它需要两个输入:游戏二进制文件(如
GameAssembly.dll或libil2cpp.so)和对应的全局元数据文件(global-metadata.dat)。它的作用是恢复函数名、方法签名、字符串(未加密的部分)等符号信息,为逆向工具生成一个映射文件(如IDA.py脚本)。 - IDA Pro 或 Ghidra:专业的反汇编和静态分析工具。加载游戏二进制,然后运行Il2CppDumper生成的脚本,恢复符号。这样,很多函数就会有有意义的名称,例如
StringLiteral::Decrypt。 - Strings 查找工具:使用
strings命令或IDA的字符串窗口进行初步扫描,评估字符串加密的强度。如果还能看到大量明文路径、UI文本,说明保护很弱或没有;如果都是乱码,说明有加密。
分析步骤:
- 定位字符串引用:在IDA中,即使字符串被加密,其数据地址仍然会被代码引用。你可以搜索对数据段(如
.rodata)的交叉引用。找到一处引用后,查看使用它的函数。 - 分析引用函数:进入引用加密字符串的函数。观察其逻辑:
- 如果函数很简单,就是一个循环异或,那很可能就是解密函数。
- 如果函数调用了另一个函数,就跟进去。
- 注意函数开头和结尾的字符串操作,比如可能调用
il2cpp_string_new或类似的内存分配函数来创建新的字符串对象,其参数就是解密后的字符指针。
- 识别模式:在多个不同的解密调用点之间跳转,看看它们是否调用同一个函数。如果是,那么这个函数就是全局的解密入口。分析这个入口函数的参数和内部实现,就能确定加密方式。
- 查找初始化逻辑:如果怀疑是字符串池模式,可以搜索在游戏早期(如
UnityPlayer初始化、第一个场景加载时)调用的,内部有大型循环的函数。
3.2 动态调试:在运行时抓取明文
静态分析可能遇到混淆或复杂的逻辑,此时动态调试就是终极武器。目标是让游戏运行起来,并在解密函数执行后,直接从内存中获取明文字符串。
工具准备:
- 调试器:Android平台常用
IDA Pro或Ghidra附加到进程,iOS平台可用LLDB或IDA Pro。 - Frida:一款强大的动态插桩框架,尤其适合Hook函数、打印参数和返回值。对于il2cpp游戏,有
frida-il2cpp-bridge这样的神器,可以直接基于il2cpp的运行时信息进行Hook,无需复杂的地址计算。 - 游戏内存Dump工具:在合适的时机(如字符串池初始化后)dump整个游戏进程的内存,然后用工具扫描其中的字符串。
实战流程(以Frida为例): 假设通过静态分析,我们怀疑解密函数名为StringDecryptor::Decrypt。
Hook解密函数:
// frida-il2cpp-bridge 示例脚本 Il2Cpp.perform(() => { const StringDecryptor = Il2Cpp.Domain.assembly("Assembly-CSharp").image.class("StringDecryptor"); const DecryptMethod = StringDecryptor.method("Decrypt"); // 可能需要重载参数 Interceptor.attach(DecryptMethod.implementation, { onEnter: function(args) { // args[0] 可能是一个加密字符串的字节数组指针 this.encryptedPtr = args[0]; console.log(`[Enter Decrypt] Arg0: ${this.encryptedPtr}`); }, onLeave: function(retval) { // retval 是解密后的 il2cpp String 对象 const decryptedString = Il2Cpp.String.fromPointer(retval).content; console.log(`[Leave Decrypt] Result: ${decryptedString}`); // 可以将结果保存到文件 } }); });运行这个脚本,游戏运行时所有对该解密函数的调用,其输入和输出都会打印出来。这是最直接有效的方法。
下断点分析:如果Hook不成功或想深入了解过程,可以用调试器在解密函数入口或
il2cpp_string_new处下断点。当断点命中时,检查寄存器和栈内存,找到解密后的字符缓冲区。内存扫描:在游戏主界面加载完成后(此时大部分字符串应已解密),使用调试器或外部工具dump进程内存。然后用十六进制编辑器或编程方式,搜索常见的字符串模式或中文字符编码(如UTF-8、UTF-16LE),往往能直接扫出大量明文。
3.3 自动化与模式匹配
对于经常分析不同游戏的安全研究人员,可以尝试将经验自动化。
- 特征码扫描:为常见的加密模式编写特征码(YARA规则或简单的字节模式)。例如,识别RC4的S盒初始化循环,识别XOR循环等。在二进制文件中扫描这些特征,快速定位可疑函数。
- 模拟执行:如果解密函数是纯算法、无外部依赖,可以将其代码片段提取出来,用
Unicorn或Qiling这样的模拟执行框架来运行,直接对密文进行解密测试。 - 利用Il2CppDumper的扩展:Il2CppDumper本身在尝试恢复字符串时,会调用一些简单的解密算法。可以研究其源码,了解其模式,并尝试为其添加新的解密算法识别模块。
4. 实战案例拆解:从混淆到清晰
让我们虚构一个案例,模拟分析一个名为“幻想纪元”的Unity手游。用Il2CppDumper处理其libil2cpp.so后,导入IDA,发现大部分函数名已恢复。
第一步:字符串扫描使用IDA的字符串视图,发现可读字符串极少,大部分是0x8F 0xA2 0x15 ...这样的字节序列。初步判断字符串被加密。
第二步:交叉引用追踪随机选择一个加密数据地址,查看谁引用了它。跳转到引用函数sub_123456,发现其代码类似:
void *__fastcall sub_123456(const char *encryptedData) { size_t len = strlen(encryptedData); char *decryptedBuf = (char *)malloc(len + 1); for (int i = 0; i < len; ++i) { decryptedBuf[i] = encryptedData[i] ^ 0x8F; // 固定密钥0x8F的XOR } decryptedBuf[len] = 0; return il2cpp_string_new(decryptedBuf); }这明显是一个简单的单字节XOR解密函数。
第三步:验证与批量解密
- 验证:在动态调试中,在此函数返回前(
il2cpp_string_new调用后)下断点,查看decryptedBuf的内容,确认是正常的游戏字符串(如“开始游戏”、“物品栏”)。 - 定位所有调用:在IDA中查看
sub_123456的被调用列表,发现它有上百处引用。这说明游戏统一使用这个函数解密字符串。 - 编写解密脚本:既然算法如此简单,我们可以直接编写一个Python脚本,从
libil2cpp.so的.rodata段中提取所有可能是加密字符串的数据(例如,长度大于2、以00结尾的连续非零字节段),然后用0x8F异或解密,输出可读结果。
运行这个脚本,就能批量获得游戏中的明文字符串。import idc, idautils, idaapi import re def xor_decrypt(data, key=0x8F): return bytes([b ^ key for b in data]) for seg in idautils.Segments(): seg_name = idc.get_segm_name(seg) if seg_name in ['.rodata', '.data.rel.ro']: # 常见的数据段 start = idc.get_segm_start(seg) end = idc.get_segm_end(seg) current = start while current < end: # 尝试读取以00结尾的连续非零字节块 encrypted = bytearray() while current < end: b = idc.get_wide_byte(current) if b == 0: if len(encrypted) > 2: # 假设长度大于2的才有意义 try: decrypted = xor_decrypt(encrypted) # 简单判断是否为可打印字符串(可根据需要调整) if all(32 <= c < 127 for c in decrypted): print(f"Addr: {hex(start + (current - len(encrypted)))}, Decrypted: {decrypted.decode('ascii')}") except: pass encrypted = bytearray() current += 1 break else: encrypted.append(b) current += 1 else: current += 1
第四步:应对变种如果发现解密函数不是固定的0x8F,而是key = 0x8F - i(即密钥随索引变化),那么只需修改解密脚本中的算法即可。关键在于通过静态分析和动态调试,精确还原出这个算法。
5. 疑难排查与进阶对抗
在实际分析中,事情往往不会这么顺利。以下是可能遇到的挑战及应对策略。
5.1 加密函数被混淆或内联
问题:静态分析时,找不到一个独立的、清晰的Decrypt函数。解密代码可能被编译器优化内联到了每一个调用点,或者被控制流扁平化等混淆技术打乱。
解决思路:
- 寻找共性:即使被内联,每个调用点的解密算法逻辑应该是相似的。对比多个调用点的汇编代码,寻找相同的指令模式(如相同的异或操作码、相同的常量加载)。
- 动态Hook通用API:如果解密后最终都要创建字符串,可以Hook
il2cpp_string_new或底层的内存分配函数。在Hook中,向上回溯调用栈,找到传入的缓冲区指针,从而捕获解密后的字符串内容。这绕过了对具体解密函数的依赖。 - 使用Frida Stalker:Frida的Stalker可以跟踪代码执行流程。可以在一个已知会触发解密的操作(如打开某个UI)前后开启跟踪,分析执行了哪些指令,从中识别出解密逻辑。
5.2 字符串与元数据双重加密
问题:不仅字符串内容被加密,连global-metadata.dat文件也被加密或混淆,导致Il2CppDumper无法正常工作,无法恢复函数名和符号。
解决思路:
- 修复元数据:首先需要处理元数据。这可能需要对
global-metadata.dat的文件格式进行逆向,找到其加密或混淆的方式(有时只是简单的XOR或zlib压缩后的变形)。可以尝试用十六进制编辑器查看文件头,对比正常元数据的魔数,或者搜索已知的模式。 - 绕过元数据:如果元数据无法修复,就需要进行纯粹的二进制分析。这难度极大。可以尝试:
- 寻找il2cpp运行时函数:
il2cpp运行时有很多导出函数(如il2cpp_init,il2cpp_runtime_invoke)。通过分析这些函数的交叉引用,可以定位到用户代码的大致区域。 - 字符串引用模式:即使字符串被加密,其引用模式(函数开头加载地址)仍然存在。通过模式识别,可以定位到大量相似的函数片段,这些可能就是各个不同的函数入口。
- 动态分析优先:在这种情况下,动态调试(Frida, 调试器)的价值远大于静态分析。在运行时,内存中的对象是完整的,可以通过Frida的Il2Cpp API直接枚举类和方法。
- 寻找il2cpp运行时函数:
5.3 反调试与反Hook检测
问题:游戏集成了强大的反调试和反Hook机制,导致调试器无法附加,或Frida脚本一注入游戏就崩溃。
解决思路:
- 使用隐藏工具:对于Frida,可以使用
frida-server的隐藏版本,或者使用objection等工具,它们有一些反反调试的脚本。对于调试器,可以尝试在游戏启动后再附加。 - 内核模块调试:在Android上,如果游戏检测用户空间的调试器,可以尝试使用
gdbserver或KGDB进行内核级调试,但这需要root和更深的系统知识。 - 模拟器与修改环境:在修改过的Android模拟器(如支持内核模块的定制镜像)或越狱的iOS设备上进行分析,这些环境可能预先禁用了某些检测。
- Patch二进制文件:静态修改游戏二进制文件,直接NOP掉(空指令)反调试的检测函数调用。这需要精确找到检测代码的位置。
- 时间差攻击:在游戏启动完成、反调试初始化之前,有一个极短的时间窗口可以附加调试器。这需要精确的时机把握和自动化脚本。
5.4 加密算法复杂且密钥隐蔽
问题:加密算法不是简单的XOR,而是使用了AES或自定义的复杂算法,并且密钥不是硬编码,而是运行时从服务器下载或由其他算法动态生成。
解决思路:
- 定位密钥生成逻辑:如果密钥是动态的,那么一定有一段代码负责生成或获取它。搜索网络请求相关函数(在il2cpp中可能表现为对
UnityWebRequest的调用),或者搜索大的常量数组(可能是密钥或密钥盒)。 - Hook密码学相关API:如果游戏使用了系统或第三方库的加密函数(如OpenSSL的
AES_decrypt),直接Hook这些库函数,打印输入密钥和输出结果。 - 内存断点:在动态调试中,找到一处已知的密文字符串,在其解密后的明文缓冲区上设置内存访问断点。当游戏读取这个明文时,断点会触发,此时查看调用栈,就能找到解密函数,并观察其使用的密钥是从哪个变量或寄存器传来的。
- 侧信道分析:作为最后的手段,如果算法实现有漏洞(如时间侧信道),可能通过分析其执行时间来推断部分信息,但这在游戏逆向中极少使用。
6. 工具链推荐与使用技巧
工欲善其事,必先利其器。一套顺手的工具能极大提升分析效率。
核心工具:
- Il2CppDumper:无可替代。务必使用最新版本,并尝试其所有模式(
Manual,Auto(Plus)等),有时一种模式不行另一种可能成功。 - IDA Pro / Ghidra:静态分析主力。IDA的Hex-Rays反编译器对理解复杂逻辑帮助巨大。Ghidra免费且功能强大,其反编译器也在快速进步。
- Frida with frida-il2cpp-bridge:动态分析神器。学会编写和注入Frida脚本是现代移动安全分析的必备技能。
frida-il2cpp-bridge提供了高层API,让Hook il2cpp对象变得异常简单。 - dnSpy / ILSpy:虽然il2cpp编译后不再是C#,但有时开发者会留下未加密的
Assembly-CSharp.dll用于测试,或者某些版本使用了Mono后端。先用这些工具查看C#源码,能对游戏逻辑有全局了解,即使最终是il2cpp,也能指导逆向。
辅助工具与技巧:
- 010 Editor with Templates:用010 Editor的二进制模板功能解析
global-metadata.dat或游戏存档文件,有时能发现有趣的结构。 - Android Studio Profiler / LLDB:用于性能分析和底层调试。特别是LLDB,可以编写Python脚本进行自动化内存搜索和操作。
- 自定义Python脚本:无论是批量解密字符串,还是从内存dump中提取数据,Python都是最灵活的武器。结合
capstone(反汇编)、unicorn(模拟执行)等库,可以构建强大的自动化分析流水线。 - 版本对比:如果游戏有多个版本(如国内版和国际版),对比两个版本的二进制文件差异,有时能快速定位到与加密或验证相关的代码修改处。
分析心法:
- 由外到内,由浅入深:不要一开始就扎进最复杂的加密函数。先从简单的字符串引用、明显的解密调用入手,建立信心和理解。
- 大胆假设,小心验证:根据代码特征(循环、异或、常量数组)猜测加密类型,然后用动态调试或写小脚本验证你的猜测。
- 善用搜索:在IDA中,善于使用“立即数搜索”、“指令序列搜索”等功能。例如,搜索
xor byte ptr [寄存器], 8Fh这样的指令模式。 - 记录与归档:将分析过的加密函数特征、密钥、算法记录下来。很多游戏公司会复用同一套保护方案,你的经验库就是最大的财富。
字符串加密与解密的对抗,是Unity游戏安全攻防中最基础的一环。掌握这些常见的加密方式和分析方法,就如同获得了一把打开il2cpp黑盒的钥匙。但这仅仅是开始,后面还有函数逻辑混淆、资源加密、网络协议保护等更多的挑战。每一次成功的分析,不仅是对技术的锤炼,更是对耐心和思维缜密度的考验。记住,没有绝对安全的系统,只有不断演进的技术和永不停歇的好奇心。在实际操作中,最花时间的往往不是算法本身,而是如何绕过重重保护,让代码运行到你能观察它的那一刻。多动手,多调试,经验自然会在一次次“碰壁”与“通关”中积累起来。