首页
社区
课程
招聘
[原创]某校园题材二游逆向记录
发表于: 1天前 500

[原创]某校园题材二游逆向记录

1天前
500

某校园题材二游逆向记录

受朋友委托,对某二游进行逆向工程。

经了解,目标游戏使用Unity引擎开发,同时登录PC、Android、iOS平台。这里为了方便绕过反作弊和反调试,选用Android端作为逆向对象。

初步分析

针对客户端进行解包,可以看到非常熟悉的Unity il2cpp打包结构:


对于这种情况,不必多说,先尝试直接使用il2cppdumper开干:

不过很显然事情并不会这么简单,意料之中地报错了,从报错信息中可以得到,libil2cpp.so本身应该不存在保护,问题主要集中在global-metadata.dat。分析错误可知崩溃点在MapVATR读CodeRegistration/MetadataRegistration的数组时抛EndOfStreamException,这基本是非常明显的il2cpp元数据结构魔改特征。

不过也不必先急于对魔改后的结构进行完整逆向,我们可以使用容错能力更强的cpp2il在加载StrippedCodeRegSupport插件的情况下跑diffable-cs和isildump得到结构和机器码:


metadata usage token布局分析

虽然cpp2il已经能跑出diffable-cs和isildump,但il2cppdumper本身仍然无法正常解析。既然崩溃点和metadata usage解析强相关,那就继续顺着相关调用往下看。

先照重定位表随手还原几个metadata usage slot,再顺着读对应位置的内容,可以拿到一些看起来像metadata usage token的值。照常见il2cpp解法来解token:

kind  = token >> 29;
index = token & 0x1FFFFFFF;

结果明显异常,有的index直接越界,有的虽然没越界,但解出来也是无关文本:

可以判断这次魔改动的就是token的编码方式,跟通用公式对不上。

继续分析相关汇编,可以很容易发现相关位置在赋值前会先调0x6353FAC去解这些metadata usage,那就直接跟进该函数:

这层只是个跳板,顺着sub_6363BF4的尾调用进到sub_63E2AA0,关键逻辑就在这里:

unsigned __int64 __fastcall sub_63E2AA0(unsigned __int64 *a1, char a2, int a3, int a4, int a5, int a6, int a7, int a8)
{
  unsigned __int64 v9; // x20
  __int64 v10; // x21
  __int64 v11; // x0
  unsigned int *v12; // x20
  int *v13; // x10
  unsigned __int64 v14; // x0
  unsigned __int64 *v15; // x9
  unsigned __int64 v16; // x8
  int v17; // w9
  unsigned int *v18; // x20
  __int64 v19; // x0
  int v21; // [xsp+0h] [xbp-30h]
  char v22[8]; // [xsp+8h] [xbp-28h] BYREF
  int v23[2]; // [xsp+10h] [xbp-20h]
  void *v24; // [xsp+18h] [xbp-18h]

  do
    v9 = __ldaxr(a1);
  while ( __stlxr(v9, a1) );
  __dmb(0xBu);
  if ( (v9 & 1) != 0 )
  {
    v10 = ((unsigned int)v9 >> 1) & 0xFFFFFFF;
    switch ( (unsigned int)v9 >> 29 )
    {
      case 1u:
        v11 = sub_63E2C60((unsigned int)v10, a2 & 1);
        goto LABEL_17;
      case 2u:
        v9 = *(_QWORD *)(*(_QWORD *)(qword_FDCE5E8 + 56) + 8LL * (unsigned int)v10);
        if ( !v9 )
          return v9;
        goto LABEL_19;
      case 3u:
      case 6u:
        v11 = sub_63E2DA8((unsigned int)v9);
        goto LABEL_17;
      case 4u:
        v12 = (unsigned int *)(qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 184) + 8LL * (unsigned int)v10);
        v9 = *(_QWORD *)(sub_63E2C60(*v12, 1) + 128) + 32LL * (int)v12[1];
        if ( !v9 )
          return v9;
        goto LABEL_19;
      case 5u:
        v9 = *(_QWORD *)(qword_FDCE628 + 8LL * (unsigned int)v10);
        if ( v9 )
          goto LABEL_18;
        v13 = (int *)(qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 8) + 8 * v10);
        v14 = il2cpp_string_new_len_0(
                (int)qword_FDCE5F0 + *(_DWORD *)(qword_FDCE5F8 + 16) + v13[1],
                *v13,
                a3,
                a4,
                a5,
                a6,
                a7,
                a8,
                v21,
                v22[0],
                v23[0],
                v24);
        v9 = v14;
        v15 = (unsigned __int64 *)(qword_FDCE628 + 8 * v10);
        break;
      case 7u:
        v18 = (unsigned int *)(qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 184) + 8LL * (unsigned int)v10);
        v19 = sub_63E2C60(*v18, 1);
        v11 = sub_63E2DF4(*(_QWORD *)(v19 + 128) + 32LL * (int)v18[1], v22);
