首页
社区
课程
招聘
[原创]某手游逆向全流程复盘:从 IL2CPP Dump 到 TCP 握手协议还原
发表于: 1小时前 68

[原创]某手游逆向全流程复盘:从 IL2CPP Dump 到 TCP 握手协议还原

1小时前
68

本文记录了对某款基于 Unity 引擎(IL2CPP 编译)手游的完整逆向分析过程,涵盖运行时 Dump、网络协议识别、加密逻辑还原、握手流程分析,以及隐藏在脚本引擎中的业务逻辑挖掘。

文章以过程复盘为主,保留了实际分析中的弯路与回溯,力求真实还原研究思路。目标游戏已脱敏处理。

分析环境:

启动游戏后第一步是被动观察流量,用 SunnyNet 抓取全量网络数据,确认游戏是否走 TCP。启动后很快能看到若干条 TCP 长连接建立,选取其中持续有数据交互的连接,结合游戏内操作(日常任务或者日常关卡等)观察流量是否随操作变化——数据量明显跟随操作波动,确认这条 TCP 连接就是游戏主逻辑信道。

看原始字节流,能很快发现一个规律:每个数据包前固定以 45 67 开头,结尾固定是 89 AB,中间内容随操作变化且不可读。Magic 头尾明确,说明是自定义二进制协议,中间部分大概率经过加密或压缩处理。把这两个特征记下来,后续在代码里定位包结构时会直接用到。

把 APK 解包后,第一步检查 global-metadata.dat 的文件头。标准未加固的 IL2CPP metadata 文件头是固定的 magic(AF 1B B1 FA),但这里头部字节不符,判断 metadata 要么被加密,要么做了自定义结构处理。走常规 Il2CppDumper 静态分析这条路行不通。

转换思路,改用运行时 dump。注入 frida-il2cpp-bridge,等 IL2CPP runtime 在内存中完成自解密并初始化后,由脚本直接从内存里 dump 出 dump.cs。注入时机很关键,太早 runtime 还没完成初始化,太晚可能触发反调试,需要根据游戏启动流程适当调整 attach 时机。最终成功拿到 cs 文件。

拿到 dump.cs 之后,回过头来用抓包看到的特征做索引。直接搜 456789AB 没有结果——想到这类常量在 C# 里可能以十进制保存,换算后搜对应十进制值,找到了:

定位到 MoleMole 命名空间下,顺藤摸瓜翻相关类,重点关注两个工具类:MoleMole.AesUtilsMoleMole.Crc32Utils

写 Frida 脚本分别 hook 两个工具类的方法,打印调用日志,结合抓包时间线对比:

交叉比对 hook 日志里的明文输入和抓包的密文输出,确认数据包中间的不可读部分确实是 AES 加密的结果。

重启游戏,多次打印 AesUtils 加密调用时类内的 IV 字段——每次启动值相同,确认 IV 是静态固定的。这里有两种验证手段:一是直接 hook AesUtils 的方法打印类字段,二是下沉到 native 层 hook AES 初始化点(如 AES_init_ctx_iv 或 mbedtls 对应接口),两种方式拿到的值一致。

重启游戏后发现 key 每次不同,且没有明显规律,不像是简单的时间戳或随机种子生成。打印调用栈,发现收包和发包走的 key 不同,最终在 MoleMole.TcpAsyncClient 里找到两个字段:

读写 key 分离,说明是握手后协商的 session key。key 的来源悬而未决,需要从握手流程里继续找。

从调用栈继续往上追,AesUtils.Encrypt 的调用方是 MoleMole.NetPacket.SerializeSec。这个函数内部大量通过 System.IO.MemoryStream 做字节拼接,但调用都是通过计算偏移间接发起的(IL2CPP 的 vtable 调用方式),没有直接可读的符号。通过hook跳转点并减去libil2cpp地址可以得到函数地址来确定具体调用的函数。整体逻辑是生成4567,然后拿到调用MoleMole.NetPacketk__BackingField字段做cmdid,接下来调用MoleMole.NetPacketHeadCalculateSize函数确定part1的长度并转成字节,然后通过调用MoleMole.NetPacketBodyget_Length函数确定part2的长度并转成字节,接着拼接HeadBody后调用MoleMole.AESUtilsEncrpt加密,再生成89ab后将前面所有的内容都用System.IO.MemoryStreamWrite拼接到一起

