Project SEKAI 逆向笔记 持续更新
非常喜欢 Project Sekai 这款游戏,不过我本身是主要学习密码学的,对逆向不甚了解,仅向下面博客进行了拙劣的学习,尝试提供一些基础的实践尝试,可能会记录得过于详细。
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
pjsk逆向唯一神贴: Project SEKAI 逆向 - 笔记归档
↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
更新日志
如果您已有相关经验,可以跳过试错较多且略显杂乱的 第一章 部分,直接阅读 第二章,我将从全新的视角去审计整个游戏,也希望我的分析能给您带来新的收获。我自己也觉得整篇博客杂乱无章,但是我还是很喜欢事无巨细地记录每段研究,我会在第二章尽可能优化语言和结构,还请见谅。
20250410完成了静态解包第三方工具等部分的大致整理20250411完成了提取 global-metadata.dat部分的整理- 忘了
20260404完成了修改 API-url20260604修正了对ffa3bd6214f33fe73cb72fee2262bedb的解释20260605开启第二章20260606新建mod 制作章节,记录FusionCore和搭建 mod 编译环境并测试 mod20260607完成代码审计章节的逆向工具和逆向分析准备两个小节20260615完成 cn-6.0.0 版本的 VMP 审计和global-metadata.dat的静态提取
第一章-学习与试错
静态解包
动态分析
cn-3.4.0 提取 global-metadata.dat (成功)
研究版本:Project Sekai 国服 3.4.0
游戏安装包(失败)
拿到公测开始公布的安装包,解压,找到./assets/bin/Data/Managed/Metadata/global-metadata.dat
发现 AF 1B B1 FA,但是在第九个字节开始,真的没加密吗?我不信会这么仁慈

尝试把前八个字节删掉,并找到 ./lib/arm64-v8a/libil2cpp.so,一并丢给 Il2CppDumper
果不其然报错了,而且是运行一半之后才报错,如图

查看 hexdump 发现整个文件到处都是乱码,应该还是有加密
/proc/[PID]/maps 提取 libcamera-metadata.dat (失败)
参照 metadata 动态提取 中的方法
启动游戏,进 adb shell 用 top 命令拿 PID=31607

su 后在 /proc/[PID]/maps 查找 metadata,结果如图

使用 dd 来 dump 这三段内存
dd if=/proc/31607/mem of=/sdcard/libcamera_metadata.so.1 bs=1 skip=$(printf "%u" 0x715074d000) count=$((0x7150755000-0x715074d000))dd if=/proc/31607/mem of=/sdcard/libcamera_metadata.so.2 bs=1 skip=$(printf "%u" 0x715076a000) count=$((0x715076b000-0x715076a000))dd if=/proc/31607/mem of=/sdcard/libcamera_metadata.so.3 bs=1 skip=$(printf "%u" 0x715076b000) count=$((0x715076d000-0x715076b000))第一个是 ELF,第二个第三个不知道是什么东西

内存搜索 AF 1B B1 FA (失败)
分别使用 Frida 和 GameGuardian 使用众多方法在内存中搜索魔术头 AF 1B B1 FA 无果。
注意:使用 GameGuardian 有封号风险,谨慎使用

痛失小号×1
其实还可能是因为出于恶搞,用 GG 在游戏里面改了 2147483647 个水晶(当然只是前端)

当时 root 机截图坏了,临时拍了个屏
内存搜索特殊字段(成功)
虽然被 ban 了,但是毕竟游戏的 metadata 已经被加载了,尝试继续在游戏开始界面
还是在 metadata 动态提取 中,发现其他服中的 metadata 有这样的字符串

GG 搜了一下 mscorlib,有好多结果,又搜了一下 mscorlib.dll.<Module>(注意用十六进制搜,里面有的点并不是字符点),我草,唯一结果

所在内存地址 0x7B26457032 ,顺便看一下内存上下文,确实有像 metadata 的字符串
到 /proc/[PID]/maps 找这段内存
cat /proc/4958/maps | more7b25f88000-7b25f89000 ---p 00000000 00:00 0 [anon:thread stack guard]7b25f89000-7b25f8a000 ---p 00000000 00:00 07b25f8a000-7b2608e000 rw-p 00000000 00:00 07b2629a000-7b2629b000 ---p 00000000 00:00 0 [anon:thread stack guard]7b2629b000-7b2629c000 ---p 00000000 00:00 07b2629c000-7b263a0000 rw-p 00000000 00:00 07b263a0000-7b2761f000 rw-p 00000000 00:00 07b2761f000-7b27e08000 rw-s 00000000 00:0b 9591 anon_inode:dmabuf7b27e08000-7b2f500000 r-xp 00000000 b3:42 93921 /data/app/com.hermes.mk-N1TZmXwSQOMJzh2TegB6vA==/lib/arm64/libil2cpp.so--More--找到
7b263a0000-7b2761f000 rw-p 00000000 00:00 0用 dd dump 出这段内存
dd if=/proc/4958/mem of=/sdcard/dump.dat bs=1 skip=$(printf "%u" 0x7b263a0000) count=$((0x7b2761f000-0x7b263a0000))
一个很像 global-metadata.dat 的文件,在 WinMerge 中严谨对比一下

经过比对,相较于 apk 中解包出的 metadata,dump 出的 metadata 具有以下五点不同
- 魔数
AF 1B B1 FA被改为00 00 00 00,这是先前全局搜索内存无果的原因(朝夕你真毒啊) - 魔数后的第一个字节
1B被改为了18,原因未知 D0 39 1E 00->FF FF FF 7F,事后猜测这似乎是一个被改到INT_MAX的int值- 加密段被解密,事后经验证为一段异或加密
- 尾部多出一堆
00,大概是分配内存多余的空间
结合两个 metadata,经过多次尝试,最终需要还原的 global-metadata.dat 方法如下:
- 取内存 dump 出的
metadata - 比对 apk 中解包出的
metadata,删除多余的00字节 - 复原
FF FF FF 7F->D0 39 1E 00 - 复原
18->1B - 复原
00 00 00 00->AF 1B B1 FA - 删除头
8字节,即使AF 1B B1 FA位于文件开头
最后使用工具 Il2CppDumper 解析 libil2cpp.so

使用 dnSpy 反编译 ./DummyDll/Assembly-CSharp.dll

有 Beebyte 但是类名函数名没混淆
任务完成
恢复好的 global-metadata.dat 的 MD5 为 9069c4db8cd9c520c2d22be69c36d30b
静态分析 libil2cpp.so (失败)
已经通过 dump 和修补拿到了完整的 global-metadata.dat,但是没有成功通过逆向 libil2cpp.so 发现解密算法,下面分享一下我的思路思路
参考文章:
使用 IDA 打开 ./lib/arm64-v8a/libil2cpp.so,直接 Shift+F12 搜 metadata

(注:部分变量函数已更名,但我基本不会逆向,可能有误标)

进 LoadMetadataFile
_BYTE *__fastcall LoadMetadataFile(char *string_global_metadata_dat){ void *v2; // x8 char *v3; // x9 __int64 strlen_global_metadata_dat; // x0 char *v5; // x8 void *v6; // x9 __int64 v7; // x0 const char *v8; // x1 __int64 v9; // x20 _BYTE *metadata_mapped; // x19 char *v12; // [xsp+0h] [xbp-60h] BYREF __int64 v13; // [xsp+8h] [xbp-58h] void *v14[2]; // [xsp+10h] [xbp-50h] BYREF void *p; // [xsp+20h] [xbp-40h] void *v16[2]; // [xsp+28h] [xbp-38h] BYREF void *v17; // [xsp+38h] [xbp-28h] char *error; // [xsp+40h] [xbp-20h] BYREF void *v19; // [xsp+48h] [xbp-18h]
sub_1915B10(v14); v12 = "Metadata"; v13 = 8LL; v2 = (void *)((unsigned __int64)LOBYTE(v14[0]) >> 1); if ( ((__int64)v14[0] & 1) != 0 ) v3 = (char *)p; else v3 = (char *)v14 + 1; if ( ((__int64)v14[0] & 1) != 0 ) v2 = v14[1]; error = v3; v19 = v2; sub_189CEEC((__int64)&error, (__int64)&v12, v16); if ( ((__int64)v14[0] & 1) != 0 ) operator delete(p); strlen_global_metadata_dat = strlen(string_global_metadata_dat); if ( ((__int64)v16[0] & 1) != 0 ) v5 = (char *)v17; else v5 = (char *)v16 + 1; if ( ((__int64)v16[0] & 1) != 0 ) v6 = v16[1]; else v6 = (void *)((unsigned __int64)LOBYTE(v16[0]) >> 1); v12 = string_global_metadata_dat; v13 = strlen_global_metadata_dat; error = v5; v19 = v6; sub_189CEEC((__int64)&error, (__int64)&v12, v14); LODWORD(error) = 0; v7 = osFileOpen((__int64)v14, 3, 1u, 1u, 0, &error); if ( (_DWORD)error ) { if ( ((__int64)v14[0] & 1) != 0 ) v8 = (const char *)p; else v8 = (char *)v14 + 1; sub_1915180("ERROR: Could not open %s", v8); } else { v9 = v7; metadata_mapped = (_BYTE *)utilsMemoryMappedFileMap(v7); sub_1892FFC(v9, &error); if ( !(_DWORD)error ) goto LABEL_22; sub_191534C(metadata_mapped); } metadata_mapped = 0LL;LABEL_22: if ( ((__int64)v14[0] & 1) != 0 ) operator delete(p); if ( ((__int64)v16[0] & 1) != 0 ) operator delete(v17); return metadata_mapped;}对比 Unity 的默认实现
void* il2cpp::vm::MetadataLoader::LoadMetadataFile(const char* fileName){ std::string resourcesDirectory = utils::PathUtils::Combine(utils::Runtime::GetDataDir(), utils::StringView<char>("Metadata"));
std::string resourceFilePath = utils::PathUtils::Combine(resourcesDirectory, utils::StringView<char>(fileName, strlen(fileName)));
int error = 0; os::FileHandle* handle = os::File::Open(resourceFilePath, kFileModeOpen, kFileAccessRead, kFileShareRead, kFileOptionsNone, &error); if (error != 0){ utils::Logging::Write("ERROR: Could not open %s", resourceFilePath.c_str()); return NULL; }
void* fileBuffer = utils::MemoryMappedFile::Map(handle);
os::File::Close(handle, &error); if (error != 0) { utils::MemoryMappedFile::Unmap(fileBuffer); fileBuffer = NULL; return NULL; }
return fileBuffer;}这段代码的特征和文章 某手游il2cpp逆向分析----libtprt保护 中的例子极其相似,对照进行分析
v7 = osFileOpen((__int64)v14, 3, 1u, 1u, 0, &error); 读取文件后紧跟着异常处理,然后进 utilsMemoryMappedFileMap
jp-6.4.0 提取 global-metadata.dat
20260401 补
取 base.apk 中的 global-metadata.dat,发现一样有加密
绘制直方图:

观察直方图,仍然有明显的统计特征,信息熵较小,排除 AES/DES,仍然考虑传统的循环异或
dd 或 GG 提取内存中的 global-metadata.dat
:/proc/5511 # cat maps | grep "metadata"708946341000-708947cc8000 rw-p 00000000 08:23 5114671 /storage/emulated/0/Android/data/com.sega.pjsekai/files/il2cpp/Metadata/global-metadata.dat将 dumper 出来的和 apk 中的进行 xor,辅助脚本如下
import sys
def main(): if len(sys.argv) != 4: print(f"Usage: python {sys.argv[0]} <file1> <file2> <output>") raise SystemExit(1)
file1, file2, output = sys.argv[1], sys.argv[2], sys.argv[3] chunk_size = 1024 * 1024
with open(file1, "rb") as f1, open(file2, "rb") as f2, open(output, "wb") as out: while True: b1 = f1.read(chunk_size) b2 = f2.read(chunk_size)
if not b1 or not b2: break
n = min(len(b1), len(b2)) out.write(bytes(x ^ y for x, y in zip(b1[:n], b2[:n])))
if __name__ == "__main__": main()验证了循环异或的猜想,但密钥已不是当年的密钥

