Description
最近在参与一款区块链的游戏,它是基于 Unity3D 做的网页游戏,使用 wasm 调用 WebGL 接口进行画面渲染。对于 wasm 的逆向网上的资料较少,中文资料就更 少了,因此操作起来有点困难。下面我记录一下整个过程。
Goal
分析和发现这款游戏的通信协议加解密过程,从而可以自行编写脚本进行交互。
Requirement
- UnityPack,用于从 data 文件中解包出 Unity 资源文件,包括 global-meta 文件。
- Il2CppDumper,用于从 wasm 打包中解析出 dll 和 global-meta 中解析出符号表。
- Ghidra,NSA 开源的一款逆向工具,主要是免费。
- ghidra-wasm-plugin,Ghidra 的 wasm 插件。
Steps
Unpack resource assets
参考 UnityPack,解包出 data 文件的 Unity 资源。
import os
import struct
SIGNATURE = 'UnityWebData1.0'
class BinaryReader:
    def __init__(self, buf, endian="<"):
        self.buf = buf
        self.endian = endian
    def align(self):
        old = self.tell()
        new = (old + 3) & -4
        if new > old:
            self.seek(new - old, os.SEEK_CUR)
    def read(self, *args):
        return self.buf.read(*args)
    def seek(self, *args):
        return self.buf.seek(*args)
    def tell(self):
        return self.buf.tell()
    def read_string(self, size=None, encoding="utf-8"):
        if size is None:
            ret = self.read_cstring()
        else:
            ret = struct.unpack(self.endian + "%is" % (size), self.read(size))[0]
        try:
            return ret.decode(encoding)
        except UnicodeDecodeError:
            return ret
    def read_cstring(self) -> bytes:
        ret = []
        c = b""
        while c != b"\0":
            ret.append(c)
            c = self.read(1)
            if not c:
                raise ValueError("Unterminated string: %r" % (ret))
        return b"".join(ret)
    def read_boolean(self) -> bool:
        return bool(struct.unpack(self.endian + "b", self.read(1))[0])
    def read_byte(self) -> int:
        return struct.unpack(self.endian + "b", self.read(1))[0]
    def read_ubyte(self) -> int:
        return struct.unpack(self.endian + "B", self.read(1))[0]
    def read_int16(self) -> int:
        return struct.unpack(self.endian + "h", self.read(2))[0]
    def read_uint16(self) -> int:
        return struct.unpack(self.endian + "H", self.read(2))[0]
    def read_int(self) -> int:
        return struct.unpack(self.endian + "i", self.read(4))[0]
    def read_uint(self) -> int:
        return struct.unpack(self.endian + "I", self.read(4))[0]
    def read_float(self) -> float:
        return struct.unpack(self.endian + "f", self.read(4))[0]
    def read_double(self) -> float:
        return struct.unpack(self.endian + "d", self.read(8))[0]
    def read_int64(self) -> int:
        return struct.unpack(self.endian + "q", self.read(8))[0]
class DataFile:
    def load(self, file):
        buf = BinaryReader(file, endian="<")
        self.path = file.name
        self.signature = buf.read_string()
        header_length = buf.read_int()
        if self.signature != SIGNATURE:
            raise NotImplementedError('Invalid signature {}'.format(repr(self.signature)))
        self.blobs = []
        while buf.tell() < header_length:
            offset = buf.read_int()
            size = buf.read_int()
            namez = buf.read_int()
            name = buf.read_string(namez)
            self.blobs.append({ 'name': name, 'offset': offset, 'size': size })
        if buf.tell() > header_length:
            raise NotImplementedError('Read past header length, invalid header')
        for blob in self.blobs:
            buf.seek(blob['offset'])
            blob['data'] = buf.read(blob['size'])
            if len(blob['data']) < blob['size']:
                raise NotImplementedError('Invalid size or offset, reading past file')
f = open('webglBuild_28.data', 'rb')
df = DataFile()
df.load(f)
for blob in df.blobs:
    print('extracting @ {}:\t{} ({})'.format(blob['offset'], blob['name'], blob['size']))
    dest = os.path.join('extracted', blob['name'])
    os.makedirs(os.path.dirname(dest), exist_ok=True)
    with open(dest, 'wb') as f:
        f.write(blob['data'])
解包出来的资源目录如下:
extracted
├── Il2CppData
│   └── Metadata
│       └── global-metadata.dat
├── Managed
│   └── mono
│       └── 4.0
│           └── machine.config
├── Resources
│   └── unity_default_resources
├── RuntimeInitializeOnLoads.json
├── ScriptingAssemblies.json
├── boot.config
├── data.unity3d
└── resources.resource
这里重点是 global-metadata.dat 文件,这个包含了游戏核心的符号表,能对应每个函数的实现和地址偏移。
Dump symbols
这里使用到 Il2CppDumper,使用就很简单了。这个工具需要 .NET 环境执行,我是在 mac 下安装虚拟机跑的,看 issue 下面有人说可以 mac 下安装 .NET 环境执行,感兴趣的可以折腾一下。
Il2CppDumper.exe webglBuild_28.wasm global-metadata.dat
Decompile
我使用的是 Ghidra,理论上 IDA 也可以。使用 Ghidra 需要先安装 wasm 插件才能支持 wasm 格式的文件反编译。如果直接使用 Release 产出的版本,需要 严格对应 Ghidra 版本号。对我来说可能自己编译会更简单,参考 Custom build。
反编译后文件会是混乱的状态,全是有 function 偏移组成,因此需要关联上 Il2CppDumper 导出的符号表映射过来方便我们阅读代码。在 Script Manager 中

这个脚本来源是 Il2CppDumper 中。执行脚本后 functions 列表中的函数名称都会变成可读的了。
但是目前这个脚本仍有一些做不好,需要手动更新函数签名中各个参数的名字。举栗子,如 Ghidra 反编译出来的函数签名是:
void Playground.App.LocalPlayer$$ScriptRequest<object,-object>(undefined4 __this,undefined4 type,undefined4 ars,undefined4 del,int method)
通过 Il2CppDumper 的 script.json,通过 Name 中查找到 Playground.App.LocalPlayer$$ScriptRequest 对应的 Signature,对照填充回
去就好。
另外一个是,反编译后的代码中有一些 invoke 方法脚本并没有处理,需要手动去关联,如:import::env::invoke_vi(&DAT_ram_000061ca,uVar2);,
对应调用的函数偏移地址是 0x61ca,在 script.json 中找到 Address 是 25034,且 TypeSignature 是 vi 的函数签名,调用的函数就是这个了。