LABEL_17:
        v9 = v11;
LABEL_18:
        if ( v9 )
          goto LABEL_19;
        return v9;
      default:
        return 0;
    }
    while ( 1 )
    {
      v16 = __ldaxr(v15);
      if ( v16 )
        break;
      if ( !__stlxr(v14, v15) )
      {
        v17 = 1;
        goto LABEL_22;
      }
    }
    __clrex();
    v17 = 0;
LABEL_22:
    __dmb(0xBu);
    if ( v17 )
    {
      sub_63D38A4(qword_FDCE628 + 8 * v10);
      if ( !v9 )
        return v9;
    }
    else
    {
      v9 = v16;
      if ( !v16 )
        return v9;
    }
LABEL_19:
    __dmb(0xBu);
    *a1 = v9;
  }
  return v9;
}

剥出关键逻辑:

// 最低位为0:已解析真实指针,直接返回
if ((token & 1) == 0)
    return token;

// 最低位为1:待解析token
kind  = (token >> 29) & 0x7;
index = (token >> 1)  & 0x0FFFFFFF;

switch (kind) {
case 1:                  // 类型/类引用
    resolved = sub_63E2C60(index, a2 & 1);
    break;

case 2:                  // 全局缓存表里直接取
    resolved = *(_QWORD *)(*(_QWORD *)(qword_FDCE5E8 + 56) + 8 * index);
    break;

case 3:
case 6:                  // 走特殊解析分支
    resolved = sub_63E2DA8(token);
    break;

case 4:                  // 表里定位类型+成员索引,再算槽位
    entry = qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 184) + 8 * index;
    klass = sub_63E2C60(*(uint32_t *)entry, 1);
    resolved = *(_QWORD *)(klass + 128) + 32 * *(int *)(entry + 4);
    break;

case 5:                  // 字符串字面量,带CAS缓存
    resolved = qword_FDCE628[index];
    if (!resolved) {
        entry = qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 8) + 8 * index;
        new_string = il2cpp_string_new_len_0(
            qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 16) + entry->offset,
            entry->length
        );
        // CAS抢写,避免多线程重复覆盖
        if (atomic_compare_exchange(&qword_FDCE628[index], 0, new_string)) {
            resolved = new_string;
            sub_63D38A4(&qword_FDCE628[index]);
        } else {
            resolved = qword_FDCE628[index];
        }
    }
    break;

case 7:                  // 在case 4基础上再解一层成员
    entry = qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 184) + 8 * index;
    klass = sub_63E2C60(*(uint32_t *)entry, 1);
    resolved = sub_63E2DF4(*(_QWORD *)(klass + 128) + 32 * *(int *)(entry + 4), tmp);
    break;

default:
    return 0;
}

if (resolved)
    *a1 = resolved;   // 回写真实指针,对应开头直接返回

return resolved;

可见真正的token布局是这样:

kind  = (token >> 29) & 0x7;
index = (token >> 1)  & 0x0FFFFFFF;

可得与原版il2cpp的显著差异:最低位被单独拿去当状态标志(标记是否已解析),index得先右移掉这一位再取28位。套通用公式时这个flag混进了index,自然要么越界、要么解出无关字符串。

修改il2cppdumper以适配魔改布局

让我们先来解决EndOfStreamException崩溃。根据调用链下断点可得,il2cppdumper根据version = 31来处理metadata文件,而metadata的文件头也确实是这样描述的。但问题在于,游戏实际使用的Unity版本为2021.3.56,其对应il2cpp版本为27,不得不说这招确实有点阴。

修改Il2CppDumper/Il2Cpp/Il2CppClass.cs中的class Il2CppCodeRegistration,直接锁定版本来修复:

         [Version(Min = 22)]
         public ulong unresolvedVirtualCallPointers;