于是将异或结果文件与 apk 中的文件再次异或(或截断 dumper 中文件),可以得到明文 global-metadata.dat,这里给出明文的 MD5 供复现参考 724056876AEA9BC85D514378F504627A
再从 /data/app/xxx/xxx/lib/arm64/libil2cpp.so 提取了一份 libil2cpp.so 并从内存中提取了两份看起来像的 ELF 文件
经 il2cppdumper 验证,/data/app/ 中的 libil2cpp.so 是最合适且有效的
导入 IDA 分析并重建符号即可
获取 AES key (成功)
配置工具链 root 物理机 + frida + frida-il2cpp-bridge
hook 下面脚本
脚本来自 Project SEKAI 逆向 - 笔记归档 - IL2CPP 动态调用 runtime
import "frida-il2cpp-bridge";
declare namespace console { function log(...args: any[]): void;}
Il2Cpp.perform(() => {
Il2Cpp.domain.assemblies.forEach((i)=>{ console.log(i.name); })
console.log("started") const game = Il2Cpp.domain.assembly("Assembly-CSharp").image; const apiManager = game.class("Sekai.APIManager"); Il2Cpp.gc.choose(apiManager).forEach((instance: Il2Cpp.Object) => { console.log("instance found") const crypt = instance.method<Il2Cpp.Object>("get_Crypt").invoke(); const aes = crypt.field<Il2Cpp.Object>("aesAlgo"); const key = aes.value.method("get_Key").invoke(); const iv = aes.value.method("get_IV").invoke(); console.log(key); console.log(iv); });});成功捕获 key 和 iv
# key = [103,50,102,99,67,48,90,99,122,78,57,77,84,74,54,49]# iv = [109,115,120,51,73,86,48,105,57,88,69,53,117,89,90,49]
key = b'g2fcC0ZczN9MTJ61'iv = b'msx3IV0i9XE5uYZ1'API 分析
MITM
工具:mitmproxy WireGurad MuMu模拟器
可以参阅详细教程 Mitmproxy方案使用教程 也可以参考下面简略教程
- 在 PC 上安装 mitmproxy
- 在电脑端安装证书
C:\Users\用户\.mitmproxy\mitmproxy-ca.p12 - 将
C:\Users\用户\.mitmproxy\mitmproxy-ca-cert.cer重命名为c8750f0d.0 - 将
c8750f0d.0移动到安卓设备的/system/etc/security/cacerts/c8750f0d.0或通过模块安装到系统 CA - 电脑端运行
mitmweb -m wireguard --no-http2 -s 脚本.py --set termlog_verbosity=warn --ignore 这里输入你的IP地址启动 mitmproxy - 安卓端启动 wireguard 并扫码连接
至此可以形成这样的代理链
正常情况下MobileDevice --------> --------> --------> --------> --------> The Server - - ->X mitmproxy (but not trut)
使用 MITMMobileDevice --------> --------> mitmproxy - - - - > - - - - > The Server(With MITM CA) | | (if redirection rule is set) v Hacker's ServerAPI URLs
抓包发现主要有如下几个地址 (My Sekai 烤森除外,这个好像是特殊的 api)
https://production-game-api.sekai.colorfulpalette.org -> 最最最主要的 api, GET POST PUT 都有https://game-version.sekai.colorfulpalette.org -> 只在启动时请求, 返回字段包含 profile assetbundleHostHash domain, 其中 domain 总为 https://production-game-api.sekai.colorfulpalette.orghttps://issue.sekai.colorfulpalette.org -> 并不常见, 似乎只在有问题的时候才会https://cdp.cloud.unity3d.com/v1/events -> u3d 收集信息的地址, 不用搭理只要第一个 api 即可实现基本游玩需求
分析加密
第一层显而易见是 HTTP,使用 mitmproxy 轻松解决
接下来的分析还是来自 Project SEKAI 逆向 - 笔记归档,先是一层 AES-CBC,密钥和初始化向量在本文的 动态分析-获取AES key 中获取
然后是 MessagePack 格式的信息,可以直接解码得到 json
临时分析时可以使用 CyberChef 对流量包轻松解密

为了便于 mitmproxy 自动化抓包解密和后期可能的 PS 计划,编写 python 加解密脚本如下
import jsonimport msgpackfrom Crypto.Cipher import AESfrom Crypto.Util.Padding import pad, unpad
# get key and iv from frida-il2cpp-bridge firstkey = b'g2fcC0ZczN9MTJ61'iv = b'msx3IV0i9XE5uYZ1'
def decrypt_aes_cbc(data: bytes) -> bytes: cipher = AES.new(key, AES.MODE_CBC, iv) decrypted_data = unpad(cipher.decrypt(data), AES.block_size) return decrypted_data
def encrypt_aes_cbc(data: bytes) -> bytes: cipher = AES.new(key, AES.MODE_CBC, iv) padded_data = pad(data, AES.block_size) encrypted_data = cipher.encrypt(padded_data) return encrypted_data
def encode_msgpack(data: dict) -> bytes: if data is None: raise ValueError("Input data for encode_msgpack cannot be None") packed = msgpack.packb(data, use_bin_type=True) if not isinstance(packed, bytes): raise TypeError("msgpack.packb did not return bytes") return packed
def decode_msgpack(data: bytes) -> dict: return msgpack.unpackb(data, raw=False)
def pjsk_encode_msgpack(data: dict) -> bytes: packed = encode_msgpack(data) encrypted = encrypt_aes_cbc(packed) return encrypted
def pjsk_decode_msgpack(data: bytes) -> dict: decrypted = decrypt_aes_cbc(data) decoded = decode_msgpack(decrypted) return decoded注:除了极个别 request 中是 16 字节的 magic payload (在本文的测试环境和测试账号中是 ffa3bd6214f33fe73cb72fee2262bedb) 外,其余所有 request/response 的payload 均以该方法加密,本文的剩余部分将直接解密不再重复强调
20260604 补充:
如图,所谓 Magic Payload 实际上只是零字节的 AES-CBC 加密,如果禁用 unpad 就能发现了。我明明应当第一时间捕捉到这个关键点,却没有及时发现问题,为此感到十分羞愧。

[整活] 十连十彩 (成功)
在正式 API 分析前,整个活
目的:在不侵害原 colorful stage 任何利益的前提下,获得十连十彩的视频
观察发现抽卡的时候会向形如 https://production-game-api.sekai.colorfulpalette.org/api/user/1145141919810/gacha/803/gachaBehaviorId/3139?isPriorityUsePaidJewel=False 的 API 发送 request,并在 response 中返回一大堆东西,response 的 obtainPrizes 键下有十个卡牌信息,便是抽卡结果信息,例如
"obtainPrizes": [ { "card": { "resourceId": 1236, "resourceType": "card", "resourceLevel": 1, "quantity": 1 }, "newFlg": true, "gachaLotteryType": "normal", "costume3d": [ { "resourceId": 844137, "resourceType": "costume_3d", "resourceLevel": 0, "quantity": 1 }, { "resourceId": 844138, "resourceType": "costume_3d", "resourceLevel": 0, "quantity": 1 }, { "resourceId": 844161, "resourceType": "costume_3d", "resourceLevel": 0, "quantity": 1 } ], "cardExtra": [] }, { "card": { "resourceId": 972, "resourceType": "card", "resourceLevel": 1, "quantity": 1 }, "newFlg": false, "gachaLotteryType": "normal", "costume3d": [], "cardExtra": [] }, { "card": { "resourceId": 127, "resourceType": "card", "resourceLevel": 1, "quantity": 1 }, "newFlg": false, "gachaLotteryType": "normal", "costume3d": [], "cardExtra": [] }, { "card": { "resourceId": 10, "resourceType": "card", "resourceLevel": 1, "quantity": 1 }, "newFlg": false, "gachaLotteryType": "normal", "costume3d": [], "cardExtra": [] }, { "card": { "resourceId": 316, "resourceType": "card", "resourceLevel": 1, "quantity": 1 }, "newFlg": false, "gachaLotteryType": "normal", "costume3d": [], "cardExtra": [] }, { "card": { "resourceId": 26, "resourceType": "card", "resourceLevel": 1, "quantity": 1 }, "newFlg": false, "gachaLotteryType": "normal", "costume3d": [], "cardExtra": [] }, { "card": { "resourceId": 431, "resourceType": "card", "resourceLevel": 1, "quantity": 1 }, "newFlg": false, "gachaLotteryType": "normal", "costume3d": [], "cardExtra": [] }, { "card": { "resourceId": 431, "resourceType": "card", "resourceLevel": 1, "quantity": 1 }, "newFlg": false, "gachaLotteryType": "normal", "costume3d": [], "cardExtra": [] }, { "card": { "resourceId": 401, "resourceType": "card", "resourceLevel": 1, "quantity": 1 }, "newFlg": false, "gachaLotteryType": "normal", "costume3d": [], "cardExtra": [] }, { "card": { "resourceId": 148, "resourceType": "card", "resourceLevel": 1, "quantity": 1 }, "newFlg": false, "gachaLotteryType": "normal", "costume3d": [], "cardExtra": [] }],obtainPrizes 下一共 10 个卡面信息,resourceId 为卡面 ID,可在 SekaiViewer 快速获取,newFlg 指示是否为第一次抽到, newFlg 为 true 且具有服装的真四星需要附加 costume3d 信息
因此思路很简单,截取服务器返回包,改 resourceId 为十个四星卡即可
exp 如下
# the more miku the better!# author: [MetaMiku](https://github.com/MetaMikuAI)
from mitmproxy import httpfrom Crypto.Cipher import AESfrom Crypto.Util.Padding import pad, unpadimport msgpackimport json
# get key and iv from frida-il2cpp-bridge firstkey = b'g2fcC0ZczN9MTJ61'iv = b'msx3IV0i9XE5uYZ1'
target_url_list = [ "https://production-game-api.sekai.colorfulpalette.org",]
def decrypt_aes_cbc(data: bytes) -> bytes: cipher = AES.new(key, AES.MODE_CBC, iv) decrypted_data = unpad(cipher.decrypt(data), AES.block_size) return decrypted_data
def encrypt_aes_cbc(data: bytes) -> bytes: cipher = AES.new(key, AES.MODE_CBC, iv) padded_data = pad(data, AES.block_size) encrypted_data = cipher.encrypt(padded_data) return encrypted_data
def encode_msgpack(data: dict) -> bytes: if data is None: raise ValueError("Input data for encode_msgpack cannot be None") packed = msgpack.packb(data, use_bin_type=True) if not isinstance(packed, bytes): raise TypeError("msgpack.packb did not return bytes") return packed
def decode_msgpack(data: bytes) -> dict: return msgpack.unpackb(data, raw=False)
def dict_to_pretty_json(data: dict) -> str: return json.dumps(data, ensure_ascii=False, indent=2)
def pjsk_encrypt_msgpack(data: dict) -> bytes: packed = encode_msgpack(data) encrypted = encrypt_aes_cbc(packed) return encrypted
def pjsk_decrypt_msgpack(data: bytes) -> dict: decrypted = decrypt_aes_cbc(data) decoded = decode_msgpack(decrypted) return decoded
def hack_gacha_results(data: dict) -> dict: target_data = { "card": { "resourceId": 1235, "resourceType": "card", "resourceLevel": 1, "quantity": 1 }, "newFlg": False, "gachaLotteryType": "normal", "costume3d": [], "cardExtra": [] }
gacha_results = data["obtainPrizes"] for idx, result in enumerate(gacha_results): id = result["card"]["resourceId"] print(f"Gacha ID: {id}") gacha_results[idx] = target_data
data["obtainPrizes"] = gacha_results return data
def request(flow: http.HTTPFlow) -> None: # type: ignore for target_url in target_url_list: print(flow.request.pretty_url) if flow.request.pretty_url.startswith(target_url): pass
def response(flow: http.HTTPFlow) -> None: # type: ignore for target_url in target_url_list: print(flow.request.pretty_url) if flow.request.pretty_url.startswith(target_url): if "gachaBehaviorId" in flow.request.pretty_url: original_data = pjsk_decrypt_msgpack(flow.response.content) modified_data = hack_gacha_results(original_data) re_encrypted = pjsk_encrypt_msgpack(modified_data) flow.response.content = re_encrypted
# mitmweb -m wireguard --no-http2 -s ./mitmproxy.py --set termlog_verbosity=info --ignore 127.0.0.1the more Miku the better!

