找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4236

积分

0

好友

582

主题
发表于 8 小时前 | 查看: 6| 回复: 0

文章记录了对某款基于 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 静态分析这条路行不通。

Hex编辑器查看global-metadata.dat文件

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

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

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

// MoleMole.PacketDefine
const int HEAD_MAGIC = 17767;  // 0x4567
const int 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 是静态固定的。这里有两种验证手段:一是直接 hook AesUtils 的方法打印类字段,二是下沉到 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 (key) {
const keyBytes = new Uint8Array(key.handle.readByteArray(key.length));
console.log("Key (hex): " + bytesToHex(arrayToBytes(key)));
console.log("Key length: " + key.length);
    }
if (iv) {
const ivBytes = new Uint8Array(iv.handle.readByteArray(iv.length));
console.log("IV (hex): " + bytesToHex(arrayToBytes(iv)));
console.log("IV length: " + iv.length);
    }
console.log("==============================");
return this.method('.ctor').invoke(algo, encryption, key, iv);
  };
});

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

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

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

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

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

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

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

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

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

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

return ret;
    };

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

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

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

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 "frida-il2cpp-bridge";

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

Session1.implementation = function (value: Il2Cpp.Object) {
console.log("=== Hook set_SessionValue1 ===");
// 获取 ByteString 对象
const bsObj = value as Il2Cpp.Object;
// 尝试调用 ToByteArray() 获取原始 bytes
let bytes = null;
if (bsObj.class.method("ToByteArray")) {
try {
        bytes = bsObj.method("ToByteArray").invoke(); // 返回 byte[] 类型
console.log("ByteString 长度:", bytes.length);
// 可以进一步将 bytes 转为 hex 或 base64 打印
console.log("Bytes (hex):", bytesToHex(arrayToBytes(bytes)));
      } catch (e) {
console.warn("调用 ToByteArray 失败:", e);
      }
    }
// 调用原始 setter 函数
const ret = this.method("set_SessionValue1").invoke(value);
console.log("=== End Hook ===");
return ret;
  };
const Session2 = PacketHead.method("set_SessionValue2");
console.log("找到方法: set_SessionValue2 ->", Session2);
Session2.implementation = function (value: Il2Cpp.Object) {
console.log("=== Hook set_SessionValue2 ===");
// 获取 ByteString 对象
const bsObj = value as Il2Cpp.Object;
// 尝试调用 ToByteArray() 获取原始 bytes
let bytes = null;
if (bsObj.class.method("ToByteArray")) {
try {
        bytes = bsObj.method("ToByteArray").invoke(); // 返回 byte[] 类型
console.log("ByteString 长度:", bytes.length);
// 可以进一步将 bytes 转为 hex 或 base64 打印
console.log("Bytes (hex):", bytesToHex(arrayToBytes(bytes)));
      } catch (e) {
console.warn("调用 ToByteArray 失败:", e);
      }
    }
// 调用原始 setter 函数
const ret = this.method("set_SessionValue2").invoke(value);
console.log("=== End Hook ===");
return ret;
  };
});

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

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

}

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

from Crypto.PublicKey import RSA

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

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

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

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

握手时序

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

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

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

return ret;
  };
});

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

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

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
{
    System.StringPathToUse(System.String filepath);       // 0x05c73d00
    System.BooleanFileExists(System.String filepath);     // 0x05c73e40
    System.StringReadFile(System.String filepath, System.String& debugpath);  // 0x05c74078
    System.StringCheckAndFixPath(System.String filepath); // 0x05c73f9c
    System.Void.ctor();                                   // 0x05c743a4
}

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

Hook ReadFile 拿到完整脚本

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

import "frida-il2cpp-bridge"

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

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

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

console.log("
  • Hooking CustomTsScriptLoader.ReadFile");     readFile.implementation = function (filepath, debugPath) { const result = this.method('ReadFile').invoke(filepath, debugPath); try { console.log("\n[CustomTsScriptLoader.ReadFile]"); console.log("  filepath =", filepath); console.log("  debugPath =", debugPath.value);   // out 参数 console.log("  code (length=" + result.length + "):\n" + result);         } catch (e) { console.log("[!] Error printing result:", e);         } return result;     }; });
  • 加载的模块涵盖:

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

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

    0x09 总结与踩坑复盘

    整体分析流程

    抓包识别 Magic 特征(45 67 / 89 AB)
        ↓
    APK 检查 → metadata 头部异常 → 转用 frida-il2cpp-bridge 运行时 dump
        ↓
    dump.cs 搜索魔数 → 十进制转换 → 定位 PacketDefine / AesUtils
        ↓
    动态 hook → 确认 AES 加密 → 固定 IV
        ↓
    调用栈追溯 → SerializeSec → 还原组包结构
        ↓
    握手包识别(明文 pb)→ RSA 参数提取 → 解密 session key
        ↓
    发现 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 对不上时第一时间检查是否有脚本引擎介入

    通过这次逆向分析的完整复盘,我们可以清晰地看到,面对经过 IL2CPP 编译且融合了脚本引擎的复杂客户端,从流量特征识别到加密逻辑还原,再到脚本层逻辑的挖掘,需要一套系统性的分析思路和灵活的工具组合。其中,灵活运用 Frida 进行动态分析是贯穿始终的关键。

    如果你对 .Net 相关逆向或者类似的移动安全技术感兴趣,欢迎到 云栈社区 的 C#/.Net 板块进行更深入的交流与探讨。




    上一篇:PHPX 2.1发布解析:如何用C++编写PHP高性能扩展与静态方言
    您需要登录后才可以回帖 登录 | 立即注册

    手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

    GMT+8, 2026-3-29 13:19 , Processed in 0.513493 second(s), 41 queries , Gzip On.

    Powered by Discuz! X3.5

    © 2025-2026 云栈社区.

    快速回复 返回顶部 返回列表