-        [Version(Min = 29.1)]
+        [Version(Min = 31.1)]   // [MX-FIX#1] Yostar 伪造版本号; 实际 v27 结构无此字段
         public ulong unresolvedInstanceCallPointers;
-        [Version(Min = 29.1)]
+        [Version(Min = 31.1)]
         public ulong unresolvedStaticCallPointers;

接着,我们还需要绕过自动搜索,允许强制指定注册结构地址。因为结构体布局对上之后,FindCodeRegistration2019的自动搜索在魔改后的metadata中必定会得到错误结果。在这里我们直接干掉自动搜索,手动填地址:

Il2CppDumper/Config.cs

         public bool NoRedirectedPointer { get; set; } = false;
+        public string ForceCodeRegistration { get; set; } = "";
+        public string ForceMetadataRegistration { get; set; } = "";

Il2CppDumper/Program.cs中 在Console.WriteLine("Searching...") 后,PlusSearch前:

-                var flag = il2Cpp.PlusSearch(...);
+                bool flag;
+                if (!string.IsNullOrEmpty(config.ForceCodeRegistration) &&
+                    !string.IsNullOrEmpty(config.ForceMetadataRegistration))
+                {
+                    var cr = Convert.ToUInt64(config.ForceCodeRegistration, 16);
+                    var mr = Convert.ToUInt64(config.ForceMetadataRegistration, 16);
+                    Console.WriteLine($"[MX] Forcing CodeRegistration=0x{cr:x}, MetadataRegistration=0x{mr:x}");
+                    il2Cpp.Init(cr, mr);
+                    flag = true;
+                }
+                else
+                flag = il2Cpp.PlusSearch(...);

针对当前样本,使用如下地址常量:

  • ForceCodeRegistration = "ee1c1a0"
  • ForceMetadataRegistration = "f410418"

地址常量求解大致思路:

  • MetadataRegistration:在数据段里找合适的结构,如typeDefinitionsSizesCounttypesCount,且每个指针字段都挂着R_AARCH64_RELATIVE重定位,对得上即可。
  • CodeRegistration:先用codeGenModulesCount == imageCount(镜像数,本样本 108=0x6c)这条线锁定那对 (count, ptr),再从这个 count 字段往前数 13 个 8 字节就是结构体起点(这套布局在 codeGenModules 之前一共 14 项指针/计数)即可求得。

附对应脚本:

#!/usr/bin/env python3
import sys, struct
from elftools.elf.elffile import ELFFile

SO = sys.argv[1] if len(sys.argv) > 1 else "extracted/arm64/lib/arm64-v8a/libil2cpp.so"
META = sys.argv[2] if len(sys.argv) > 2 else "extracted/base/assets/bin/Data/Managed/Metadata/global-metadata.dat"

data = open(SO, "rb").read()
rel = {}
segs = []
with open(SO, "rb") as f:
    elf = ELFFile(f)
    for s in elf.iter_sections():
        if s.name == ".rela.dyn":
            for r in s.iter_relocations():
                if r["r_info_type"] == 1027:
                    rel[r["r_offset"]] = r["r_addend"]
    f.seek(0)
    elf = ELFFile(f)
    for s in elf.iter_segments():
        if s.header.p_type == "PT_LOAD":
            segs.append((s.header.p_vaddr, s.header.p_offset, s.header.p_filesz, s.header.p_memsz))

def in_file(va):
    for vaddr, off, fsz, msz in segs:
        if vaddr <= va < vaddr + fsz:
            return True
    return False
def is_ptr(va):
    return va in rel
def num(va):
    return struct.unpack_from("<Q", data, va)[0]

md = open(META, "rb").read()
imagesSize = struct.unpack_from("<I", md, 0xac)[0]
IMAGE_COUNT = imagesSize // 0x28   # sizeof(Il2CppImageDefinition) v29/31 = 0x28
print(f"[i] imagesSize=0x{imagesSize:x} -> imageCount={IMAGE_COUNT}\n")

def find_codereg():
    cands = {}
    for va in range(segs[2][0], segs[2][0] + segs[2][2] - 16, 8):  # RW Data Part
        c = num(va)
        if c == IMAGE_COUNT and is_ptr(va + 8):
            start = va + 8 - 14*8
            if start < segs[2][0]: continue
            if (not is_ptr(start)) and is_ptr(start + 8):   # (count, ptr)
                rev = num(start)
                if 0 <= rev <= 100000:
                    cands.setdefault(c, []).append(start)
    return cands