处理方式:hook 各处 MemoryStream.Write 调用点,打印调用地址,减去对应模块基址,再拿偏移去 dump.cs 里查对应方法名,逐步还原出拼接顺序。结合 CalculateSize(protobuf 标准方法)的调用位置,最终确认数据包结构如下:

body 在加密前分为 part1 和 part2 两段:part1 是连接级别的元数据,与整条 TCP 连接的生命周期绑定;part2 是业务逻辑 payload,随 cmdid 变化而变化。

在组包结构确认之后,重新审视 TCP 连接建立初期的数据包。观察到连接建立后最初的 4 个数据包 body 部分是明文——没有走 SerializeSec 的加密流程。用 CyberChef 对 body 做 protobuf raw decode,能正常解析出标准 pb 结构,字段清晰可读,确认握手阶段数据是明文 pb 传输。

把 cmdid 对应到 dump.cs 里的 proto message 类逐包分析。在 cmdid=0x0066 的 part1 中,发现两段长度均为 256 字节的 bytestring。256 字节即 2048 bit,结合字段的上下文语义,合理推测这里使用了 RSA-2048 进行密钥交换。

hook System.Security.Cryptography 的底层 RSA 实现,打印 RSAParameters 结构体内容,可以直接拿到完整的公私钥参数:

用 Python 从这些参数还原密钥:

用私钥解密两段 256 字节的 bytestring,分别得到两串 16/32 字节的数据——正是后续通信使用的 session_read_key_session_write_key_。至此 key 的来源完全清晰。

cmdid=0x0067 是客户端的回包,part1 中包含用本地私钥加密的数据,发送至服务端做身份验证。本地私钥是设备级别唯一的,推测是首次运行时生成并持久化存储的密钥对,公钥在注册/登录阶段已上传服务端。这一步完成后,双方持有相同的 session key,后续所有数据包切换为 AES 加密传输。

在分析具体业务包时,发现部分 cmdid 在 dump.cs 里完全查不到对应的 protobuf 类定义——既没有 message 类,也没有相关的 encode/decode 调用。排查方向首先是 xlua:hook xlua 相关入口没有命中,排除。

继续翻 dump.cs,在命名空间里发现了 Puerts 相关的类。Puerts 是腾讯开源的在 Unity 里嵌入 JavaScript/TypeScript 运行时的框架,这意味着游戏把部分业务逻辑(包括这些 cmdid 对应的 pb 处理)下沉到了 JS 层,绕过了 IL2CPP 的静态编译,所以 dump.cs 里看不到。

Puerts 加载脚本走的是 ILoader 接口,游戏实现了一个自定义 loader:

关键方法是 ReadFile——Puerts 每次加载一个脚本模块都会调用它,返回值就是脚本的完整字符串内容。

直接 hook ReadFile,打印返回值和 filepath 参数,游戏运行过程中所有被动态加载的脚本内容都会从这里流出。脚本以明文 JS 文件形式加载,没有字节码编译或混淆处理,可以直接阅读。

加载的模块涵盖:

之前查不到的 cmdid,在这些 JS 模块里全部能找到对应的结构定义,协议层至此完整还原。

附件内有完整的分析脚本可以直接使用

整个过程里卡得最久的是 AES key 的来源问题。确认 IV 是静态固定的之后,key 每次重启都不同,也没有明显的生成规律。当时的思路是在"加密函数周围"找 key——尝试过对所有网络收包回调打印内容来碰运气,始终定位不到。

真正的突破口是调用栈:从 AesUtils.Encrypt 往上追调用链,找到 SerializeSec,再结合 TCP 握手初期有明文包这个观察,才把视角转移到握手流程上。最后通过 hook RSA 底层参数,才把 session_read_key_ / session_write_key_ 的来源完整串联起来。


传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

上传的附件:
收藏
免费 4
支持
分享
最新回复 (1)
雪    币: 104
活跃值: (7967)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
tql
8分钟前
0
游客
登录 | 注册 方可回帖
返回