手游逆向全流程复盘:从IL2CPPDump到TCP握手协议还原

admin 2026-03-31 11:43:43 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文复盘了某IL2CPP手游的完整逆向流程。作者通过抓包定位自定义协议特征,针对元数据加密采用frida运行时Dump,结合动态Hook确认AES加密并还原由Magic头尾与密文组成的组包格式,同时揭示读写Key分离的会话密钥机制,为同类Unity手游协议逆向提供了高实操价值的参考路径。 综合评分: 93 文章分类: 移动安全,逆向分析,实战经验,二进制安全,安全工具


cover_image

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

梧桐生 梧桐生

看雪学苑

2026年3月27日 18:03 上海

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

#

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

分析环境:

  • 设备:Android 物理机
  • 抓包:SunnyNet抓包工具
  • 主要工具:frida-il2cpp-bridge、CyberChef、Python(pycryptodome)

0x01 初步抓包与流量特征识别

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

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

#

0x02 APK 检查与 Dump 方案选择

把 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 文件。

0x03 从 dump.cs 定位数据包结构

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

// MoleMole.PacketDefine
constint HEAD_MAGIC = 17767;  // 0x4567
constint TAIL_MAGIC = 35243;  // 0x89AB

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

0x04 动态验证加密位置

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

  • Crc32Utils

    相关方法始终没有调用记录,排除 CRC 完整性校验的可能。

  • AesUtils

    的加密/解密方法有稳定调用,且调用时机与抓包数据发出时间吻合。

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

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {

const Assembly = Il2Cpp.domain.assembly("Assembly-CSharp");

const AESUtils = Assembly.image.class('MoleMole.AESUtils');
const Encrpt = AESUtils.method('Encrpt');
const Decrpt = AESUtils.method('Decrpt');
// static System.Byte[] Decrpt(System.Byte[] encrypted, System.Byte[] key);
Decrpt.implementation = function (encrypted, key) {

console.log("=== AESUtils.Decrpt Hooked ===");

// 打印参数
const encBytes = arrayToBytes(encrypted);
const keyBytes = arrayToBytes(key);
console.log("Decrypting:", bytesToHex(encBytes));
console.log("Key:", bytesToHex(keyBytes));

// 调用原始函数
const result = Decrpt.invoke(encrypted, key);

// 打印结果
console.log("Decrypted:", bytesToHex(arrayToBytes(result)));
console.log("==============================");
return result
  };

Encrpt.implementation = function (encrypted, key) {

console.log("=== AESUtils.Encrpt Hooked ===");

// 打印参数
const encBytes = arrayToBytes(encrypted);
const keyBytes = arrayToBytes(key);
console.log("Encrypting:", bytesToHex(encBytes));
console.log("Key:", bytesToHex(keyBytes));

// 调用原始函数
const result = Encrpt.invoke(encrypted, key);

// 打印结果
console.log("Encrypted:", bytesToHex(arrayToBytes(result)));
console.log("==============================");
return result
  };
});

#

0x05 IV 与 Key 分析

IV

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

Key

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

MoleMole.TcpAsyncClient
    System.Byte[] session_read_key_;   // offset 0xa8
    System.Byte[] session_write_key_;  // offset 0xb0

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

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {
const AesTransform = Il2Cpp.domain.assembly("System.Core").image.class("System.Security.Cryptography.AesTransform");
// Hook the constructor to capture key and IV
const ctor = AesTransform.method(".ctor");
  ctor.implementation = function (algo, encryption, key, iv) {
console.log("==============================");
console.log("[AesTransform Constructor]");
console.log("Encryption mode: " + encryption);
// Key and IV are Il2Cpp.Array<byte>
if&nbsp;(key) {
const&nbsp;keyBytes =&nbsp;new&nbsp;Uint8Array(key.handle.readByteArray(key.length));
console.log("Key (hex): "&nbsp;+&nbsp;bytesToHex(arrayToBytes(key)));
console.log("Key length: "&nbsp;+ key.length);
&nbsp; &nbsp; }
if&nbsp;(iv) {
const&nbsp;ivBytes =&nbsp;new&nbsp;Uint8Array(iv.handle.readByteArray(iv.length));
console.log("IV (hex): "&nbsp;+&nbsp;bytesToHex(arrayToBytes(iv)));
console.log("IV length: "&nbsp;+ iv.length);
&nbsp; &nbsp; }
console.log("==============================");
return&nbsp;this.method('.ctor').invoke(algo, encryption, key, iv);
&nbsp; };
});