def find_metareg():
    best = []
    for va in range(segs[2][0], segs[2][0] + segs[2][2] - 0x80, 8):
        ok = True
        for i in range(7):
            cva = va + i*16
            if is_ptr(cva) or not is_ptr(cva + 8):
                ok = False; break
            if num(cva) == 0 or num(cva) > 4_000_000:
                ok = False; break
        if ok:
            term = va + 7*16
            if not is_ptr(term) and num(term) == 0:
                best.append(va)
    return best

print("=== CodeRegistration ===")
for c, starts in sorted(find_codereg().items()):
    for st in starts:
        print(f"  imageCount={c}(0x{c:x})  ->  CodeRegistration = 0x{st:x}")
print("\n=== MetadataRegistration ===")
for va in find_metareg()[:10]:
    print(f"  MetadataRegistration = 0x{va:x}  (genericClassesCount={num(va)})")

随后再跑修改后的il2cppdumper,即可得到熟悉的东西:

接下来可继续使用IDA或Ghidra等工具载入libil2cpp.so,使用il2cppdumper附带的脚本根据script.jsonil2cpp.h给反编译结果上符号。

至此,完整的游戏逻辑已经跟裸奔没有任何本质区别。

资源文件解密

在写这里的时候我的IDA还在对libil2cpp.so进行分析,我当然不想再等一个小时两个小时的,所以下面的内容主要先根据ISIL来解。

资源路径与压缩包形态

简单预览il2cpp_dump_mx/stringliteral.json,能看到一组成套出现的资源路径常量:

"/TableBundles/"
"/MediaResources/"
"/Android_PatchPack/catalog_Android.zip"
"/Catalog/TableCatalog.bytes"
"/Catalog/MediaCatalog.bytes"
"(\/catalog_.+)\.zip"
".zip"

这基本已经把资源分成了几类:TableBundles、MediaResources、PatchPack里的Addressables,以及对应的catalog。

接着全量搜索ZipInputStreamCreatePassword的调用点,可以看到主要命中三处:

  • BundleLogicDataReader:读取TableBundles
  • MediaService:读取MediaResources
  • ResourcePatcher.DownloadCatalog:读取Addressables清单

其中前两者在创建ZipInputStream后都会设置Password,后者不会。

整体管线大致如下:

TableCatalogMediaCatalog本身是MemoryPack,从我们修改适配后的il2cppdumper的dump结果il2cpp_dump_mx/dump.cs中也能证明:

public class TableCatalog : IMemoryPackable<TableCatalog>
public class MediaCatalog : IMemoryPackable<MediaCatalog>

资源根{root}则来自登录dispatcher下发的ServerInfoData.ConnectionGroups[].AddressablesCatalogUrlRoot

TableBundles解包

易得读取点在:

cpp2il_isil/IsilDump/BlueArchive/MX/AssetBundles/BundleLogicDataReader.txt
BundleLogicDataReader.ReadBinary(System.String zippedFilePath)

关键ISIL:

060 Call BundleLogicDataReader.GetBundleNameFromRelativePath, X0, X1   ; X22 = 包名 bundleName
064 Call BundleLogicDataReader.GetTablePath, X0, X1                    ; X19 = 本地 zip 路径
076 Call File.OpenRead, X0                                             ; 打开 zip 文件流
087 Call ZipInputStream..ctor, X0, X1                                  ; new ZipInputStream(fileStream)
094 Move W1, 20                                                        ; length = 20
095 Move X0, X22                                                       ; arg = bundleName
097 Call TableService.CreatePassword, X0, X1                           ; pwd = CreatePassword(bundleName, 20)
100 Move [X0+128], X1                                                  ; zipStream.Password = pwd
105 Call ZipInputStream.GetNextEntry, X0
113 Call String.ToLower …116 op_Inequality                             ; 找名字匹配的条目
128 Call ZipEntry.get_Size … 读出 entry 字节

可以知道它是SharpZipLib的带口令ZIP

MediaResources同理:

cpp2il_isil/IsilDump/BlueArchive/Media/Service/MediaService.txt

对应ISIL:

101 Call ZipInputStream..ctor
129 Call TableService.CreatePassword
132 Move [X0+128], X1                          ; .Password = pwd
140 Call ZipInputStream.GetNextEntry

后面多处可见类似逻辑,由此可知,MediaResources同样复用TableService.CreatePassword这套口令机制。