分析 JWT (基本完成)
- 观察到流量中有大量 JWT 以
X-Session-TokencredentialsessionTokensignatureparam等处出现 - 除
param外,所有 jwt 均包含用户的userId和一个未知含义的 uuid - 所有 jwt 的签名密钥均未知 (也有可能是我菜,尝试 frida hook 了一些哈希函数无果,大概本地完全不验签,只有服务器会验签)
- 所有 jwt 的 header 均为:
{ "typ": "JWT", "alg": "HS256"}速写了一段用于 JWT 分析(加解密函数略)
from mitmproxy import http
LOG_FILE = "analyze_jwt.txt"
targets = [ "https://production-game-api.sekai.colorfulpalette.org"]
def request(flow: http.HTTPFlow) -> None: for target in targets: if flow.request.pretty_url.startswith(target): with open(LOG_FILE, "a") as f: f.write(f"Request: {flow.request.method} {flow.request.pretty_url}\n") if "X-Session-Token" in flow.request.headers: f.write(f"X-Session-Token: {flow.request.headers['X-Session-Token']}\n") if flow.request.content: if len(flow.request.content) == 16: return
try: data = pjsk_decode_msgpack(flow.request.content) jwt_fields = detect_jwt_in_json(data) if jwt_fields: f.write("Detected JWT fields in JSON payload:\n") f.write(json.dumps(jwt_fields, indent=2) + "\n") except json.JSONDecodeError: pass f.write("\n")
def response(flow: http.HTTPFlow) -> None: for target in targets: if flow.request.pretty_url.startswith(target): with open(LOG_FILE, "a") as f: f.write(f"Response: {flow.request.method} {flow.request.pretty_url}\n") if "X-Session-Token" in flow.response.headers: f.write(f"X-Session-Token: {flow.response.headers['X-Session-Token']}\n") if flow.response.content: if len(flow.request.content) == 16: return try: data = pjsk_decode_msgpack(flow.response.content) jwt_fields = detect_jwt_in_json(data) if jwt_fields: f.write("Detected JWT fields in JSON payload:\n") f.write(json.dumps(jwt_fields, indent=2) + "\n") except json.JSONDecodeError: pass f.write("\n\n")
# 毕竟是临时的脚本, 嵌套多了一点就多一点吧 -- MetaMiku
def is_jwt(token: str) -> bool: parts = token.split('.') return len(parts) == 3 and all(part.isascii() for part in parts) and not parts[0].startswith('http') and len(token) > 32
def detect_jwt_in_json(data: dict) -> dict: jwt_fields = {} for key, value in data.items(): if isinstance(value, str) and is_jwt(value): jwt_fields[key] = value elif isinstance(value, dict): nested_jwt = detect_jwt_in_json(value) if nested_jwt: jwt_fields[key] = nested_jwt elif isinstance(value, list): for item in value: if isinstance(item, dict): nested_jwt = detect_jwt_in_json(item) if nested_jwt: jwt_fields[key] = nested_jwt return jwt_fields- 启动游戏时会发送第一个请求
GET /api/system不携带X-Session-Token - 然后访问
/api/user/<userId>/auth?refreshUpdatedResources=False并在 payload 中携带credential,每次登录的时候都会携带该credential不变 (该credential与用户绑定,所谓更新丢账号应该就是丢的它),服务端返回sessionToken - 在此之后,客户端的所有请求都需携带该
sessionToken - 服务端除
/api/system和/api/information外,都需要返回新的sessionToken,客户端收到后更新sessionToken,并在下次请求中携带新的sessionToken(平时网络不好导致卡退到主界面大概就是因为这个不同步了吧)保证了前向安全性 - 该
credential不随 重新登录 或 设置引继码 而变,但是会在确认继承引继码时接收服务器分发的新的credential userRegistration的signature似乎永远不变- 注:jwt 的签名密钥无从得知,如果需要在官方服务器和个人服务器之间反复测试的话,个人服务器不得分发新的
credential(但是可以重放),以免账号丢失(20260616补充:SNA 和 引继码相关 jwt 还是可以在客户端审计到的)
注册新账号
- 携带本机设备硬件信息访问
/api/user
{ "platform": "Android", "deviceModel": "Samsung SM-S9210", "operatingSystem": "Android OS 12 / API-32 (V417IR/1974)"}服务端将返回一个包括 userRegistration credential updatedResources 三个字段的新用户包
- 客户端携带
credential访问/api/user/<user_id>/auth?refreshUpdatedResources=False以请求本次会话的初始X-Session-Token(注:此次请求可能会携带旧账号的X-Session-Token),该包其余部分与新账号无关,注意到其中有suiteMasterSplitPath字段如
{ "sessionToken": "脱敏", "appVersion": "5.6.0", "multiPlayVersion": "miku", "dataVersion": "5.6.0.30", "assetVersion": "5.6.0.30", "removeAssetVersion": "1.13.0.30", "assetHash": "5242f35c-e32d-bafb-a488-74b247052713", "appVersionStatus": "available", "isStreamingVirtualLiveForceOpenUser": false, "deviceId": "c8b6d5f1-f96a-4588-b650-c2a7452e4bd6", "updatedResources": {}, "suiteMasterSplitPath": [ "suitemasterfile/5.6.0.30/00_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41", "suitemasterfile/5.6.0.30/01_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41", "suitemasterfile/5.6.0.30/02_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41", "suitemasterfile/5.6.0.30/03_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41", "suitemasterfile/5.6.0.30/04_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41", "suitemasterfile/5.6.0.30/05_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41", "suitemasterfile/5.6.0.30/06_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41" ], "obtainedBondsRewardIds": []}- 然后客户端则携带
X-Session-Token先后访问了
GET /api/suitemasterfile/5.6.0.30/00_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41GET /api/suitemasterfile/5.6.0.30/01_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41GET /api/suitemasterfile/5.6.0.30/02_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41GET /api/suitemasterfile/5.6.0.30/03_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41GET /api/suitemasterfile/5.6.0.30/04_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41GET /api/suitemasterfile/5.6.0.30/05_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41GET /api/suitemasterfile/5.6.0.30/06_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41经测验,虽然携带了 X-Session-Token,但实际上需要的是包含完整 CloudFront 的 Cookie

巨长的数据,编码后都有这么大,霓虹人太可怕了
[整活]强制 All Perfect
警告:此脚本存在封号风险,切勿使用

from mitmproxy import httpfrom time import timefrom crypto import prsk_dec, prsk_encimport jsonfrom random import randint
def request(flow: http.HTTPFlow) -> None: # /api/user/<userId>/live/<userLiveId> if "/api/user/" in flow.request.pretty_url and "/live/" in flow.request.pretty_url: if flow.request.content is None: return body = prsk_dec(flow.request.content) if "score" in body: body["score"] = randint(90000, 120000) body["life"] = 1000 perfectCount = body["perfectCount"] greatCount = body["greatCount"] goodCount = body["goodCount"] badCount = body["badCount"] missCount = body["missCount"] maxCombo = body["maxCombo"] total = perfectCount + greatCount + goodCount + badCount + missCount body["perfectCount"] = total body["greatCount"] = 0 body["goodCount"] = 0 body["badCount"] = 0 body["missCount"] = 0 body["maxCombo"] = total body["tapCount"] = total*2 + randint(0, 100) flow.request.content = prsk_enc(body) print(f"Modified live data: {body}")[整活] 自定义名片预览图
目标:上传自定义图片
观察发现用完名片后,会向 https://production-game-api.sekai.colorfulpalette.org/api/user/{user_id}/custom-profile/1/custom-profile-card/{card_id} 上传自定义名片数据,其中新增名片为 POST,修改名片为 PUT
Request 数据如下
{ "thumbnail": "此处为图片的预览图,格式为 JFIF base64,大小为 1190w * 528h", "customProfileCard": { "version": 3, "generals": [], "generalBackgrounds": [], "storyBackgrounds": [], "standMembers": [], "cardMembers": [], "honors": [], "bondsHonors": [], "texts": [ { "objectData": { "position": { "x": -18.276344299316406, "y": -13.701630592346191, "z": 0 }, "scale": { "x": 1, "y": 1, "z": 1 }, "rotation": { "x": 0, "y": 0, "z": 0, "w": 1 }, "layer": 0, "lock": false, "visible": true }, "text": "hello", "fontId": 1, "type": 513, "colorId": 1, "size": 24, "outlineColorId": 1, "outlineSize": 0, "lineSpacing": 0 } ], "collections": [], "others": [], "shapes": [], "stamps": [] }}很简单,自己构造一个相同大小的 jpeg,b64 编码后上传即可
一个基本的 exp 如下
# Patch custom-profile-cardfrom mitmproxy import httpfrom crypto import prsk_dec, prsk_enc # type: ignore
target_image_b64 = Noneassert target_image_b64 is not None, "Please set target_image_b64 variable"
def request(flow: http.HTTPFlow) -> None: if flow.request.method in ["POST", "PUT"] and "custom-profile-card" in flow.request.pretty_url: if flow.request.content is None: return request_data = prsk_dec(flow.request.content) request_data['thumbnail'] = target_image_b64 flow.request.content = prsk_enc(request_data)成品图:

继续可以搞一个 大 一点的图
先用 ffmpeg 切片并压缩
ffmpeg -i input.jpg -filter_complex \"[0:v]crop=iw/3:ih/3:0:0[out1]; \ [0:v]crop=iw/3:ih/3:iw/3:0[out2]; \ [0:v]crop=iw/3:ih/3:2*iw/3:0[out3]; \ [0:v]crop=iw/3:ih/3:0:ih/3[out4]; \ [0:v]crop=iw/3:ih/3:iw/3:ih/3[out5]; \ [0:v]crop=iw/3:ih/3:2*iw/3:ih/3[out6]; \ [0:v]crop=iw/3:ih/3:0:2*ih/3[out7]; \ [0:v]crop=iw/3:ih/3:iw/3:2*ih/3[out8]; \ [0:v]crop=iw/3:ih/3:2*iw/3:2*ih/3[out9]" \-map "[out1]" 1.jpg \-map "[out2]" 2.jpg \-map "[out3]" 3.jpg \-map "[out4]" 4.jpg \-map "[out5]" 5.jpg \-map "[out6]" 6.jpg \-map "[out7]" 7.jpg \-map "[out8]" 8.jpg \-map "[out9]" 9.jpgexp
from mitmproxy import httpfrom base64 import b64encodefrom crypto import prsk_dec, prsk_enc # type: ignore
target_dir_path = '~/prsk/test/patchimages/'
# POST/PUT https://production-game-api.sekai.colorfulpalette.org/api/user/{user_id}/custom-profile/1/custom-profile-card/{card_id}def request(flow: http.HTTPFlow) -> None: if flow.request.method in ["POST", "PUT"] and "custom-profile-card" in flow.request.pretty_url: if flow.request.content is None: return card_id = flow.request.pretty_url.split("/")[-1] request_data = prsk_dec(flow.request.content) target_image_path = target_dir_path + str(card_id) + '.jpg' with open(target_image_path, 'rb') as f: image = f.read()
request_data['thumbnail'] = b64encode(image).decode('utf-8') flow.request.content = prsk_enc(request_data) # print(request_data) # check the modified request data but not actually send it print(f"Replaced thumbnail for card_id {card_id} from {target_image_path}")成果图