#

0x06 组包结构还原

从调用栈继续往上追,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拼接到一起。

// Assembly-CSharp
class&nbsp;MoleMole.NetPacket&nbsp;:&nbsp;System.Object,&nbsp;System.IDisposable
{
&nbsp; &nbsp; System.UInt16 <cmdId>k__BackingField;&nbsp;// 0x10
&nbsp; &nbsp; Baseproto.PacketHead Head;&nbsp;// 0x18
&nbsp; &nbsp; System.IO.MemoryStream Body;&nbsp;// 0x20
__int64 __fastcall&nbsp;sub_2CDB4C0(__int64 a1, __int64 *a2, __int64 *a3){
&nbsp; __int64 v6;&nbsp;// x0
&nbsp; __int64 result;&nbsp;// x0
&nbsp; __int64 v8;&nbsp;// x22
&nbsp; __int64 v9;&nbsp;// x23
unsignedint&nbsp;v10;&nbsp;// w24
&nbsp; __int64 v11;&nbsp;// x22
&nbsp; __int64 v12;&nbsp;// x23
unsignedint&nbsp;v13;&nbsp;// w24
unsignedint&nbsp;v14;&nbsp;// w0
&nbsp; __int64 v15;&nbsp;// x22
unsignedint&nbsp;v16;&nbsp;// w24
&nbsp; __int64 v17;&nbsp;// x23
unsignedint&nbsp;v18;&nbsp;// w24
&nbsp; __int64 v19;&nbsp;// x22
unsignedint&nbsp;v20;&nbsp;// w0
&nbsp; __int64 v21;&nbsp;// x22
unsignedint&nbsp;v22;&nbsp;// w24
&nbsp; __int64 v23;&nbsp;// x23
unsignedint&nbsp;v24;&nbsp;// w24
&nbsp; __int64 v25;&nbsp;// x22
&nbsp; __int64 v26;&nbsp;// x21
&nbsp; __int64 v27;&nbsp;// x0
&nbsp; __int64 v28;&nbsp;// x20
&nbsp; __int64 v29;&nbsp;// x21
&nbsp; __int64 v30;&nbsp;// x0
&nbsp; __int64 v31;&nbsp;// x21
&nbsp; __int64 v32;&nbsp;// x19
&nbsp; __int64 v33;&nbsp;// x20
unsignedint&nbsp;v34;&nbsp;// w21

if&nbsp;( (byte_A5674D1 &&nbsp;1) ==&nbsp;0&nbsp;)
&nbsp; {
sub_292804C(69884);
&nbsp; &nbsp; byte_A5674D1 =&nbsp;1;
&nbsp; }
if&nbsp;( (IsPatched_3702264(2967,&nbsp;0) &&nbsp;1) !=&nbsp;0&nbsp;)
&nbsp; {
&nbsp; &nbsp; v6 =&nbsp;sub_37021C4(2967,&nbsp;0);
if&nbsp;( !v6 )
sub_2954148();
return&nbsp;sub_2A1F5DC(v6, a1, a2, a3,&nbsp;0);
&nbsp; }
else
&nbsp; {
&nbsp; &nbsp; result = *a2;
if&nbsp;( *a2 )
&nbsp; &nbsp; {
if&nbsp;( *(a1 +&nbsp;0x20) )
&nbsp; &nbsp; &nbsp; {
&nbsp; &nbsp; &nbsp; &nbsp; (*(*result +&nbsp;760LL))(result,&nbsp;0, *(*result +&nbsp;768LL));
if&nbsp;( !*a2 )
sub_2954148();
&nbsp; &nbsp; &nbsp; &nbsp; (*(**a2 +&nbsp;504LL))(*a2,&nbsp;0, *(**a2 +&nbsp;512LL));
&nbsp; &nbsp; &nbsp; &nbsp; v8 = *a2;
if&nbsp;( (*(qword_A43BEB8 +&nbsp;295) &&nbsp;2) !=&nbsp;0&nbsp;&& !*(qword_A43BEB8 +&nbsp;216) )
il2cpp_runtime_class_init_0(qword_A43BEB8);
&nbsp; &nbsp; &nbsp; &nbsp; v9 =&nbsp;GetBytesNetworkdThread(0x4567u);
if&nbsp;( (*(qword_A426770 +&nbsp;295) &&nbsp;2) !=&nbsp;0&nbsp;&& !*(qword_A426770 +&nbsp;216) )
il2cpp_runtime_class_init_0(qword_A426770);
&nbsp; &nbsp; &nbsp; &nbsp; v10 =&nbsp;sub_45C78DC(0x4567, System_Int32);
if&nbsp;( !v8 )
sub_2954148();
&nbsp; &nbsp; &nbsp; &nbsp; (*(*v8 +&nbsp;808LL))(v8, v9,&nbsp;0, v10, *(*v8 +&nbsp;816LL));
&nbsp; &nbsp; &nbsp; &nbsp; v11 = *a2;
&nbsp; &nbsp; &nbsp; &nbsp; v12 =&nbsp;GetBytesNetworkdThread(*(a1 +&nbsp;0x10));
&nbsp; &nbsp; &nbsp; &nbsp; v13 =&nbsp;sub_45C78DC(*(a1 +&nbsp;0x10), System_Int32);
if&nbsp;( !v11 )
sub_2954148();
&nbsp; &nbsp; &nbsp; &nbsp; (*(*v11 +&nbsp;808LL))(v11, v12,&nbsp;0, v13, *(*v11 +&nbsp;816LL));
if&nbsp;( !*(a1 +&nbsp;0x18) )
sub_2954148();
&nbsp; &nbsp; &nbsp; &nbsp; v14 =&nbsp;CalculateSize(*(a1 +&nbsp;0x18));
&nbsp; &nbsp; &nbsp; &nbsp; v15 = *a2;
&nbsp; &nbsp; &nbsp; &nbsp; v16 = v14;
&nbsp; &nbsp; &nbsp; &nbsp; v17 =&nbsp;GetBytesNetworkdThread(v14);
&nbsp; &nbsp; &nbsp; &nbsp; v18 =&nbsp;sub_45C78DC(v16, System_Int32);
if&nbsp;( !v15 )
sub_2954148();
&nbsp; &nbsp; &nbsp; &nbsp; (*(*v15 +&nbsp;808LL))(v15, v17,&nbsp;0, v18, *(*v15 +&nbsp;816LL));
&nbsp; &nbsp; &nbsp; &nbsp; v19 = *(a1 +&nbsp;0x20);
if&nbsp;( !v19 )
sub_2954148();
&nbsp; &nbsp; &nbsp; &nbsp; v20 = (*(*v19 +&nbsp;0x1D8LL))(*(a1 +&nbsp;0x20), *(*v19 +&nbsp;0x1E0LL));
&nbsp; &nbsp; &nbsp; &nbsp; v21 = *a2;
&nbsp; &nbsp; &nbsp; &nbsp; v22 = v20;
&nbsp; &nbsp; &nbsp; &nbsp; v23 =&nbsp;GetBytesNetworkdThread_0(v20);
&nbsp; &nbsp; &nbsp; &nbsp; v24 =&nbsp;sub_45C79C0(v22, qword_A4A6F50);
if&nbsp;( !v21 )
sub_2954148();
&nbsp; &nbsp; &nbsp; &nbsp; (*(*v21 +&nbsp;808LL))(v21, v23,&nbsp;0, v24, *(*v21 +&nbsp;816LL));
&nbsp; &nbsp; &nbsp; &nbsp; v25 =&nbsp;sub_2962ABC(System_IO_MemoryStream);
System_IO_MemoryStream_ctor(v25);
Google_Protobuf_MessageExtensions_WriteTo(*(a1 +&nbsp;0x18), v25);
&nbsp; &nbsp; &nbsp; &nbsp; v26 = *(a1 +&nbsp;0x20);
if&nbsp;( !v26 )
sub_2954148();
&nbsp; &nbsp; &nbsp; &nbsp; (*(*v26 +&nbsp;0x388LL))(v26, v25, *(*v26 +&nbsp;912LL));
if&nbsp;( !v25 )
sub_2954148();
&nbsp; &nbsp; &nbsp; &nbsp; v27 = (*(*v25 +&nbsp;888LL))(v25, *(*v25 +&nbsp;896LL));
&nbsp; &nbsp; &nbsp; &nbsp; v28 = *a3;
&nbsp; &nbsp; &nbsp; &nbsp; v29 = v27;
if&nbsp;( (*(qword_A43BD60 +&nbsp;295) &&nbsp;2) !=&nbsp;0&nbsp;&& !*(qword_A43BD60 +&nbsp;216) )
il2cpp_runtime_class_init_0(qword_A43BD60);
&nbsp; &nbsp; &nbsp; &nbsp; v30 =&nbsp;MoleMole_AESUtils_Encrpt(v29, v28);
&nbsp; &nbsp; &nbsp; &nbsp; v31 = *a2;
if&nbsp;( !v30 )
sub_2954148();
if&nbsp;( !v31 )
sub_2954148();
&nbsp; &nbsp; &nbsp; &nbsp; (*(*v31 +&nbsp;808LL))(*a2, v30,&nbsp;0, *(v30 +&nbsp;24), *(*v31 +&nbsp;816LL));
&nbsp; &nbsp; &nbsp; &nbsp; v32 = *a2;
&nbsp; &nbsp; &nbsp; &nbsp; v33 =&nbsp;GetBytesNetworkdThread(0x89ABu);
&nbsp; &nbsp; &nbsp; &nbsp; v34 =&nbsp;sub_45C78DC(0x89AB, System_Int32);
if&nbsp;( !v32 )
sub_2954148();
&nbsp; &nbsp; &nbsp; &nbsp; (*(*v32 +&nbsp;808LL))(v32, v33,&nbsp;0, v34, *(*v32 +&nbsp;816LL));
return&nbsp;1;
&nbsp; &nbsp; &nbsp; }
else
&nbsp; &nbsp; &nbsp; {
return&nbsp;0;
&nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
&nbsp; }
return&nbsp;result;
}
import&nbsp;"frida-il2cpp-bridge";

Il2Cpp.perform(() =>&nbsp;{
// 找到 System.IO.MemoryStream 类
const&nbsp;mscorlibAssembly =&nbsp;Il2Cpp.domain.tryAssembly("mscorlib") ||&nbsp;Il2Cpp.domain.tryAssembly("System");
if&nbsp;(!mscorlibAssembly) {
console.error("mscorlib or System assembly not found!");
return;
&nbsp; }

const&nbsp;MemoryStreamClass&nbsp;= mscorlibAssembly.image.tryClass("System.IO.MemoryStream");
if&nbsp;(!MemoryStreamClass) {
console.error("MemoryStream class not found!");
return;
&nbsp; }

// Hook Write(byte[] buffer, int offset, int count)
const&nbsp;WriteMethod&nbsp;=&nbsp;MemoryStreamClass.tryMethod("Write",&nbsp;3);
if&nbsp;(WriteMethod) {
WriteMethod.implementation&nbsp;=&nbsp;function&nbsp;(buffer, offset, count) {
const&nbsp;hexString =&nbsp;bytesToHex(arrayToBytes_len(buffer, offset + count));

// 判断以 "4567" 开头 且 长度 > 12
if&nbsp;(hexString.startsWith("4567") && hexString.length&nbsp;>&nbsp;12) {
console.log("[HOOK] MemoryStream.Write called");
console.log(`Buffer length:&nbsp;${buffer.length}, Offset:&nbsp;${offset}, Count:&nbsp;${count}`);
console.log(`Write data (hex):&nbsp;${hexString}`);
&nbsp; &nbsp; &nbsp; }

return&nbsp;this.method("Write",&nbsp;3).invoke(buffer, offset, count);
&nbsp; &nbsp; };
console.log("MemoryStream.Write hooked successfully!");
&nbsp; }&nbsp;else&nbsp;{
console.error("Write method not found!");
&nbsp; }

const&nbsp;GetBuffer&nbsp;=&nbsp;MemoryStreamClass.tryMethod("GetBuffer",&nbsp;0);
if&nbsp;(GetBuffer) {
GetBuffer.implementation&nbsp;=&nbsp;function&nbsp;(stream: Il2Cpp.Object) {
const&nbsp;length =&nbsp;this.method("get_Length").invoke();
const&nbsp;ret =&nbsp;this.method("GetBuffer").invoke();

const&nbsp;hexString =&nbsp;bytesToHex(arrayToBytes_len(ret, length));

// 判断 hexString 是否以4567开头 且长度 > 12
if&nbsp;(hexString.startsWith("4567") && hexString.length&nbsp;>&nbsp;12) {
console.log("[HOOK] MemoryStream.GetBuffer called");
console.log(`Length:&nbsp;${length}`);
console.log(`GetBuffer data (hex):&nbsp;${hexString}`);
&nbsp; &nbsp; &nbsp; }

return&nbsp;ret;
&nbsp; &nbsp; };

console.log("MemoryStream.GetBuffer hooked successfully!");
&nbsp; }&nbsp;else&nbsp;{
console.error("WriteTo method not found!");
&nbsp; }
});

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

┌─────────────┬───────────┬─────────────┬─────────────┬────────────────────┬──────────────┐
│ &nbsp;45&nbsp;67&nbsp; &nbsp; &nbsp; │ &nbsp;cmd_id &nbsp; │&nbsp;len(part1) &nbsp;│&nbsp;len(part2) &nbsp;│ &nbsp;encrypted_body &nbsp; &nbsp;│ &nbsp;89&nbsp;AB &nbsp; &nbsp; &nbsp; │
│ &nbsp;HEAD_MAGIC │ &nbsp;(2B) &nbsp; &nbsp; │ &nbsp;(2B) &nbsp; &nbsp; &nbsp; │ &nbsp;(4B) &nbsp; &nbsp; &nbsp; │ &nbsp;AES 加密 pb &nbsp; &nbsp; &nbsp; │ &nbsp;TAIL_MAGIC &nbsp;│
└─────────────┴───────────┴─────────────┴─────────────┴────────────────────┴──────────────┘

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

0x07 握手流程与 Session Key 协商

发现未加密的握手包

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

cmdid=0x0066:密钥交换包

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

import&nbsp;"frida-il2cpp-bridge";

Il2Cpp.perform(() =>&nbsp;{
const&nbsp;PacketHead&nbsp;=&nbsp;Assembly.image.class('Baseproto.PacketHead');
const&nbsp;Session1&nbsp;=&nbsp;PacketHead.method("set_SessionValue1");
console.log("找到方法: set_SessionValue1 ->",&nbsp;Session1);

Session1.implementation&nbsp;=&nbsp;function&nbsp;(value: Il2Cpp.Object) {
console.log("=== Hook set_SessionValue1 ===");

// 获取 ByteString 对象
const&nbsp;bsObj = value&nbsp;as&nbsp;Il2Cpp.Object;

// 尝试调用 ToByteArray() 获取原始 bytes
let&nbsp;bytes =&nbsp;null;
if&nbsp;(bsObj.class.method("ToByteArray")) {
try&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; bytes = bsObj.method("ToByteArray").invoke();&nbsp;// 返回 byte[] 类型
console.log("ByteString 长度:", bytes.length);
// 可以进一步将 bytes 转为 hex 或 base64 打印
console.log("Bytes (hex):",&nbsp;bytesToHex(arrayToBytes(bytes)));
&nbsp; &nbsp; &nbsp; }&nbsp;catch&nbsp;(e) {
console.warn("调用 ToByteArray 失败:", e);
&nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }

// 调用原始 setter 函数
const&nbsp;ret =&nbsp;this.method("set_SessionValue1").invoke(value);

console.log("=== End Hook ===");
return&nbsp;ret;
&nbsp; };

const&nbsp;Session2&nbsp;=&nbsp;PacketHead.method("set_SessionValue2");
console.log("找到方法: set_SessionValue2 ->",&nbsp;Session2);

Session2.implementation&nbsp;=&nbsp;function&nbsp;(value: Il2Cpp.Object) {
console.log("=== Hook set_SessionValue2 ===");

// 获取 ByteString 对象
const&nbsp;bsObj = value&nbsp;as&nbsp;Il2Cpp.Object;

// 尝试调用 ToByteArray() 获取原始 bytes
let&nbsp;bytes =&nbsp;null;
if&nbsp;(bsObj.class.method("ToByteArray")) {
try&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; bytes = bsObj.method("ToByteArray").invoke();&nbsp;// 返回 byte[] 类型
console.log("ByteString 长度:", bytes.length);
// 可以进一步将 bytes 转为 hex 或 base64 打印
console.log("Bytes (hex):",&nbsp;bytesToHex(arrayToBytes(bytes)));
&nbsp; &nbsp; &nbsp; }&nbsp;catch&nbsp;(e) {
console.warn("调用 ToByteArray 失败:", e);
&nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }

// 调用原始 setter 函数
const&nbsp;ret =&nbsp;this.method("set_SessionValue2").invoke(value);

console.log("=== End Hook ===");
return&nbsp;ret;
&nbsp; };
});

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

// mscorlib
structSystem.Security.Cryptography.RSAParameters&nbsp;:&nbsp;System.ValueType
{
System.Byte[]Exponent;&nbsp;// 0x10 &nbsp; &nbsp;// 公钥参数
System.Byte[]Modulus;&nbsp;// 0x18
System.Byte[]P;&nbsp;// 0x20 &nbsp; &nbsp;// 私钥参数
System.Byte[]Q;&nbsp;// 0x28
System.Byte[]DP;&nbsp;// 0x30
System.Byte[]DQ;&nbsp;// 0x38
System.Byte[]InverseQ;&nbsp;// 0x40
System.Byte[]D;&nbsp;// 0x48

}

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

from Crypto.PublicKey&nbsp;import&nbsp;RSA

key&nbsp;=&nbsp;RSA.construct((
int.from_bytes(modulus,&nbsp;'big'),
int.from_bytes(exponent,&nbsp;'big'),
int.from_bytes(d,&nbsp;'big'),
int.from_bytes(p,&nbsp;'big'),
int.from_bytes(q,&nbsp;'big'),
))

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

cmdid=0x0067:客户端身份确认包

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

握手时序

Client &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Server
&nbsp; │ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
&nbsp; │──[0x0066]&nbsp;明文 pb ──────────────> &nbsp;│ &nbsp;发送: token和设备ID
&nbsp; │ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
&nbsp; │ &nbsp;<──────────── 明文 pb&nbsp;[0x0066]── &nbsp;│ &nbsp;回应RSA 加密的key(read + write)
&nbsp; │ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
&nbsp; │──[0x0067]&nbsp;明文 pb ──────────────> &nbsp;│ &nbsp;发送:本地私钥加密的身份数据
&nbsp; │ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
&nbsp; │ &nbsp;<──────────── 明文 pb&nbsp;[0x0067]── &nbsp;│ &nbsp;服务端确认,握手完成
&nbsp; │ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
&nbsp; │══════════ 后续全部 AES 加密 ═══════│
import&nbsp;"frida-il2cpp-bridge";

Il2Cpp.perform(() =>&nbsp;{
const&nbsp;Assembly&nbsp;=&nbsp;Il2Cpp.domain.assembly("Assembly-CSharp");
const&nbsp;TcpAsyncClient&nbsp;=&nbsp;Assembly.image.class('MoleMole.TcpAsyncClient');
const&nbsp;send =&nbsp;TcpAsyncClient.method('send');
&nbsp; send.implementation&nbsp;=&nbsp;function&nbsp;(packet) {
const&nbsp;cmdId &nbsp; = packet.method('get_cmdId').invoke();
const&nbsp;ret =&nbsp;this.method("send").invoke(packet);
const&nbsp;bodyArr = packet.method('getBody').invoke().method("ToArray").invoke();
const&nbsp;hexBody =&nbsp;bytesToHex(arrayToBytes_len(bodyArr, bodyArr.length));

console.log(
"=== Hook send ===\n"&nbsp;+
"cmdId = "&nbsp;+ cmdId +&nbsp;"\n"&nbsp;+
"send ==> "&nbsp;+ hexBody
&nbsp; &nbsp; );

return&nbsp;ret;
&nbsp; };
});

function&nbsp;arrayToBytes(arr: Il2Cpp.Array<Il2Cpp.Byte>):&nbsp;number[] {
const&nbsp;result:&nbsp;number[] = [];
for&nbsp;(let&nbsp;i =&nbsp;0; i < arr.length; i++) {
&nbsp; &nbsp; &nbsp; result.push(arr.get(i));
&nbsp; &nbsp; }
return&nbsp;result;
&nbsp; }

function&nbsp;arrayToBytes_len(arr,length):&nbsp;number[] {
const&nbsp;result:&nbsp;number[] = [];
for&nbsp;(let&nbsp;i =&nbsp;0; i < length; i++) {
&nbsp; &nbsp; &nbsp; result.push(arr.get(i));
&nbsp; &nbsp; }
return&nbsp;result;
&nbsp; }

#

0x08 业务逻辑层:发现 Puerts JS 脚本引擎

cmdid 对不上的异常

在分析具体业务包时,发现部分 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:

// Assembly-CSharp
class CustomTsScriptLoader : System.Object, Puerts.ILoader
{
&nbsp; &nbsp; System.StringPathToUse(System.String filepath); &nbsp; &nbsp; &nbsp;&nbsp;// 0x05c73d00
&nbsp; &nbsp; System.BooleanFileExists(System.String filepath); &nbsp; &nbsp;&nbsp;// 0x05c73e40
&nbsp; &nbsp; System.StringReadFile(System.String filepath, System.String& debugpath); &nbsp;// 0x05c74078
&nbsp; &nbsp; System.StringCheckAndFixPath(System.String filepath);&nbsp;// 0x05c73f9c
&nbsp; &nbsp; System.Void.ctor(); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 0x05c743a4
}

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

Hook ReadFile 拿到完整脚本

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

import&nbsp;"frida-il2cpp-bridge"

Il2Cpp.perform(() =>&nbsp;{
// 拿到 Assembly-CSharp 里的 CustomTsScriptLoader
const&nbsp;asm =&nbsp;Il2Cpp.domain.assembly("Assembly-CSharp");
const&nbsp;loaderClass = asm.image.class("CustomTsScriptLoader");

if&nbsp;(!loaderClass) {
console.log("[!] 没找到 CustomTsScriptLoader");
return;
&nbsp; &nbsp; }

// Hook ReadFile(string filepath, out string debugPath)
const&nbsp;readFile = loaderClass.method("ReadFile");
if&nbsp;(!readFile) {
console.log("[!] 没找到 ReadFile 方法");
return;
&nbsp; &nbsp; }

console.log("[*] Hooking CustomTsScriptLoader.ReadFile");

&nbsp; &nbsp; readFile.implementation&nbsp;=&nbsp;function&nbsp;(filepath, debugPath) {
const&nbsp;result =&nbsp;this.method('ReadFile').invoke(filepath, debugPath);

try&nbsp;{
console.log("\n[CustomTsScriptLoader.ReadFile]");
console.log(" &nbsp;filepath =", filepath);
console.log(" &nbsp;debugPath =", debugPath.value); &nbsp;&nbsp;// out 参数
console.log(" &nbsp;code (length="&nbsp;+ result.length&nbsp;+&nbsp;"):\n"&nbsp;+ result);
&nbsp; &nbsp; &nbsp; &nbsp; }&nbsp;catch&nbsp;(e) {
console.log("[!] Error printing result:", e);
&nbsp; &nbsp; &nbsp; &nbsp; }

return&nbsp;result;
&nbsp; &nbsp; };
});

加载的模块涵盖:

  • 各 cmdid 对应的 protobuf message 定义(以 JS 对象形式内联定义)
  • 收发包的序列化/反序列化逻辑
  • 部分游戏业务逻辑(战斗、任务等)

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

#

0x09 总结与踩坑复盘

整体分析流程

抓包识别 Magic 特征(45&nbsp;67&nbsp;/&nbsp;89&nbsp;AB)
&nbsp; &nbsp; ↓
APK 检查 → metadata 头部异常 → 转用 frida-il2cpp-bridge 运行时&nbsp;dump
&nbsp; &nbsp; ↓
dump.cs 搜索魔数 → 十进制转换 → 定位 PacketDefine / AesUtils
&nbsp; &nbsp; ↓
动态 hook → 确认 AES 加密 → 固定 IV
&nbsp; &nbsp; ↓
调用栈追溯 → SerializeSec → 还原组包结构
&nbsp; &nbsp; ↓
握手包识别(明文 pb)→ RSA 参数提取 → 解密 session key
&nbsp; &nbsp; ↓
发现 Puerts → hook ReadFile → 拿到完整 JS 脚本 → 补全 pb 定义

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

最深的弯路:AES key 的来源

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

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

回头看,这个弯路的根本原因是先入为主地在加密函数附近找 key,而没有从”key 是什么时候被写入TcpAsyncClient字段的”这个角度出发。如果一开始就 hook 字段写入时机,可能会更快定位到握手环节。

可复用的方法论

  • 常量搜索注意进制

    :十六进制 magic 在 C# 代码里经常以十进制存储,搜索时记得换算

  • metadata 异常直接上运行时 dump

    :头部不对就放弃静态分析,frida-il2cpp-bridge 省时间

  • 调用栈是定位 key 来源最有效的手段

    :比在函数周围盲打 hook 效率高得多

  • IL2CPP dump 不完整时优先排查脚本层

    :Puerts / xlua 的存在会导致部分逻辑在 dump.cs 里看不到,遇到 cmdid 对不上时第一时间检查是否有脚本引擎介入

#

看雪ID:梧桐生

https://bbs.kanxue.com/user-home-922433.htm

*本文为看雪论坛优秀文章,由 梧桐生 原创,转载请注明来自看雪社区

往期推荐

安卓逆向基础知识之frida Hook

2025 强网杯和强网拟态部分题解

在逆向分析方面-unidbg真的适合 MCP 吗?

AI静态分析,内核模块隐藏 Frida 特征,绕过linker私有结构遍历崩溃链

某安全so库深度解析

球分享

球点赞

球在看

点击阅读原文查看更多


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:看雪学苑 梧桐生 梧桐生《手游逆向全流程复盘:从 IL2CPP Dump 到 TCP 握手协议还原》

评论:0   参与:  0