反过来看Addressables清单zip,位置在:

cpp2il_isil/IsilDump/BlueArchive/MX/AssetBundles/ResourcePatcher_NestedType__DownloadCatalog_d__150.txt

这里的逻辑是:

196 Call ZipInputStream..ctor
203 Call ZipInputStream.GetNextEntry

整个方法里没有CreatePassword调用。也就是说,catalog_Android.zip只是普通zip容器,不带口令。

口令算法

跟进TableService.CreatePassword

cpp2il_isil/IsilDump/BlueArchive/TableService.txt
TableService.CreatePassword(string key, int length=20) @ 0x7D216FC

关键ISIL:

037 Call XXHashService.CalculateHash, X0      ; seed = xxHash32(key)
045 Add W8, W19, W19                          ; ADD W8,W19,W19,LSL#1 => length*3
046 Add W9, W8, 3
047/048 CMP/CSEL                              ; 负数向上取整修正
052 SBFM W19,W8,2,0x1F                        ; W19 = (length*3) >> 2 = length*3/4
053 Call MersenneTwister..ctor, X0, X1         ; mt = new MersenneTwister(seed)
057 Call MersenneTwister.NextBytes, X0, X1     ; raw = mt.NextBytes(15)
072 Call Convert.ToBase64String, X0            ; 15字节 → 20个base64字符

可得大致等价C#逻辑:

public static string CreatePassword(string key, int length = 20) {
    uint seed = XXHashService.CalculateHash(key);
    byte[] raw = new MersenneTwister((int)seed).NextBytes(length * 3 / 4);
    return Convert.ToBase64String(raw);
}

调用处传入的length固定为20,实际就是取15字节随机流,然后转成20字符base64。

继续往下看XXHashService.CalculateHash

cpp2il_isil/IsilDump/BlueArchive/MX/Core/Services/XXHashService.txt
XXHashService.CalculateHash(string) @ 0x6DF0B18

关键逻辑:

005 Call String.IsNullOrEmpty
015 Call Encoding.get_UTF8
020/021 BLR (XXHash32 实例.ComputeHash)

也就是:

xxHash32(UTF8(name), seed=0)

再看MersenneTwister

cpp2il_isil/IsilDump/BlueArchive/MX/Core/Math/MersenneTwister.txt

构造函数里调用init_genrand(seed),常数和流程都是MT19937,也就是n=624、初始化乘数为1812433253

值得注意的是,它的Next()并不直接返回genrand_int32(),反而使用以下逻辑:

(genrand_int32() >> 1) & 0x7FFFFFFF

NextBytes(len)则是每4字节调用一次Next(),再用BitConverter.GetBytes按小端写入输出。对应ISIL:

036 Call MersenneTwister.genrand_int32
039 ShiftRight W22, 1
040 And W22, W22, 0x7FFFFFFF
050 Call BitConverter.GetBytes

该操作会导致每4字节里的最高字节恒小于0x80,后面的验证中也能看到对应效果。

完整复现实现:

import base64, struct

# ---- xxHash32 (seed 0) ----
P1,P2,P3,P4,P5 = 2654435761,2246822519,3266489917,668265263,374761393
M=0xFFFFFFFF
def _rl(x,r): x&=M; return ((x<<r)|(x>>(32-r)))&M
def xxhash32(data, seed=0):
    n=len(data); i=0
    if n>=16:
        v1=(seed+P1+P2)&M; v2=(seed+P2)&M; v3=seed&M; v4=(seed-P1)&M
        def _round(v,k): return (_rl((v+(k*P2)&M)&M,13)*P1)&M
        while i<=n-16:
            v1=_round(v1,struct.unpack_from('<I',data,i)[0]);  i+=4
            v2=_round(v2,struct.unpack_from('<I',data,i)[0]);  i+=4
            v3=_round(v3,struct.unpack_from('<I',data,i)[0]);  i+=4
            v4=_round(v4,struct.unpack_from('<I',data,i)[0]);  i+=4
        h=(_rl(v1,1)+_rl(v2,7)+_rl(v3,12)+_rl(v4,18))&M
    else:
        h=(seed+P5)&M
    h=(h+n)&M
    while i+4<=n:
        k=struct.unpack_from('<I',data,i)[0]; i+=4
        h=(_rl((h+k*P3)&M,17)*P4)&M
    while i<n:
        h=(_rl((h+data[i]*P5)&M,11)*P1)&M; i+=1
    h^=h>>15; h=(h*P2)&M; h^=h>>13; h=(h*P3)&M; h^=h>>16
    return h&M