不过预览图应该是仅自己可见的,要想真改还得想办法注入名片
一点碎碎念
20260331,在给游戏「东方夜雀食堂」做了五个多月的 mod 后,最近得空回来看一下,但是发现最近怎么 PS 遍地飞了??小小打探一下,好像没比我的进度多多少(?
这太怪了,而且这大张旗鼓的,有点可怕。
不过在有群 u 说开源方面我的进度是最快的,我还是很开心的 heart~
修改 API-url
这是做 PS 不得不品的一环
注意,以下方法并不是可以也绝不是允许非法公开分发版本的制作方式,仅仅是技术讨论以及对下文“防护意见”的讨论 任何人不允许进行实际的公开分发行为
偶然搞到一份 API-url 改到 127.0.0.1 的样本,CN-6.0.0
解包简单 grep 下
└─# grep -rn "127.0.0.1" .grep: ./assets/bin/Data/Managed/Metadata/global-metadata.dat: binary file matches./assets/url_config.json:4: "passport_host_merge": "127.0.0.1:18081",./assets/url_config.json:6: "passport_host": "127.0.0.1:18081",./assets/url_config.json:13: "bsdk_server_url": "http://127.0.0.1:18081/",./assets/url_config.json:14: "gsdk_server_url": "http://127.0.0.1:18081/",./assets/url_config.json:15: "gsdk_server_url_sandbox": "http://127.0.0.1:18081/",./assets/url_config.json:22: "bsdk_host": "127.0.0.1:18081",./assets/url_config.json:24: "gsdk_host": "127.0.0.1:18081",./assets/url_config.json:26: "gsdk_host_sandbox": "127.0.0.1:18081",./assets/url_config.json:35: "geas_host": "http://127.0.0.1:18081/",./assets/url_config.json:36: "gs_server_url": "http://127.0.0.1:18081/",./assets/url_config.json:38: "gs_server_url_sandbox": "http://127.0.0.1:18081/",url_config?谁家好人把这么重要的配置直接塞在 apk 里面?经检查 JP-6.4.0 不包含该文件
字节代理的两个服的 url_config 位置如下
cn-6.0.0.apk/assets/url_config.jsontw-6.0.0.xapk/installtime.apk/assets/url_config.json猜测该文件只是字节渠道服所设计,日服不应有该文件
因此首先考虑审计 cn-6.0.0 的渠道层,推测有关 HOST 表的内容位于 Java 层而不在 il2cpp 层
package com.bytedance.ttgame.core.init;
public final class ConfigParserCommon { private static final String CONFIG_FILE_NAME = "config.json"; private static final String CONFIG_URL_NAME = "url_config.json"; public static ChangeQuickRedirect changeQuickRedirect; private static Gson sGson = new Gson(); private Config config; private JsonObject mConfigJson; private JsonObject mSdkJson;
private void parseUrlConfigJsonFile(Context context) throws IOException { if (PatchProxy.proxy(new Object[]{context}, this, changeQuickRedirect, false, "f979d4a46b8a655f4dc6cc3eb20fc165") != null) { return; } InputStream inputStreamOpen = null; try { try { try { inputStreamOpen = context.getAssets().open(CONFIG_URL_NAME); byte[] bArr = new byte[inputStreamOpen.available()]; inputStreamOpen.read(bArr); JsonObject asJsonObject = ((JsonObject) new JsonParser().parse(new String(bArr, "UTF-8"))).getAsJsonObject("urls"); if (asJsonObject != null) { this.config.urlConfig = (HashMap) sGson.fromJson(asJsonObject, new TypeToken<HashMap<String, Object>>() { // from class: com.bytedance.ttgame.core.init.ConfigParserCommon.3 }.getType()); } if (inputStreamOpen != null) { inputStreamOpen.close(); } } catch (Throwable th) { if (inputStreamOpen != null) { try { inputStreamOpen.close(); } catch (IOException e) { e.printStackTrace(); } } throw th; } } catch (Exception e2) { e2.printStackTrace(); if (inputStreamOpen != null) { inputStreamOpen.close(); } } } catch (IOException e3) { e3.printStackTrace(); } }}可以看到是字节渠道服负责读取了 url_config.json,而这在 jp-6.4.0 是没有的。
分析 jp-6.4.0,在 java 层似乎找不到任何 API_URL 的配置,因此逆 libil2cpp.so
使用之前解密的 libil2cpp.so 和 global-metadata.dat 通过 il2cppDumper 解包
用 dnspy 打开 DummyDll/Assembly-CSharp.dll,容易找到
private static string apiUrlBase;private static string sekaiGameAPIDomain;private static AppInfoResponse appInfo;
// Token: 0x17000B56 RID: 2902// (get) Token: 0x06004E80 RID: 20096 RVA: 0x00002050 File Offset: 0x00000250[Token(Token = "0x17000B56")]public static string ApiUrlBase{ [Token(Token = "0x6004E80")] [Address(RVA = "0x5B0FF24", Offset = "0x5B0BF24", VA = "0x5B0FF24")] get { if (string.IsNullOrEmpty(apiUrlBase)) { if (appInfo != null) { SetupApiEndpoint(appInfo.domain); } else { LogUtility.LogError("appInfoを取得していないためAssetBundleInfoの実行ができません", Array.Empty<object>()); return string.Empty; } } return apiUrlBase; }}
// Token: 0x06004E97 RID: 20119 RVA: 0x00002053 File Offset: 0x00000253[Token(Token = "0x6004E97")][Address(RVA = "0x5B100BC", Offset = "0x5B0C0BC", VA = "0x5B100BC")]public static void SetupApiEndpoint(string domain){ apiUrlBase = string.Format("https://{0}/api/", domain); sekaiGameAPIDomain = string.Format("https://{0}/", domain);}// Token: 0x06003783 RID: 14211 RVA: 0x00002050 File Offset: 0x00000250[Token(Token = "0x6003783")][Address(RVA = "0x5A0AC50", Offset = "0x5A06C50", VA = "0x5A0AC50", Slot = "9")]public override string Execute(APICore<EmptyRequest, AppInfoResponse>.OnAPIEventHandler onCallBackResponse){ if (onCallBackResponse != null) { this.onFinishAPI = (APICore<EmptyRequest, AppInfoResponse>.OnAPIEventHandler)Delegate.Combine(this.onFinishAPI, onCallBackResponse); } string api = $"https://game-version.sekai.colorfulpalette.org/{EnvironmentConfig.ClientVersionAPI}/{EnvironmentConfig.ClientAppHash}"; return base.CallFullURL(api, APICoreParam.Method.GET, null, new APICore<EmptyRequest, AppInfoResponse>.OnAPIEventHandler(this.OnCallBack), null, true, null);}
// Token: 0x06003784 RID: 14212 RVA: 0x00002053 File Offset: 0x00000253[Token(Token = "0x6003784")][Address(RVA = "0x5A0ADF0", Offset = "0x5A06DF0", VA = "0x5A0ADF0")]private void OnCallBack(APICore<EmptyRequest, AppInfoResponse> apiCore){ if (apiCore.Result.State == APIState.SuccessComplete) { EnvironmentConfig.SetAppInfo(apiCore.Response); } this.onFinishAPI?.Invoke(apiCore);}注:以上代码为手工复原,并非直接反编译得到,因此可能存在细微差别
因此考虑修改 https://game-version.sekai.colorfulpalette.org/{0}/{1},但是如何改呢?改 dnspy 肯定是不行的,似乎并没有一个方法可以将 Assembly-CSharp 重新打包回 apk,那么回顾一下现有各文件的来源:
能改的无非是改原 libil2cpp.so 和 global-metadata.dat,那么这个地址位于哪里呢?VERSION_API_URL_BASE_FORMAT 作为字符串常量,自然是存放在 global-metadata.dat 中,这涉及到 global-metadata.dat 的作用原理之一,中文互联网已有很多讨论,此文不再赘述。


一共有两处需要修改的地方,且为了避免修改原有地址长度进而导致整个 global-metadata.dat 失效,此处使用 aaaa 字符串进行占位填充
保存,异或加密,再打包进 APK/assets/bin/Data/Managed/Metadata/global-metadata.dat 中,签名安装,抓包测试
很不幸,在正常的游戏中,我们是能够看到一个对 GET https://game-version.sekai.colorfulpalette.org/6.4.0/6bad6856-ef61-eb43-47f5-dbc95fc5967c 的请求,然而在修改的安装包中,无法看到有这样一个 GET http://192.168.0.100:5000/ 的请求。
无法看到本应有的 GET 请求,说明我们找的切入点没问题;游戏无法正常发送请求,说明仍有某些点没有考虑到。
不过如果有 Android 相关开发经验,也许会记得,从某个版本起,Android 默认禁止了 HTTP 明文传输,也就是必须通过 HTTPS 进行通信。
为此,重新改包为 https 地址进行测试,成功抓到请求:
![]()
然而我们是正经审计,不能让其通入外网,更不可能有 HTTPS 了,因此重新将内网地址打包进去,并在 AndroidMainfest 中配置 cleartextTrafficPermitted="true"
可惜还是不行,抓日志发现
PS E:\> adb logcat -s Unity--------- beginning of main04-03 11:30:19.810 14331 14421 W Unity : Non-secure network connections disabled in Player Settings04-03 11:30:19.810 14331 14421 W Unity : UnityEngine.Networking.UnityWebRequest:SendWebRequest()04-03 11:30:19.810 14331 14421 W Unity : Sekai.<SendRequest>d__35:MoveNext()04-03 11:30:19.810 14331 14421 W Unity : UnityEngine.SetupCoroutine:InvokeMoveNext(IEnumerator, IntPtr)04-03 11:30:19.810 14331 14421 W Unity : Sekai.APIManager:CallAPIFull(String, Method, A, OnAPIEventHandler, Boolean, APIExecuteBehaviourParam, OnAPIEventHandler, Dictionary`2)04-03 11:30:19.810 14331 14421 W Unity : Sekai.APICaller`2:CallFullURL(String, Method, A, OnAPIEventHandler, OnAPIEventHandler, Boolean, Dictionary`2)04-03 11:30:19.810 14331 14421 W Unity : Sekai.GetAppInfoAPI:Execute(OnAPIEventHandler)04-03 11:30:19.810 14331 14421 W Unity : Sekai.APIExecutor:Execute(IAPICaller`2, OnAPIEventHandler, Action, AfterErrorDetectionType, AfterInterruptionType, Boolean, Boolean, Action)04-03 11:30:19.810 14331 14421 W Unity : CP.API.APIUtility:ExecuteAppInfoAPI(Action`1, Boolean)04-03 11:30:19.810 14331 14421 W Unity : Sekai.TitleController:Login(Action`1)04-03 11:30:19.810 14331 14421 W Unity : CP.<DelayCallCore>d__12:MoveNext()04-03 11:30:19.810 14331 14421 W Unity : UnityEngine.SetupCoroutine:InvokeMoveNext(IEnumerator, IntPtr)04-03 11:30:19.810 14331 14421 W Unity :04-03 11:30:19.810 14331 14421 W Unity : [ line -146986952]04-03 11:30:19.810 14331 14421 W Unity :应该是 HTTP 请求被 Unity 层拦下了,审计 libunity.so,该文件也无加密
libunity.so 并不大,可以全局搜索字符串
__int64 __fastcall sub_936FAC(__int64 a1){ _QWORD *v2; // x19 _QWORD *v3; // x0 int v5; // w8 __int64 *v6; // x9 _QWORD v7[5]; // [xsp+0h] [xbp-C0h] BYREF __int128 v8; // [xsp+28h] [xbp-98h] int v9; // [xsp+38h] [xbp-88h] __int64 v10; // [xsp+40h] [xbp-80h] char v11; // [xsp+48h] [xbp-78h] __int64 v12; // [xsp+50h] [xbp-70h] int v13; // [xsp+58h] [xbp-68h] char *v14; // [xsp+60h] [xbp-60h] char *v15; // [xsp+68h] [xbp-58h] __int64 v16[4]; // [xsp+70h] [xbp-50h] BYREF char v17; // [xsp+90h] [xbp-30h] unsigned int v18; // [xsp+94h] [xbp-2Ch] __int64 v19; // [xsp+98h] [xbp-28h]
v2 = (_QWORD *)(a1 + 136); v19 = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2)) + 40); if ( *(_BYTE *)(a1 + 168) == 1 ) v3 = (_QWORD *)(a1 + 136); else v3 = (_QWORD *)*v2; if ( (unsigned int)sub_64B840(v3, "http:", 5LL) ) return 1LL; if ( *(_BYTE *)(a1 + 168) != 1 ) v2 = (_QWORD *)*v2; if ( (sub_937134(v2) & 1) != 0 ) return 1LL; v5 = *(_DWORD *)(sub_DD534C() + 716); if ( v5 ) { if ( v5 != 1 ) return 1LL; sub_64C614(v16, "Non-secure HTTP connections disabled in release builds"); v11 = 1; v6 = (__int64 *)v16[0]; if ( v17 == 1 ) v6 = v16; v12 = 0LL; v13 = 0; v9 = 0; v7[2] = &byte_15D8D4; v7[3] = &byte_15D8D4; v14 = &byte_15D8D4; v15 = &byte_15D8D4; v7[4] = &byte_15D8D4; v8 = xmmword_17DB60; v7[0] = v6; v7[1] = &byte_15D8D4; v10 = 0LL; sub_DCEB10(v7); if ( !v17 ) sub_4BADF4(v16[0], v18, &byte_15D8D4, 518LL); } return 0LL;}还可以发现一处
char *__fastcall sub_937134(const char *a1){ char *result; // x0 char *v2; // x19 char *v3; // x20 size_t v4; // x21 char *v5; // x0 size_t v6; // x20 _BYTE *v7; // x0
result = strstr(a1, "://"); if ( result ) { v2 = result + 3; if ( !result[3] ) return 0LL; result = strchr(result + 3, 47); if ( !result ) return result; v3 = result; v4 = result - v2; if ( result == v2 ) { return 0LL; } else { v5 = (char *)memchr(v2, 64, v4); if ( v5 ) v6 = v3 - (v5 + 1); else v6 = v4; if ( v5 ) v2 = v5 + 1; v7 = memchr(v2, 58, v6); if ( v7 ) v6 = v7 - v2; if ( !strncmp(v2, "localhost", v6) ) return (_BYTE *)(&dword_0 + 1); else return (char *)(strncmp(v2, "127.0.0.1", v6) == 0); } } return result;}sub_937134 中确认了 127.0.0.1 localhost 的白名单,而 sub_936FAC 校验了 HTTP
可以考虑先改 URL 为 127.0.0.1:8831,然后用 adb reverse tcp:8831 tcp:8831 反向映射,在电脑用 ncat 来接请求验证
我这里用的是 8831 端口,成功接收到 HTTP 请求如图

对图中该请求固定返回
{ profile = "production", assetbundleHostHash = "cf2d2388", domain = "127.0.0.1:8831"}但抓包发现该请求并没有成功被发出
重新认真审计 libil2cpp.so 可以发现,有一点之前忽略了
// Token: 0x06004E97 RID: 20119 RVA: 0x00002053 File Offset: 0x00000253[Token(Token = "0x6004E97")][Address(RVA = "0x5B100BC", Offset = "0x5B0C0BC", VA = "0x5B100BC")]public static void SetupApiEndpoint(string domain){ apiUrlBase = string.Format("https://{0}/api/", domain); sekaiGameAPIDomain = string.Format("https://{0}/", domain);}如果 domain="127.0.0.1:8831" ,则格式化后得到 https://127.0.0.1:8831/api/ 和 https://127.0.0.1:8831,其中 https 和 127.0.0.1 一起出现似乎构成了一个不好的 URL,因此进一步修改这两个字符串:

如此一来,原本应该发给 https://production-game-api.sekai.colorfulpalette.org/api/xxxx 的请求转而发给 http://127.0.0.1/AAAA/xxxx,相应地接收 API 即可。
至此,成功将所需权限从 root(用于安装 mitm 所需的 CA 证书) 降级到 adb (用于转发请求)
不过,使用 AAA 进行占位填充并不是一个很优雅的解决方案,可以利用 C# 格式化字符串的特性(即使已被 il2cpp),我们知道
string.Format("abc{0}ghi", "def"); // 输出 "abcdefghi"但是我们可以构造模板
string.Format("abc{00}ghi", "def"); // 同样输出 "abcdefghi"因此我们可以巧妙地利用多个 0 来占位,来替换使用 AAA 进行占位填充的做法,来得到一个更优雅的解决方案
不过这只是 C# 层面的推测,但经过实际测试,il2cpp 似乎也保留了 C# 这种字符串格式化的特性
因此最终所做 patch 如下:



20260719 注:如果不想动脑子凑 padding 的话,也可以直接使用工具 JeremieCHN/MetaDataStringEditor,无脑修改这三个字符串即可。
防护意见
在修改 API-URL 这一段攻击链中,主要涉及以下几个方面的问题:
global-metadata.dat解密过于简单且可逆,导致攻击者可以自由解密和重加密libil2cpp.so明文存于apk中,易于解密、审计和篡改apk缺少强签名校验,很容易被篡改
对此我有几个不成熟的修复方案仅供参考,
- 最简单最粗暴的方法,给
apk加壳加强签名校验,篡改后无法安装 - 使用非对称加密来保护
global-metadata.dat,使攻击者无法重加密,将弱点转移至保护较短的、更便于隐藏和混淆的解密公钥上 - 关键常量字符串混淆或拼接,增加攻击者的审计难度
- 移除对回环地址的白名单
- 对代码进行混淆,避免攻击者轻易找到切入点(我总记得以前是有混淆的来着?)
- 移除
url_config.json,将渠道服和非渠道服的差异化配置放在代码中进行编译时区分,而不是放在外部文件中 - 使用极短的域名来获取实际 API 地址,限制攻击者的篡改范围
- 使用自行签发的证书,拒绝其他包括根证书在内的证书,增加攻击者的测试难度
- 参考其他游戏,如「明⚪⚪舟」,有极为严苛的环境检查,从开始即限制攻击者的抓包分析和修改测试
- 现有逻辑是极为标准的 Unity il2cpp 游戏模型,可以考虑对敏感逻辑进行特殊化处理,减少同质化攻击面
PS 编写
尝试中,霓虹人定义的数据结构太恐怖了
第三方工具
Sekai Viewer
众所周知的 Sekai Viewer
Moe Sekai
非常好用的新一代数据查看器 Moe Sekai
SekaiTools
可用于 “统计原理、统计器、查看和编辑统计结果、系统语音、静态Spine场景、羁绊称号、互动语音” 等
注入博客(以 hexo 为例)
资源工具 sssekai
用于下载完整的未加密混淆的资源文件,参见 GitHub
示例使用:
- 构建 / 更新资源缓存索引例:
sssekai abcache --db ./abcache.db --app-region jp --app-version 6.0.1 --app-appHash 0bd8114a-e782-681a-a54e-c5ec1ffb33b5- 下载资源包 / asset bundle
sssekai abcache --db ./abcache.db --no-update --download-dir ./bundles/- 挂载资源 (Web)
sssekai abserve --db ./abcache.db --host 127.0.0.1 --port 8831第二章-新的视角
20260605 记
前段时间给东方夜雀食堂写了很久的模组,自认为对 il2cpp 的游戏有一定了解,按已有经验对相关问题重新分析一下。因此开了第二章,算是个人在有一定经验后,快速的逆向与更深入的审计。
常规提取
jp-6.5.5 提取 global-metadata.dat 和 libil2cpp.so
20260605 记
首先根据 jp-6.4.0 的审计,global metadata 为循环异或,key 为
00000000 5a 70 65 82 0a b8 72 de 98 85 9a 5b 43 ec 8b 84 |Zpe..¸rÞ...[Cì..|00000010 67 1b 61 83 e7 6c 55 df 45 13 a2 98 81 b8 d3 45 |g.a.çlUßE.¢..¸ÓE|00000020 0d df 15 00 32 8d f6 64 f0 cc a1 7f 33 a4 2c 72 |.ß..2.ödðÌ¡.3¤,r|00000030 8b 70 f4 8b f4 24 3e ee 1b 85 3d f7 15 d9 5d df |.pô.ô$>î..=÷.Ù]ß|00000040 b2 55 4d 64 d7 8a 51 d9 24 fc 93 1b ca d5 ac a0 |²UMd×.QÙ$ü..ÊÕ¬ |00000050 e6 2f 0d 11 c3 48 50 e9 a0 27 e5 ac 50 ff 4e 16 |æ/..ÃHPé 'å¬PÿN.|00000060 6f c1 cc de 56 c8 9b 01 1e 1b 66 f8 91 23 24 45 |oÁÌÞVÈ....fø.#$E|00000070 d9 9c 10 e6 cc 4b 1c 6a 04 64 c3 af a4 a5 88 25 |Ù..æÌK.j.d拉.%|然而将其用在 jp 6.5.5 安装包中的 global metadata 时是错误的。那么在 jp 6.4.0 的 libil2cpp.so 中搜索 5a 70 65 82,定位到 0x1D470F7 的 BYTE byte_1D470F7[128],全局唯一。使用该部分的代码为
// sub_3FDBB48if ( v40 >= 1 ){ v42 = 0LL; do { v41[v42] ^= byte_1D470F7[v42 & 0x7F]; ++v42; } while ( v40 != (_DWORD)v42 );}画一下 sub_3FDBB48的调用图,关系还是很明显的。

从 il2cpp_init 反向跟着找 jp 6.5.5 即可
ida /data/app/xxx/xxx/lib/arm64/libil2cpp.so
在 sub_42B62D0,key 为
00000000 b8 3e f6 ad f2 86 ab bc 3f 56 39 8a fa 08 f1 45 |¸>ö.ò.«¼?V9.ú.ñE|00000010 fe 5e e1 1c 7b 9b 49 2c f3 c1 3d c6 c9 00 0e 73 |þ^á.{.I,óÁ=ÆÉ..s|00000020 cb 96 42 51 23 e5 56 37 ba 59 50 da 2e 63 f8 a3 |Ë.BQ#åV7ºYPÚ.cø£|00000030 63 4a 69 7f ba c1 ee 1c f4 c9 ba 31 48 e5 f2 3e |cJi.ºÁî.ôɺ1Håò>|00000040 47 c6 bf 64 c6 9f 21 18 07 a9 61 22 4b 6f fe 8f |GÆ¿dÆ.!..©a"Koþ.|00000050 c8 99 63 7f 97 8f b8 09 a8 25 5d 5a 2b 25 ee d5 |È.c...¸.¨%]Z+%îÕ|00000060 66 11 92 68 a3 93 dc c3 28 50 77 11 60 cb d1 84 |f..h£.ÜÃ(Pw.`ËÑ.|00000070 cb 2a 68 af 82 73 a7 b8 0d b6 bb a1 42 c7 ed e1 |Ë*h¯.s§¸.¶»¡BÇíá|解密后的 global-metadata.dat 的 MD5 为 182796373B5708F00115E409A9120730
ps: 感慨啊,当年跟 mos9527 老师一步步走的教程尚且十分吃力,现在已能分分钟找到,也算是自己的进步了。
cn-6.0.0 提取 global-metadata.dat 和 libil2cpp.so
20260615 记
在 20260421 收到了热心大佬 Y*******1 的邮件,非常感谢。邮件内容如下
您好我在对 pjsk cn 进行逆向时参考了您的文章。在您对
global-metadata的静态解密逆向基础上,我进一步进行了分析,发现在libil2cpp.so中查找libhhld-rt.so并进入交叉引用可以找到一个动态安装 func hook 的函数,其原理是调用libhhld-rt的导出函数JNI_OnLoad,进入一个虚拟机执行一些字节码实现了对 mmap、dlopen、dlsym、fopen 等一些关键函数的hook,手动修复了符号后发现os::MemoryMappedFile::Create中也有对 hook 后的 mmap 函数的调用,因此可以猜测在libhhld-rt在一个自定义虚拟机中对global-metadata.dat进行了解密。相关的偏移如下:虚拟机字节码执行器:
base(libhhld-rt.so) + 0x2A044字节码:
base(libhhld-rt.so) + 0x151E10虚拟机字节码执行器的部分我还在逆向分析中,这个传参比较奇怪
执行器当中可以看到有 xor 相关的处理逻辑
但是在
libhhld中还可见有使用非对称加密的特征
如果您希望完全隐藏 ID 、隐藏邮件内容或允许我公开您的 ID 可以随时联系。
他精准地指出了整个解密链,虽然我个人水平非常有限,但借助 AI 辅助能勉强梳理一下静态审计的逻辑。
这也是我第一次研究 VM,下面记录可能有点细。
为什么不从内存 dump 而要硬拆 VMP?
因为去年研究过了,这次就是来学习 VMP 的,如果只需要单纯看经验的话可以看上文的内存 dump 方法和本节的总结部分。
后面部分有点水而且略显杂乱,一是难度确实高,还没认真完整复盘好,二是真的有点晚了,后面有空会重新整理和细化。
0. 审计版本
cn-6.0.0
MD5(libhhld-rt.so) = D4F4DF339D8C241FFCE602375E87134A
1. 文件布局
00000000 56 BE F0 89 C2 53 89 F0 AF 1B B1 FA 1F 00 00 00 |V¾ð.ÂS.ð¯.±ú....|00000010 00 01 00 00 E8 0D 04 00 E8 0E 04 00 94 F2 0D 00 |....è...è....ò..|00000020 7C 01 12 00 E4 CB 71 00 60 CD 83 00 40 41 00 00 ||...äËq.`Í..@A..|00000030 A0 0E 84 00 C0 95 09 00 60 A4 8D 00 E8 66 7A 00 | ...À...`¤..èfz.|00000040 48 0B 08 01 F4 28 01 00 3C 34 09 01 78 FC 0F 00 |H...ô(..<4..xü..|00000050 B8 30 19 01 08 D6 1E 00 C0 06 38 01 00 89 01 00 |¸0...Ö..À.8.....|00000060 C0 8F 39 01 CC 12 2A 00 8C A2 63 01 D4 E9 26 00 |À.9.Ì.*..¢c.Ôé&.|00000070 60 8C 8A 01 E0 25 01 00 40 B2 8B 01 7C 16 00 00 |`...à%..@²..|...|第一章中一个疑问就是,56 BE F0 89 C2 53 89 F0 这个魔数怎么来的,可以在 libhhld-rt.so.i64 按小端序搜索立即数 0xF08953C289F0BE56,定位到 sub_358A4:
__int64 __fastcall sub_358A4(__int64 result){ bool v1; // w8
v1 = 0; if ( *(_QWORD *)result && *(_QWORD *)(result + 8) >= 8uLL ) // 检查指针非空且长度大于 8 v1 = **(_QWORD **)result == 0xF08953C289F0BE56LL; // 判断魔术头是否匹配 *(_BYTE *)(result + 16) = v1; // 判断结果写入 result + 16 return result;}2. libil2cpp 的 metadata 加载方式
还是老地方,il2cpp_init
__int64 __fastcall il2cpp_init(__int64 a1){ setlocale(6, &byte_1E27B7B); return sub_47BD494(a1) & 1;}sub_47BD494 内部调用:
v18 = sub_47FDFD0(v17);sub_47FDFD0 内部调用:
v0 = sub_47938E0(&dword_BCA4238, &dword_BCA423C);sub_47938E0 内部调用:
sub_47BD22C("global-metadata.dat")sub_47BD22C 构造路径:
<Data path>/Metadata/global-metadata.dat然后打开文件并映射整文件:
sub_478E668(path, 3, 1, 1, 0, &err) // 打开文件sub_47C1064() // 映射文件sub_478E8A8(file, &err) // 关闭/释放文件对象return mapped_pointersub_478F33C 最终以 mmap 调用约定进入 PLT thunk:
result = sub_AF9ECC0(0, mapped_len, prot, flags, fd);默认路径映射偏移为 0。映射返回后,libil2cpp 直接把返回值当成 metadata 头使用,例如:
*a1 = *(int *)(v4 + 172) / 0x28;*a2 = *(int *)(v4 + 180) >> 6;这证明返回给 libil2cpp 的指针必须已经指向:
AF 1B B1 FA 1F 00 00 00而不能指向前面的 8 字节包装标记。
3. hook 安装
libhhld-rt.so 通过 JNI_OnLoad 安装运行时 hook。JNI_OnLoad 会进入类似 sub_2A044 的 VM / dispatcher 路径,完成字符串、函数解析、表初始化和 native/JNI 注册。
base(libhhld-rt.so) + 0x2A044是 VM 字节码执行器 (解释器)
很壮观的 cfg:

// base(libhhld-rt.so) + 0x29E5Cjint JNI_OnLoad(JavaVM *vm, void *reserved){ __int64 v2; // x2 __int64 v3; // x3 __int64 v4; // x4 __int64 v5; // x5 __int64 v6; // x6 unsigned __int64 v7; // x9 _QWORD v9[2]; // [xsp+0h] [xbp-470h] BYREF __int64 v10; // [xsp+10h] [xbp-460h] __int64 v11; // [xsp+18h] [xbp-458h] __int64 v12; // [xsp+20h] [xbp-450h] __int64 v13; // [xsp+28h] [xbp-448h] __int64 v14; // [xsp+30h] [xbp-440h] jint v15; // [xsp+38h] [xbp-438h] _QWORD v16[2]; // [xsp+40h] [xbp-430h] BYREF _QWORD v17[2]; // [xsp+450h] [xbp-20h] BYREF
v7 = ((~(unsigned __int64)JNI_OnLoad & 0x10400040400LL | 0xA060400A021040LL) + ((unsigned __int64)JNI_OnLoad & 0x10400040400LL | 0x1010104)) ^ 0xFF5F9EBBF5FED9C4LL; v17[1] = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2)) + 40); v16[0] = sub_2A038; v16[1] = v17; v9[0] = vm; v9[1] = reserved; v10 = v2; v11 = v3; v12 = v4; v13 = v5; v14 = v6; ((void (__fastcall *)(char *, _QWORD *, char *, char *, _QWORD *))((char *)off_1E4008 + v7))( (char *)off_1E4010 + v7, v9, (char *)off_1E4018 + v7, (char *)off_1E4020 + v7, v16); return v15;}算一下这几个数:
MASK = (1<<64) - 1JNI_OnLoad = 0x29E5Coff_1E4008 = 0x108D3C4off_1E4010 = 0x11B5190
v7 = ((((~JNI_OnLoad & MASK) & 0x10400040400 | 0xA060400A021040) + ((JNI_OnLoad & 0x10400040400) | 0x1010104)) ^ 0xFF5F9EBBF5FED9C4) & MASK
print(f"v7 = {hex(v7)}")print(f"off_1E4008 + v7 = {hex((off_1E4008 + v7) & MASK)}")print(f"off_1E4010 + v7 = {hex((off_1E4010 + v7) & MASK)}")
# v7 = 0xfffffffffef9cc80# off_1E4008 + v7 = 0x2a044 -> 指向刚才看到的 VM 字节码执行器 (解释器)# off_1E4010 + v7 = 0x151e10这是负责 JNI_OnLoad 初始化、hook 安装相关 VM bytecode
4. mmap hook 识别 global-metadata.dat
metadata 相关的 mmap hook 入口是:
sub_37AE0 -> sub_37EC8sub_37EC8 会调用:
sub_37748(fd)sub_37748 通过 /proc/self/fd/{fd} 和 realpath 解析文件描述符对应的真实路径。
在其之后访问了
.text:00000000000381C4 60 0D 00 B0 00 80 30 91 ADRL X0, byte_1E5C20 ; char *.text:00000000000381CC 80 03 00 F9 STR X0, [X28].text:00000000000381D0 40 C1 FF 97 BL .strlen.text:00000000000381D4 80 07 00 F9 STR X0, [X28,#8]查找 byte_1E5C20 的来源
.bss:00000000001E5C20 ; _BYTE byte_1E5C20[24].bss:00000000001E5C20 ?? ?? ?? ?? ?? ?? ?? ?? byte_1E5C20 % 0x18 ; DATA XREF: sub_37BC8+238↑o.bss:00000000001E5C20 ?? ?? ?? ?? ?? ?? ?? ??… ; sub_37EC8+2FC↑o该 byte_1E5C20 来自
.text:0000000000037DE8 E9 08 00 B0 29 C1 39 91 ADRL X9, byte_154E70.text:0000000000037DF0 EA 0C 80 52 MOV W10, #0x67 ; 'g'.text:0000000000037DF4 08 7D 40 93 SXTW X8, W8.text:0000000000037DF8 29 69 68 38 LDRB W9, [X9,X8].text:0000000000037DFC 29 01 0A 4A EOR W9, W9, W10.text:0000000000037E00 6A 0D 00 D0 4A 81 30 91 ADRL X10, byte_1E5C20.rodata:0000000000154E70 ; _BYTE byte_154E70[32].rodata:0000000000154E70 48 00 0B 08 05 06 0B 4A byte_154E70 DCB 0x48, 0, 0xB, 8, 5, 6, 0xB, 0x4A, 0xA, 2, 0x13, 6.rodata:0000000000154E70 0A 02 13 06 ; DATA XREF: sub_37BC8+220↑o.rodata:0000000000154E7C 03 06 13 06 49 03 06 13… DCB 3, 6, 0x13, 6, 0x49, 3, 6, 0x13, 0, 0, 0, 0, 0, 0.rodata:0000000000154E8A 00 00 00 00 00 00 DCB 0, 0, 0, 0, 0, 0byte_154E70 = bytes([0x48, 0x00, 0x0b, 0x08, 0x05, 0x06, 0x0b, 0x4A, 0x0A, 0x02, 0x13, 0x06, 0x03, 0x06, 0x13, 0x06, 0x49, 0x03, 0x06, 0x13])for c in byte_154E70: print(chr(c ^ 0x67), end="")
print()
# /global-metadata.dat可以确定 byte_1E5C20 可能用于判断 global-metadata.dat 后缀
.text:000000000003824C 21 BF FF 97 BL .memcmp.......text:0000000000038280 0B 00 00 14 B loc_382AC.......text:00000000000382AC loc_382AC ; CODE XREF: sub_37EC8+3B8↑j.text:00000000000382AC ; DATA XREF: sub_37EC8+3D0↑o ....text:00000000000382AC A8 82 9D 52 88 0A BF 72 MOV W8, #0xF854EC15.text:00000000000382B4 1F 01 17 6B CMP W8, W23.text:00000000000382B8 CB C8 FF 54 B.LT loc_37BD0.text:00000000000382BC F6 FE FF 17 B loc_37E94.......text:0000000000037E94 loc_37E94 ; CODE XREF: sub_37BC8+4↑j.text:0000000000037E94 ; sub_37BC8+15C↑j ..........text:0000000000037EF0 F1 F5 FF 97 BL sub_356B4如此便进入先前分析的 sub_356B4 中
5. 外层 metadata VM
__int64 __fastcall sub_356B4(__int64 a1, __int64 a2, int a3, int a4, int a5, __int64 a6){ unsigned __int64 v6; // x9 _QWORD v8[2]; // [xsp+0h] [xbp-450h] BYREF int v9; // [xsp+10h] [xbp-440h] int v10; // [xsp+14h] [xbp-43Ch] int v11; // [xsp+18h] [xbp-438h] __int64 v12; // [xsp+20h] [xbp-430h] __int64 v13; // [xsp+28h] [xbp-428h] _QWORD v14[2]; // [xsp+30h] [xbp-420h] BYREF _QWORD v15[2]; // [xsp+430h] [xbp-20h] BYREF
v6 = ((~(unsigned __int64)sub_356B4 & 0x10400040400LL | 0xA060400A021040LL) + ((unsigned __int64)sub_356B4 & 0x10400040400LL | 0x1010104)) ^ 0xFF5F9EBBF487B58CLL; v15[1] = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2)) + 40); v14[0] = sub_35930; v14[1] = v15; v8[0] = a1; v8[1] = a2; v9 = a3; v10 = a4; v11 = a5; v12 = a6; ((void (__fastcall *)(char *, _QWORD *, char *, char *, _QWORD *))((char *)off_1E40B0 + v6))( (char *)off_1E40D0 + v6, // bytecode start v8, // mmap 参数块 (char *)off_1E40D8 + v6, // 一组 VM 辅助数据 (char *)off_1E40E0 + v6, // 疑似参与 native callback 解析 v14); // trampoline: sub_35930 return v13;}MASK = (1<<64) - 1sub_356B4 = 0x356B4off_1E40B0 = 0x82B930off_1E40D0 = 0x94A138off_1E40D8 = 0x9CAA10off_1E40E0 = 0x9CAA18
v6 = ((((~sub_356B4 & MASK) & 0x10400040400 | 0xA060400A021040) + ((sub_356B4 & 0x10400040400) | 0x1010104)) ^ 0xFF5F9EBBF487B58C) & MASK
print(f"v6 = {hex(v6)}")print(f"off_1E40B0 + v6 = {hex((off_1E40B0 + v6) & MASK)}")print(f"off_1E40D0 + v6 = {hex((off_1E40D0 + v6) & MASK)}")print(f"off_1E40D8 + v6 = {hex((off_1E40D8 + v6) & MASK)}")print(f"off_1E40E0 + v6 = {hex((off_1E40E0 + v6) & MASK)}")
# v6 = 0xffffffffff80a0c8# off_1E40B0 + v6 = 0x359f8# off_1E40D0 + v6 = 0x154200 // bytecode start# off_1E40D8 + v6 = 0x1d4ad8 // 一组 VM 辅助数据# off_1E40E0 + v6 = 0x1d4ae0 // 疑似参与 native callback 解析为分析 bytecode,检查 0x1d4ae0 辅助数据 / 疑似 callback 相关表
.data.rel.ro:00000000001D4AE0 EC 16 38 00 00 00 00 00 DCQ 0x3816EC.data.rel.ro:00000000001D4AE8 14 17 38 00 00 00 00 00 DCQ 0x381714.data.rel.ro:00000000001D4AF0 34 17 38 00 00 00 00 00 DCQ 0x381734.data.rel.ro:00000000001D4AF8 4C 17 38 00 00 00 00 00 DCQ 0x38174C.data.rel.ro:00000000001D4B00 64 17 38 00 00 00 00 00 DCQ 0x381764.data.rel.ro:00000000001D4B08 80 17 38 00 00 00 00 00 DCQ 0x381780.data.rel.ro:00000000001D4B10 98 93 CE 00 00 00 00 00 DCQ 0xCE9398这块有点撕不动了,以后有时间深入撕一下,让 AI 总结如下:
这段外层 bytecode 的高层行为是:
保存原始 mmap 参数构造匿名 mmap 参数调用 sub_3593C -> 真实 mmap如果 mmap 失败,处理 errno 并返回调用 sub_35964 -> 真实 read(fd, mapped_buffer, requested_len)如果 read 失败,处理 errno 并返回调用 sub_359B4 -> sub_355C0(mapped_buffer, read_size)通过 sub_35984/sub_3599C/sub_359D0 保存和恢复 errno 状态把最终结果写入 argblk+0x28 作为 mmap hook 返回值外层 VM 表中的 native wrapper 主要有:
0x3593C: 包装真实 mmap,调用 sub_80E28,结果写到 arg+400x35964: 包装真实 read,调用 sub_80F3C,结果写到 arg+240x35984: 包装 __errno0x3599C: 复制 errno 类状态0x359B4: 调用内层 VM wrapper sub_355C00x359D0: 如果标志位设置,则恢复 errno内层 metadata VM
外层调用 sub_359B4:
__int64 __fastcall sub_359B4(__int64 *a1){ __int64 result; // x0
result = sub_355C0(*a1, a1[1]); a1[2] = result; return result;}__int64 __fastcall sub_355C0(__int64 a1, __int64 a2){ unsigned __int64 v2; // x9 _QWORD v4[3]; // [xsp+8h] [xbp-378h] BYREF _QWORD v5[2]; // [xsp+20h] [xbp-360h] BYREF _QWORD v6[2]; // [xsp+360h] [xbp-20h] BYREF
v2 = ((~(unsigned __int64)sub_355C0 & 0x10400040400LL | 0xA060400A021040LL) + ((unsigned __int64)sub_355C0 & 0x10400040400LL | 0x1010104)) ^ 0xFF5F9EBBF487B58CLL; v6[1] = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2)) + 40); v5[0] = sub_35930; v5[1] = v6; v4[0] = a1; v4[1] = a2; ((void (__fastcall *)(char *, _QWORD *, _QWORD, char *, _QWORD *))((char *)off_1E40B0 + v2))( (char *)off_1E40C0 + v2, v4, 0LL, (char *)off_1E40C8 + v2, v5); return v4[2];}MASK = (1<<64) - 1sub_355C0 = 0x355C0off_1E40B0 = 0x82B930off_1E40C0 = 0x94A088off_1E40C8 = 0x9CAA00
v2 = ((((~sub_355C0 & MASK) & 0x10400040400 | 0xA060400A021040) + ((sub_355C0 & 0x10400040400) | 0x1010104)) ^ 0xFF5F9EBBF487B58C) & MASK
print(f"v2 = {hex(v2)}")print(f"off_1E40B0 + v2 = {hex((off_1E40B0 + v2) & MASK)}")print(f"off_1E40C0 + v2 = {hex((off_1E40C0 + v2) & MASK)}")print(f"off_1E40C8 + v2 = {hex((off_1E40C8 + v2) & MASK)}")
# v2 = 0xffffffffff80a0c8# off_1E40B0 + v2 = 0x359f8# off_1E40C0 + v2 = 0x154150# off_1E40C8 + v2 = 0x1d4ac8因此内层会进入同一个 VM executor,执行 bytecode:
base(libhhld-rt.so) + 0x154150内层 bytecode 的高层行为是:
arg0 = mapped_bufferarg1 = read_size
调用 sub_358A4({ mapped_buffer, read_size, bool_out })如果 bool_out == 0: return mapped_buffer
payload = mapped_buffer + 8region = payload + *(u32 *)(payload + 0x18)size = *(u32 *)(payload + 0x1c)
调用 sub_358E0({ region, region, size })return payload因为 mapped_buffer 仍然包含 8 字节包装头,所以等价写法是:
region = mapped_buffer + 8 + *(u32 *)(mapped_buffer + 0x20)size = *(u32 *)(mapped_buffer + 0x24)return = mapped_buffer + 8这同时解决了两个问题:
- 解密不是整文件,而是 header 字段指定的一段区域。
- 返回给 libil2cpp 的指针是 mapped_buffer + 8。
6. XOR 还原算法
实际做 XOR 的 helper 是:
__int64 *__fastcall sub_358E0(__int64 *result){ __int64 v1; // x8 __int64 v2; // x9 __int64 v3; // x10 __int64 i; // x11
v1 = *result; if ( *result ) { v2 = result[1]; if ( v2 ) { v3 = *((int *)result + 4); if ( (int)v3 >= 1 ) { for ( i = 0LL; i < v3; i += 4LL ) *(_DWORD *)(v2 + i) = *(_DWORD *)(v1 + i) ^ dword_153F3C[((_BYTE)i + (unsigned __int8)((unsigned int)i >> 7)) & 0x7F]; } } } return result;}它是 4 字节步进的循环 XOR helper:
dst[i] = src[i] ^ dword_153F3C[((i & 0xff) + ((i >> 7) & 0xff)) & 0x7f]参数结构是:
struct XorArgs { void *src; void *dst; int size;};当前 metadata 路径里 src 和 dst 是同一个地址,因此是原地还原:
src = dst = mapped_buffer + 8 + *(u32 *)(mapped_buffer + 0x20)size = *(u32 *)(mapped_buffer + 0x24)按 payload 视角则是:
src = dst = payload + *(u32 *)(payload + 0x18)size = *(u32 *)(payload + 0x1c)7. 解密脚本
exp
from pathlib import Pathimport struct
SENTINEL = bytes.fromhex("56 be f0 89 c2 53 89 f0")IL2CPP_MAGIC = bytes.fromhex("af 1b b1 fa 1f 00 00 00")
KEY_BYTES = bytes.fromhex( "49 a2 3d f8 72 27 d1 13 97 06 c5 20 6b 2b 4e 98" "f8 5f ec 14 27 49 e2 b2 ae 77 8f 3b cd 74 24 47" "24 e5 0c 5b 31 1a 7e a1 2c 85 60 6c 67 d2 6a d8" "b7 12 26 83 45 36 a0 1c c8 ab 15 55 52 ff fe c5" "00 ac ff ff b6 5c e9 0f dd 43 cf 79 fb a3 48 aa" "88 17 d7 e1 3a 3d 66 97 a7 fe cf f5 32 76 61 ee" "ee a7 11 4b b5 f0 0e 04 00 fc 06 06 ae 0f 53 c1" "41 74 82 7a 44 1d e9 fc b1 c1 4c 8c 8d c2 94 72" "62 61 97 8d 5a 43 15 83 08 a4 17 39 27 13 7f af" "d7 ae bf d4 fc ab d0 80 c3 3d 92 63 5a b3 e6 b0" "8f 08 15 b8 23 f1 ac 9b c3 11 24 e3 0b 10 26 a0" "58 ff f2 bc fc 5c 1c 64 dc d7 a2 c4 ca 5d e0 99" "f7 99 c6 9d 21 86 6a b7 3c e0 40 8e d4 c2 f3 28" "23 12 f9 40 e0 52 a9 67 21 36 5f 50 33 3d f1 ba" "cc 61 5b a7 54 ef 6a ab 0d b6 df c4 3a 87 9d d2" "46 71 a7 57 b8 86 3f 39 54 4a 73 2a f6 6a a5 31" "60 91 5d 0c 9a a1 83 af 1f b4 c9 7f 47 ef 79 d0" "81 52 29 e3 e5 e3 02 56 69 5e 91 ab 92 19 5a 22" "b2 f6 87 a3 13 16 98 7e 9a f5 6c fc 78 73 4a d3" "d6 b7 08 b6 d9 93 eb a9 18 b2 dd 26 5f 3f f3 65" "42 44 31 f9 99 05 5c 5d 74 e7 72 ea 02 a5 05 16" "9f bc 6c ec d1 1b 8a 7f 07 cf d8 4d e0 79 6d 2e" "8f 41 90 69 d9 ba 77 cf 47 01 fe d4 e8 a3 f4 fe" "de 5b c4 85 67 8e 8f b5 d7 b8 3e a6 9b d1 9b c6" "ca 2d 44 da 43 17 0c 3c 49 9d f3 e6 04 88 56 33" "20 63 eb 85 45 34 22 da 41 a9 c4 36 89 55 18 a9" "67 2d b2 71 47 26 9a f5 3e 58 8b 3c ed 7d 71 d7" "9c 69 05 df 7d 36 78 43 39 93 45 1c 7f 3b 13 85" "e2 0c 80 49 0d ca 66 36 04 b5 7a af f1 b8 f5 4f" "e3 72 37 c2 1e f3 44 35 57 3a 67 0f e1 00 06 f4" "17 74 96 7e 03 62 a2 15 ce 34 2e 5f 1a 92 c7 70" "0f 90 c1 d1 6b da b5 5b 85 9c 97 60 a4 59 a7 4e")
assert len(KEY_BYTES) == 512
KEY = [int.from_bytes(KEY_BYTES[i:i + 4], "little") for i in range(0, len(KEY_BYTES), 4)]
def recover_metadata(input_path: Path, output_path: Path) -> None: data = bytearray(input_path.read_bytes())
if len(KEY) != 128: raise ValueError(f"KEY length must be 128 dwords, got {len(KEY)}")
if len(data) < 0x20: raise ValueError("input is too small")
if data[:8] != SENTINEL: raise ValueError(f"sentinel mismatch: {data[:8].hex(' ')}")
payload_base = 8 if data[payload_base:payload_base + 8] != IL2CPP_MAGIC: raise ValueError( f"metadata magic mismatch after sentinel: " f"{data[payload_base:payload_base + 8].hex(' ')}" )
region_offset = struct.unpack_from("<I", data, payload_base + 0x18)[0] region_size = struct.unpack_from("<I", data, payload_base + 0x1C)[0] start = payload_base + region_offset end = start + region_size
if end > len(data): raise ValueError( f"xor region out of range: start=0x{start:x}, " f"size=0x{region_size:x}, file_size=0x{len(data):x}" )
for i in range(0, region_size, 4): key_index = ((i & 0xFF) + ((i >> 7) & 0xFF)) & 0x7F key_bytes = KEY[key_index].to_bytes(4, "little") chunk_size = min(4, region_size - i) for j in range(chunk_size): data[start + i + j] ^= key_bytes[j]
clean = data[payload_base:] output_path.write_bytes(clean)
print(f"input: {input_path}") print(f"output: {output_path}") print(f"region_offset = 0x{region_offset:x}") print(f"region_size = 0x{region_size:x}") print(f"file_range = 0x{start:x} - 0x{end:x}") print(f"clean_magic = {clean[:8].hex(' ')}") print(f"region_head = {clean[region_offset:region_offset + 64]!r}")
recover_metadata(Path("global-metadata.dat"), Path("global-metadata.dat.dec"))
MD5(global-metadata.dat) = D9950194A3C5EFD2D623071790FB0BBB
8. 总结
困了,先不写了,有空再进行细化和补充。(快三点了,真得睡了)
再次感谢热心大佬 Y*******1 的指点。
网络审计
20260606 删,感觉写的有点太直白了,先删了)
代码审计
20260607 记
在 AI Agent 飞速发展的今天,想快速审计一个 il2cpp 游戏已经不是难事,如果能配合地用好工具,一定能达到事半功倍的效果。这里将首先分享一下个人为此准备的一套工具和工作流,然后按各个功能模块进行粗浅地分析,以供大家参考。这部分可能有 AI 生成总结的部分(因为 AI 真的比我总结的更高效、更准确、更有价值),此类内容会在所属小节开头进行标注。
逆向工具
在去年,我可能会说,下面的工具只需配置一部分即可。但是在现在,接下来的每一个工具都是我经过大量实践总结出的一套模式,如果以后有进步也会同步进行更新。
-
010-Editor:用于查看文件的十六进制,有助于恢复
global-metadata.dat,也可使用其他工具替代。 -
Il2CppDumper:il2cpp 逆向最权威的工具,用于解析
global-metadata.dat并导出字符串表以及 DummyDll 等重要产物。 -
dnSpy:用于粗略审计
DummyDll/*.dll,导出为桩代码项目可供 AI Agent 快速审计。 -
IDA Pro:用于对
libil2cpp.so进行反汇编分析,并在必要时进行动态调试。对于 il2cpp 游戏,IDA 是理解底层执行逻辑的重要工具。 -
IDA-Pro-MCP:该插件允许AI Agent通过MCP接口访问IDA数据库,可用于辅助定位函数、理解控制流或加速分析过程。
逆向分析准备
- 恢复
global-metadata.dat,老生常谈的话题了,应该非常简单了吧)大家都会了。 - 找到
libil2cpp.so,一般在 安装包里的lib/arm64-v8a/libil2cpp.so - 使用 Il2CppDumper 读取
global-metadata.dat和libil2cpp.so,等待一会可以得到一些产物。如果报错,检查是否是二者有未解开的加密或损坏。 - 将 Il2CppDumper 生成的
DummyDll/Assembly-CSharp.dll导入dnSpy。通过该文件,我们可以较为直观地查看游戏逻辑中的类定义、字段名称以及方法签名,并定位其虚拟地址。 - 在 dnSpy 中选中
Assembly-CSharp.dll,并通过文件 > 导出到工程将整个项目进行导出,导出的代码不含具体逻辑,只含结构,以供 AI Agent 进行标准的参考。 - 使用 IDA Pro 打开
libil2cpp.so,等待其完成初始自动分析(首次加载耗时会非常长,建议导入后直接睡到天明)。 - 关闭 IDA,切记勾选
Collect garbage而不要勾选Don't SAVE the database,备份产物libil2cpp.so.i64。 - 重新打开
libil2cpp.so.i64,点击File -> Script file,运行 Il2CppDumper 附带的ida_with_struct_py3.py脚本。根据脚本提示,依次选择生成的script.json与il2cpp.h文件,耐心等待符号与结构信息导入完成。(这次处理耗时也会非常长,建议处理后再睡一觉到天明)。 - 关闭 IDA,切记勾选
Collect garbage而不要勾选Don't SAVE the database,重新备份产物libil2cpp.so.i64。 - 为 IDA 安装 IDA Pro MCP
- 安装 skill:IL2cpp-to-csharp-skill
- 打开 IDA,启用 IDA Pro MCP,将桩代码目录和 Il2CppDumper 的产物
stringliteral.json路径提供给 Agent,即可让 Agent 开始逆向分析。
mod 制作
20260606 记
注:以下测试均在断网环境的模拟,未接入服务器,未造成任何影响,仅供学习和参考,不提供任何技术支持。
在今天之前我也很难以想象我能做到这一步,但是确实成功了。
搭建好基础环境后,编写就和普通 PC 端的 BepInEx il2cpp mod 一样了。
FusionCore
感谢项目:All-Of-Us-Mods/FusionCore
这是我针对性修复一些问题后的 fork 版本:MetaMikuAI/FusionCore
当然我不可能提供可以直接运行的版本,需要额外做如下:
- 请按照 README 自行添加对应包名
- 请按照 README 自行编译并安装
- 启动后首先关闭设置中的
Download unstripped libunity选项 - 将解密后的
global-metadata.dat放到/sdcard/FusionCore/<包名>/下
我在编译原仓库时,发现有几个问题,并在我的 fork 仓库被修复或解决:
- 游戏直接闪退:FusionCore 误将
global-metadata.dat当明文读取而崩溃,这在 Add global metadata override during bootstrap 被解决,请将解密后的global-metadata.dat放到/sdcard/FusionCore/<包名>/下 - 游戏可以正常加载,但有关 Google Play 的一些图标等资源加载错误:
FusionCore包装后导致和 Google Play 通信的一些问题,这在 Fix launcher context wrapping for game resources 被修复。 - 游戏可以正常加载,但是只能在标题页,点击标题无法登录游戏:与第二条类似的问题,这在 Fix Unity launch context and Firebase discovery 中被修复。
游戏正常加载,甚至可以直接带起来 UnityExplorer

搭建 mod 编译环境并测试 mod
与正常 il2cpp 游戏的 bepinex 环境搭建方法类似,首先至少运行过一次 FusionCore,然后复制 /sdcard/FusionCore/<包名>/BepInEx 到电脑上,这个目录即是编译 mod 所需的「依赖」。
我先前在为《东方夜雀食堂》制作 mod 时顺便写了一组很详细的开发文档,而且流程几乎一模一样。因此这里只会简单介绍一下流程,有很多细节的省略,推荐将博客和开发文档对照着阅读。MetaMystia文档 - hello world 其一。但是注意,其中 .NET 版本应当选择 10.0,除此之外无其他不同。
在这里我直接用命令行创建。classlib 代表类库,用于 mod;SekaiModTest 代表项目名,任取。
dotnet new classlib -n SekaiModTest创建好后请确保 SekaiModTest.csproj 的 TargetFramework 为 net10.0,并添加引用(先前导出的 BepInEx/core/xxx.dll 和 BepInEx/interop/xxx.dll),这里不再过多介绍。
按我个人习惯,Class1.cs 改名为 Plugin.cs,并编写代码:
using BepInEx;using BepInEx.Unity.IL2CPP;using HarmonyLib;
using Sekai.Core.Live;
namespace SekaiModTest;
[BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)]public class Plugin : BasePlugin{ public static Plugin Instance;
public Plugin() { Instance = this; }
public override void Load() { Log.LogWarning($"Plugin {MyPluginInfo.PLUGIN_NAME}-v{MyPluginInfo.PLUGIN_VERSION} is loaded!"); Log.LogWarning($"Made by MetaMiku"); Log.LogWarning($"https://github.com/MetaMikuAI/SekaiModTest");
try { var harmony = new Harmony(MyPluginInfo.PLUGIN_GUID); harmony.PatchAll(typeof(ScoreLogicPatch)); } catch { Log.LogError("FAILED to Apply Hooks!"); } }}
[HarmonyPatch(typeof(Sekai.Core.Live.ScoreLogic))]public class ScoreLogicPatch{ [HarmonyPatch(nameof(ScoreLogic.GetNoteScoreFactor))] [HarmonyPostfix] public static void GetNoteScoreFactor_Postfix(ref float __result) { Plugin.Instance.Log.LogWarning($"GetNoteScoreFactor called, setting result to 100.0f"); __result = 100.0f; }}这段代码的大概含义是:
- 模组加载时打印三行日志,并向
Sekai.Core.Live.ScoreLogic应用钩子。 - 在
Sekai.Core.Live.ScoreLogic.GetNoteScoreFactor调用结束返回结果时,将结果修改为 100.0f
编译
dotnet build将 电脑/.../BepInEx/plugins/SekaiModTest.dll 文件复制到 Android 的对应位置即可。
运行 FusionCore 中的游戏,启动 solo live,可以看到音符得分被大幅降低。

如果没有跟上,可以直接从 MetaMikuAI/SekaiModTest 获取现成代码,并注意修改 SekaiModTest.csproj 中的 BepInExPath 项。
课后作业:合理使用 Harmony Patch,使得音符在 miss 后不会扣血。
注入有风险,测试需谨慎。
翻译 mod