# ---- MT19937 ----
class MT:
    def __init__(self, seed):
        self.mt=[0]*624; self.idx=625
        s=seed & M
        self.mt[0]=s
        for i in range(1,624):
            self.mt[i]=(1812433253*(self.mt[i-1]^(self.mt[i-1]>>30))+i)&M
        self.idx=624
    def genrand_int32(self):
        if self.idx>=624:
            for i in range(624):
                y=(self.mt[i]&0x80000000)|(self.mt[(i+1)%624]&0x7fffffff)
                v=self.mt[(i+397)%624]^(y>>1)
                if y&1: v^=2567483615
                self.mt[i]=v&M
            self.idx=0
        y=self.mt[self.idx]; self.idx+=1
        y^=y>>11; y^=(y<<7)&2636928640; y^=(y<<15)&4022730752; y^=y>>18
        return y&M
    def Next(self):
        return (self.genrand_int32()>>1)&0x7FFFFFFF
    def NextBytes(self,length):
        out=bytearray(length); i=0
        while i<length:
            v=self.Next(); b=struct.pack('<I',v)
            for j in range(4):
                if i+j<length: out[i+j]=b[j]
            i+=4
        return bytes(out)

def create_key(name):
    return MT(xxhash32(name.encode())).NextBytes(8)

def create_password(key, length=20):
    raw = MT(xxhash32(key.encode())).NextBytes((length*3)//4)
    return base64.b64encode(raw).decode()

def xor(name, data):
    mt=MT(xxhash32(name.encode())); out=bytearray(data); k=0
    for i in range(len(out)):
        if i&3==0: k=mt.Next()
        out[i]^=(k>>((i&3)*8))&0xFF
    return bytes(out)

if __name__=="__main__":
    import sys
    enc=open("GameMainConfig.enc","rb").read()
    for name in ["GameMainConfig","GameMainConfig.bytes","gamemainconfig"]:
        d=xor(name,enc)
        printable=sum(1 for b in d if 9<=b<=126)
        head=d[:80].decode('utf-8','replace')
        print(f"name={name!r} printable={printable}/{len(d)}  head={head!r}")

表内第二层XOR

zip只是外层。表包里的.bytes解出来之后,部分字符串或数值字段还有第二层XOR。

能够定位到相关逻辑在:

cpp2il_isil/IsilDump/BlueArchive/MX/Data/TableEncryptionService.txt
TableEncryptionService.XOR(string name, byte[] bytes) @ 0x7672BF8

ISIL:

029 Call XXHashService.CalculateHash
041 Call MersenneTwister..ctor

循环里则是每4字节取一次MT输出,然后按字节进行XOR运算:

0x7672D88 LDRB
0x7672D8C UBFX
0x7672D90 ASRV
0x7672D98 EOR

也就是:

seed = xxHash32(name);
mt = new MersenneTwister(seed);

for i in range(len(bytes)):
    if ((i & 3) == 0)
        k = mt.Next();
    out[i] = in[i] ^ ((k >> ((i & 3) * 8)) & 0xff);

对应的Encrypt/Convert

TableEncryptionService.Encrypt(string, byte[]) @ 0x767340C
TableEncryptionService.Convert(string, byte[]) @ 0x7673530

这里走的是:

Base64( UTF16LE(s) XOR key循环 )

以及反向转换,主要用于表里某些“键:值”形式的单元格。

对应的8字节key生成函数是:

TableEncryptionService.CreateKey(string name) @ 0x7672B78

逻辑非常短:

018 Call XXHashService.CalculateHash, X0   ; seed = xxHash32(name)
028 Call MersenneTwister..ctor, X0, X1     ; mt = new MersenneTwister(seed)
032 Move W1, 8
036 Call MersenneTwister.NextBytes, X0, X1 ; return mt.NextBytes(8)

可得对应C#逻辑:

byte[] key = new MersenneTwister(xxHash32(name)).NextBytes(8);

用内置GameMainConfig验证

静态分析完成后,拿APK里内置的GameMainConfig做一组真值验证

其中的TextAsset GameMainConfig大小为914B,是密文。直接看密文字节,可以发现奇数字节呈现严格周期8:

23 06 72 57 23 06 72 57 ...

这正好符合“UTF-16LE明文的奇数字节多为0,再被8字节key循环异或”的特征。同时0657都小于0x80,也和前面MersenneTwister.Next()会右移一位的特征吻合。

使用上面的脚本CreateKey('GameMainConfig'),可以非常轻松的成功解密:

在线资源解密验证

通过SDK配置可得:

GameMainConfig.ServerInfoDataUrl
  = 131K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6&6L8%4y4@1j5i4u0Q4x3X3c8K6k6i4u0$3k6i4u0A6L8X3k6G2i4K6u0W2j5X3I4#2k6h3q4J5j5$3S2A6N6X3g2&6L8%4y4@1j5i4u0Q4x3X3g2U0L8$3#2Q4x3V1k6J5z5e0u0Q4y4h3j5$3z5g2)9#2k6X3^5$3j5X3u0*7k6X3@1&6L8o6c8#2j5h3@1&6y4h3y4F1j5X3S2#2i4K6u0W2K9Y4y4G2L8R3`.`.

ServerInfoData.ConnectionGroups[0].OverrideConnectionGroups["1.69"].AddressablesCatalogUrlRoot
  = 293K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6H3M7X3!0V1i4K6u0V1j5$3I4A6k6h3&6@1M7r3q4@1j5$3S2Q4x3X3g2T1L8s2g2W2j5i4u0U0K9r3W2$3k6i4W2G2M7%4c8S2M7W2)9J5k6h3y4G2L8g2)9J5c8Y4t1&6x3W2)9#2k6Y4c8K6N6$3E0Y4L8Y4S2B7M7s2k6K6L8$3N6Z5L8h3y4$3z5i4q4#2

随意拼一个资源URL:

cf7K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6H3M7X3!0V1i4K6u0V1j5$3I4A6k6h3&6@1M7r3q4@1j5$3S2Q4x3X3g2T1L8s2g2W2j5i4u0U0K9r3W2$3k6i4W2G2M7%4c8S2M7W2)9J5k6h3y4G2L8g2)9J5c8Y4t1&6x3W2)9#2k6Y4c8K6N6$3E0Y4L8Y4S2B7M7s2k6K6L8$3N6Z5L8h3y4$3z5i4q4#2i4K6u0r3g2r3q4T1L8r3g2n7N6h3&6V1L8r3g2K6i4K6u0r3c8i4S2U0k6h3I4Q4x3X3g2*7K9i4l9`.

下载后得到:

HTTP/1.1 200 OK
Content-Type: application/zip
Content-Length: 18209833
Last-Modified: Fri, 29 May 2026 02:29:06 GMT

sha256 = 6f6d0e1b11276a0bd9f4c5d27e860280fcb50c9e3a273344a8391e3c9b91694c
local header flags = 0x0009
method = 8
first entry = animationblendtable.bytes
entries = 71

flags=0x0009里bit0为1,说明这确实是一个加密zip。

按真实运行名Excel.zip计算口令:

CreatePassword("Excel.zip") = /wy5f3hIGGXLOIUDS9DZ

依旧成功解密。

结语

至此,已完成对该二游的改版il2cppdumper制作,并完整分析其资源加密逻辑。

回顾一下可以发现,该二游几乎未使用任何高强度加密或混淆,libil2cpp.so本身几乎裸奔,主要工作量集中在metadata usage token布局魔改、伪造metadata version以及资源下发流程上的一些工程化处理。前者会让通用工具链失效,后者则更多偏向于资源管理与简单反解包,也属于是经典的防君子不防小人设计了。

在修正metadata布局、恢复注册结构、补齐token解析逻辑之后,il2cppdumper即可重新正常工作;再配合cpp2il、ISIL、IDA/Ghidra与符号脚本,客户端逻辑基本已经处于可读状态。资源侧同理:顺着调用链把zip解密流程、口令生成原语与表内二次异或逐层还原后,整个资源系统的行为也已经可以稳定复现。

鸣谢

特别感谢 sf-yuzifu 提供部分逆向思路


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

最后于 22小时前 被Searchstars编辑 ,原因: 修正鸣谢列表中的链接
收藏
免费 3
打赏
分享
最新回复 (3)
雪    币: 155
活跃值: (176)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
tql
1天前
0
雪    币: 104
活跃值: (8642)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
tql
23小时前
0
雪    币: 4206
活跃值: (6942)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
4
感谢分享
23小时前
0
游客
登录 | 注册 方可回帖
返回