Project SEKAI 逆向笔记 持续更新#

非常喜欢 Project Sekai 这款游戏,不过我本身是主要学习密码学的,对逆向不甚了解,仅向下面博客进行了拙劣的学习,尝试提供一些基础的实践尝试,可能会记录得过于详细。

↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓

pjsk逆向唯一神贴: Project SEKAI 逆向 - 笔记归档

↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑

更新日志#

如果您已有相关经验,可以跳过试错较多且略显杂乱的 第一章 部分,直接阅读 第二章,我将从全新的视角去审计整个游戏,也希望我的分析能给您带来新的收获。我自己也觉得整篇博客杂乱无章,但是我还是很喜欢事无巨细地记录每段研究,我会在第二章尽可能优化语言和结构,还请见谅。

  • 20250410 完成了 静态解包 第三方工具 等部分的大致整理
  • 20250411 完成了 提取 global-metadata.dat 部分的整理
  • 忘了
  • 20260404 完成了 修改 API-url
  • 20260604 修正了对 ffa3bd6214f33fe73cb72fee2262bedb 的解释
  • 20260605 开启 第二章
  • 20260606 新建 mod 制作 章节,记录 FusionCore搭建 mod 编译环境并测试 mod
  • 20260607 完成 代码审计 章节的 逆向工具逆向分析准备 两个小节
  • 20260615 完成 cn-6.0.0 版本的 VMP 审计和 global-metadata.dat 的静态提取

第一章-学习与试错#

静态解包#

参见:【世界计划 多彩舞台】PJSK游戏资源解包教程-补充资料:新版

动态分析#

cn-3.4.0 提取 global-metadata.dat (成功)#

参考 Project SEKAI 逆向 - 笔记归档

研究版本: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 shelltop 命令拿 PID=31607

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

使用 dddump 这三段内存

Terminal window
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 找这段内存

Terminal window
cat /proc/4958/maps | more
Terminal window
7b25f88000-7b25f89000 ---p 00000000 00:00 0 [anon:thread stack guard]
7b25f89000-7b25f8a000 ---p 00000000 00:00 0
7b25f8a000-7b2608e000 rw-p 00000000 00:00 0
7b2629a000-7b2629b000 ---p 00000000 00:00 0 [anon:thread stack guard]
7b2629b000-7b2629c000 ---p 00000000 00:00 0
7b2629c000-7b263a0000 rw-p 00000000 00:00 0
7b263a0000-7b2761f000 rw-p 00000000 00:00 0
7b2761f000-7b27e08000 rw-s 00000000 00:0b 9591 anon_inode:dmabuf
7b27e08000-7b2f500000 r-xp 00000000 b3:42 93921 /data/app/com.hermes.mk-N1TZmXwSQOMJzh2TegB6vA==/lib/arm64/libil2cpp.so
--More--

找到

Terminal window
7b263a0000-7b2761f000 rw-p 00000000 00:00 0

dd dump 出这段内存

Terminal window
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 具有以下五点不同

  1. 魔数 AF 1B B1 FA 被改为 00 00 00 00,这是先前全局搜索内存无果的原因(朝夕你真毒啊)
  2. 魔数后的第一个字节 1B 被改为了 18,原因未知
  3. D0 39 1E 00 -> FF FF FF 7F,事后猜测这似乎是一个被改到INT_MAXint
  4. 加密段被解密,事后经验证为一段异或加密
  5. 尾部多出一堆 00,大概是分配内存多余的空间

结合两个 metadata,经过多次尝试,最终需要还原的 global-metadata.dat 方法如下:

  1. 取内存 dump 出的 metadata
  2. 比对 apk 中解包出的 metadata,删除多余的 00 字节
  3. 复原 FF FF FF 7F -> D0 39 1E 00
  4. 复原 18 -> 1B
  5. 复原 00 00 00 00 -> AF 1B B1 FA
  6. 删除头 8 字节,即使 AF 1B B1 FA 位于文件开头

最后使用工具 Il2CppDumper 解析 libil2cpp.so

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

Beebyte 但是类名函数名没混淆

任务完成

恢复好的 global-metadata.datMD59069c4db8cd9c520c2d22be69c36d30b

静态分析 libil2cpp.so (失败)#

已经通过 dump 和修补拿到了完整的 global-metadata.dat,但是没有成功通过逆向 libil2cpp.so 发现解密算法,下面分享一下我的思路思路

参考文章:

  1. 解密 metadata
  2. 某手游il2cpp逆向分析----libtprt保护

使用 IDA 打开 ./lib/arm64-v8a/libil2cpp.so,直接 Shift+F12metadata

image-20250416002147624
image-20250416002147624

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

image-20250416002305548
image-20250416002305548

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,发现一样有加密

绘制直方图:

jp-6.4.0 base.apk global-metadata.dat 直方图
jp-6.4.0 base.apk global-metadata.dat 直方图

观察直方图,仍然有明显的统计特征,信息熵较小,排除 AES/DES,仍然考虑传统的循环异或

dd 或 GG 提取内存中的 global-metadata.dat

Terminal window
:/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()

验证了循环异或的猜想,但密钥已不是当年的密钥

dump xor apk
dump xor apk

于是将异或结果文件与 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);
});
});

成功捕获 keyiv

# 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方案使用教程 也可以参考下面简略教程

  1. 在 PC 上安装 mitmproxy
  2. 在电脑端安装证书 C:\Users\用户\.mitmproxy\mitmproxy-ca.p12
  3. C:\Users\用户\.mitmproxy\mitmproxy-ca-cert.cer 重命名为 c8750f0d.0
  4. c8750f0d.0 移动到安卓设备的 /system/etc/security/cacerts/c8750f0d.0 或通过模块安装到系统 CA
  5. 电脑端运行 mitmweb -m wireguard --no-http2 -s 脚本.py --set termlog_verbosity=warn --ignore 这里输入你的IP地址 启动 mitmproxy
  6. 安卓端启动 wireguard 并扫码连接

至此可以形成这样的代理链

正常情况下
MobileDevice --------> --------> --------> --------> --------> The Server
- - ->X mitmproxy (but not trut)
使用 MITM
MobileDevice --------> --------> mitmproxy - - - - > - - - - > The Server
(With MITM CA) |
| (if redirection rule is set)
v
Hacker's Server

API 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.org
https://issue.sekai.colorfulpalette.org -> 并不常见, 似乎只在有问题的时候才会
https://cdp.cloud.unity3d.com/v1/events -> u3d 收集信息的地址, 不用搭理

只要第一个 api 即可实现基本游玩需求

分析加密#

第一层显而易见是 HTTP,使用 mitmproxy 轻松解决

接下来的分析还是来自 Project SEKAI 逆向 - 笔记归档,先是一层 AES-CBC,密钥和初始化向量在本文的 动态分析-获取AES key 中获取

然后是 MessagePack 格式的信息,可以直接解码得到 json

临时分析时可以使用 CyberChef 对流量包轻松解密

image-20250914193432369

为了便于 mitmproxy 自动化抓包解密和后期可能的 PS 计划,编写 python 加解密脚本如下

import json
import msgpack
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
# get key and iv from frida-il2cpp-bridge first
key = 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 中返回一大堆东西,responseobtainPrizes 键下有十个卡牌信息,便是抽卡结果信息,例如

"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 指示是否为第一次抽到, newFlgtrue 且具有服装的真四星需要附加 costume3d 信息

因此思路很简单,截取服务器返回包,改 resourceId 为十个四星卡即可

exp 如下

# the more miku the better!
# author: [MetaMiku](https://github.com/MetaMikuAI)
from mitmproxy import http
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import msgpack
import json
# get key and iv from frida-il2cpp-bridge first
key = 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.1

the more Miku the better!

more miku more better

分析 JWT (基本完成)#

  1. 观察到流量中有大量 JWT 以 X-Session-Token credential sessionToken signature param 等处出现
  2. param 外,所有 jwt 均包含用户的 userId 和一个未知含义的 uuid
  3. 所有 jwt 的签名密钥均未知 (也有可能是我菜,尝试 frida hook 了一些哈希函数无果,大概本地完全不验签,只有服务器会验签)
  4. 所有 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
  1. 启动游戏时会发送第一个请求 GET /api/system 不携带 X-Session-Token
  2. 然后访问 /api/user/<userId>/auth?refreshUpdatedResources=False 并在 payload 中携带 credential,每次登录的时候都会携带该 credential 不变 (该 credential 与用户绑定,所谓更新丢账号应该就是丢的它),服务端返回 sessionToken
  3. 在此之后,客户端的所有请求都需携带该 sessionToken
  4. 服务端除 /api/system/api/information 外,都需要返回新的 sessionToken,客户端收到后更新 sessionToken,并在下次请求中携带新的 sessionToken(平时网络不好导致卡退到主界面大概就是因为这个不同步了吧)保证了前向安全性
  5. credential 不随 重新登录设置引继码 而变,但是会在确认继承引继码时接收服务器分发的credential
  6. userRegistrationsignature 似乎永远不变
  7. 注:jwt 的签名密钥无从得知,如果需要在官方服务器和个人服务器之间反复测试的话,个人服务器不得分发新的 credential (但是可以重放),以免账号丢失(20260616 补充:SNA 和 引继码相关 jwt 还是可以在客户端审计到的)

注册新账号#

  1. 携带本机设备硬件信息访问 /api/user
{
"platform": "Android",
"deviceModel": "Samsung SM-S9210",
"operatingSystem": "Android OS 12 / API-32 (V417IR/1974)"
}

服务端将返回一个包括 userRegistration credential updatedResources 三个字段的新用户包

  1. 客户端携带 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": []
}
  1. 然后客户端则携带 X-Session-Token 先后访问了
GET /api/suitemasterfile/5.6.0.30/00_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41
GET /api/suitemasterfile/5.6.0.30/01_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41
GET /api/suitemasterfile/5.6.0.30/02_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41
GET /api/suitemasterfile/5.6.0.30/03_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41
GET /api/suitemasterfile/5.6.0.30/04_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41
GET /api/suitemasterfile/5.6.0.30/05_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41
GET /api/suitemasterfile/5.6.0.30/06_bb2194a8ea47722555dd056f8cc2a2d4563ea2041d4a3657b427285dca156e41

经测验,虽然携带了 X-Session-Token,但实际上需要的是包含完整 CloudFrontCookie

巨长的数据,编码后都有这么大,霓虹人太可怕了

[整活]强制 All Perfect#

警告:此脚本存在封号风险,切勿使用

image-20250927142928501
image-20250927142928501

from mitmproxy import http
from time import time
from crypto import prsk_dec, prsk_enc
import json
from 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-card
from mitmproxy import http
from crypto import prsk_dec, prsk_enc # type: ignore
target_image_b64 = None
assert 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 切片并压缩

Terminal window
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.jpg

exp

from mitmproxy import http
from base64 import b64encode
from 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

Terminal window
└─# 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.json
tw-6.0.0.xapk/installtime.apk/assets/url_config.json

猜测该文件只是字节渠道服所设计,日服不应有该文件

因此首先考虑审计 cn-6.0.0 的渠道层,推测有关 HOST 表的内容位于 Java 层而不在 il2cpp

classes3.dex
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.soglobal-metadata.dat 通过 il2cppDumper 解包

dnspy 打开 DummyDll/Assembly-CSharp.dll,容易找到

Sekai.EnvironmentConfig
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);
}
Sekai.GetAppInfoAPId
// 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,那么回顾一下现有各文件的来源:

graph LR APK[apk] SO[libil2cpp.so] META1[global-metadata.dat] META2[global-metadata.dat] PROCESS[il2cppDumper] DLL[Assembly-CSharp.dll] APK <--> SO APK <--> META1 META1 <--> |XOR key| META2 META2 --> PROCESS SO --> PROCESS PROCESS --> DLL

能改的无非是改原 libil2cpp.soglobal-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"

可惜还是不行,抓日志发现

Terminal window
PS E:\> adb logcat -s Unity
--------- beginning of main
04-03 11:30:19.810 14331 14421 W Unity : Non-secure network connections disabled in Player Settings
04-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,其中 https127.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 这一段攻击链中,主要涉及以下几个方面的问题:

  1. global-metadata.dat 解密过于简单且可逆,导致攻击者可以自由解密和重加密
  2. libil2cpp.so 明文存于 apk 中,易于解密、审计和篡改
  3. apk 缺少强签名校验,很容易被篡改

对此我有几个不成熟的修复方案仅供参考,

  1. 最简单最粗暴的方法,给 apk 加壳加强签名校验,篡改后无法安装
  2. 使用非对称加密来保护 global-metadata.dat,使攻击者无法重加密,将弱点转移至保护较短的、更便于隐藏和混淆的解密公钥上
  3. 关键常量字符串混淆或拼接,增加攻击者的审计难度
  4. 移除对回环地址的白名单
  5. 对代码进行混淆,避免攻击者轻易找到切入点(我总记得以前是有混淆的来着?)
  6. 移除 url_config.json,将渠道服和非渠道服的差异化配置放在代码中进行编译时区分,而不是放在外部文件中
  7. 使用极短的域名来获取实际 API 地址,限制攻击者的篡改范围
  8. 使用自行签发的证书,拒绝其他包括根证书在内的证书,增加攻击者的测试难度
  9. 参考其他游戏,如「明⚪⚪舟」,有极为严苛的环境检查,从开始即限制攻击者的抓包分析和修改测试
  10. 现有逻辑是极为标准的 Unity il2cpp 游戏模型,可以考虑对敏感逻辑进行特殊化处理,减少同质化攻击面

PS 编写#

尝试中,霓虹人定义的数据结构太恐怖了

第三方工具#

Sekai Viewer#

众所周知的 Sekai Viewer

Moe Sekai#

非常好用的新一代数据查看器 Moe Sekai

SekaiTools#

可用于 “统计原理、统计器、查看和编辑统计结果、系统语音、静态Spine场景、羁绊称号、互动语音” 等

参见 Github Bilibili

注入博客(以 hexo 为例)#

参见 在你的博客里放一只可爱的Spine Model吧~ | c10udlnk’ Blog

资源工具 sssekai#

用于下载完整的未加密混淆的资源文件,参见 GitHub

示例使用:

  1. 构建 / 更新资源缓存索引例:
Terminal window
sssekai abcache --db ./abcache.db --app-region jp --app-version 6.0.1 --app-appHash 0bd8114a-e782-681a-a54e-c5ec1ffb33b5
  1. 下载资源包 / asset bundle
Terminal window
sssekai abcache --db ./abcache.db --no-update --download-dir ./bundles/
  1. 挂载资源 (Web)
Terminal window
sssekai abserve --db ./abcache.db --host 127.0.0.1 --port 8831

第二章-新的视角#

20260605

前段时间给东方夜雀食堂写了很久的模组,自认为对 il2cpp 的游戏有一定了解,按已有经验对相关问题重新分析一下。因此开了第二章,算是个人在有一定经验后,快速的逆向与更深入的审计。

常规提取#

jp-6.5.5 提取 global-metadata.datlibil2cpp.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_3FDBB48
if ( 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.datlibil2cpp.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_pointer

sub_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:

image-20260615225953301
image-20260615225953301

// base(libhhld-rt.so) + 0x29E5C
jint 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) - 1
JNI_OnLoad = 0x29E5C
off_1E4008 = 0x108D3C4
off_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_37EC8

sub_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, 0
byte_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) - 1
sub_356B4 = 0x356B4
off_1E40B0 = 0x82B930
off_1E40D0 = 0x94A138
off_1E40D8 = 0x9CAA10
off_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+40
0x35964: 包装真实 read,调用 sub_80F3C,结果写到 arg+24
0x35984: 包装 __errno
0x3599C: 复制 errno 类状态
0x359B4: 调用内层 VM wrapper sub_355C0
0x359D0: 如果标志位设置,则恢复 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) - 1
sub_355C0 = 0x355C0
off_1E40B0 = 0x82B930
off_1E40C0 = 0x94A088
off_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_buffer
arg1 = read_size
调用 sub_358A4({ mapped_buffer, read_size, bool_out })
如果 bool_out == 0:
return mapped_buffer
payload = mapped_buffer + 8
region = 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

这同时解决了两个问题:

  1. 解密不是整文件,而是 header 字段指定的一段区域。
  2. 返回给 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 路径里 srcdst 是同一个地址,因此是原地还原:

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 Path
import 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"))

image-20260616025105238
image-20260616025105238

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数据库,可用于辅助定位函数、理解控制流或加速分析过程。

逆向分析准备#

  1. 恢复 global-metadata.dat,老生常谈的话题了,应该非常简单了吧)大家都会了。
  2. 找到 libil2cpp.so,一般在 安装包里的 lib/arm64-v8a/libil2cpp.so
  3. 使用 Il2CppDumper 读取 global-metadata.datlibil2cpp.so,等待一会可以得到一些产物。如果报错,检查是否是二者有未解开的加密或损坏。
  4. 将 Il2CppDumper 生成的 DummyDll/Assembly-CSharp.dll 导入dnSpy。通过该文件,我们可以较为直观地查看游戏逻辑中的类定义、字段名称以及方法签名,并定位其虚拟地址。
  5. 在 dnSpy 中选中 Assembly-CSharp.dll,并通过 文件 > 导出到工程 将整个项目进行导出,导出的代码不含具体逻辑,只含结构,以供 AI Agent 进行标准的参考。
  6. 使用 IDA Pro 打开 libil2cpp.so,等待其完成初始自动分析(首次加载耗时会非常长,建议导入后直接睡到天明)。
  7. 关闭 IDA,切记勾选 Collect garbage不要勾选 Don't SAVE the database,备份产物 libil2cpp.so.i64
  8. 重新打开 libil2cpp.so.i64,点击 File -> Script file,运行 Il2CppDumper 附带的 ida_with_struct_py3.py 脚本。根据脚本提示,依次选择生成的 script.jsonil2cpp.h 文件,耐心等待符号与结构信息导入完成。(这次处理耗时也会非常长,建议处理后再睡一觉到天明)。
  9. 关闭 IDA,切记勾选 Collect garbage不要勾选 Don't SAVE the database,重新备份产物 libil2cpp.so.i64
  10. 为 IDA 安装 IDA Pro MCP
  11. 安装 skill:IL2cpp-to-csharp-skill
  12. 打开 IDA,启用 IDA Pro MCP,将桩代码目录和 Il2CppDumper 的产物 stringliteral.json 路径提供给 Agent,即可让 Agent 开始逆向分析。

mod 制作#

20260606

注:以下测试均在断网环境的模拟,未接入服务器,未造成任何影响,仅供学习和参考,不提供任何技术支持。

在今天之前我也很难以想象我能做到这一步,但是确实成功了。

搭建好基础环境后,编写就和普通 PC 端的 BepInEx il2cpp mod 一样了。

FusionCore#

感谢项目:All-Of-Us-Mods/FusionCore

这是我针对性修复一些问题后的 fork 版本:MetaMikuAI/FusionCore

当然我不可能提供可以直接运行的版本,需要额外做如下:

  1. 请按照 README 自行添加对应包名
  2. 请按照 README 自行编译并安装
  3. 启动后首先关闭设置中的 Download unstripped libunity 选项
  4. 解密后global-metadata.dat 放到 /sdcard/FusionCore/<包名>/

我在编译原仓库时,发现有几个问题,并在我的 fork 仓库被修复或解决:

  1. 游戏直接闪退:FusionCore 误将 global-metadata.dat 当明文读取而崩溃,这在 Add global metadata override during bootstrap 被解决,请将解密后的 global-metadata.dat 放到 /sdcard/FusionCore/<包名>/
  2. 游戏可以正常加载,但有关 Google Play 的一些图标等资源加载错误:FusionCore 包装后导致和 Google Play 通信的一些问题,这在 Fix launcher context wrapping for game resources 被修复。
  3. 游戏可以正常加载,但是只能在标题页,点击标题无法登录游戏:与第二条类似的问题,这在 Fix Unity launch context and Firebase discovery 中被修复。

游戏正常加载,甚至可以直接带起来 UnityExplorer

mini live 和 mini miku…
mini live 和 mini miku…

搭建 mod 编译环境并测试 mod#

与正常 il2cpp 游戏的 bepinex 环境搭建方法类似,首先至少运行过一次 FusionCore,然后复制 /sdcard/FusionCore/<包名>/BepInEx 到电脑上,这个目录即是编译 mod 所需的「依赖」。

我先前在为《东方夜雀食堂》制作 mod 时顺便写了一组很详细的开发文档,而且流程几乎一模一样。因此这里只会简单介绍一下流程,有很多细节的省略,推荐将博客和开发文档对照着阅读。MetaMystia文档 - hello world 其一。但是注意,其中 .NET 版本应当选择 10.0,除此之外无其他不同。

在这里我直接用命令行创建。classlib 代表类库,用于 mod;SekaiModTest 代表项目名,任取。

Terminal window
dotnet new classlib -n SekaiModTest

创建好后请确保 SekaiModTest.csprojTargetFrameworknet10.0,并添加引用(先前导出的 BepInEx/core/xxx.dllBepInEx/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;
}
}

这段代码的大概含义是:

  1. 模组加载时打印三行日志,并向 Sekai.Core.Live.ScoreLogic 应用钩子。
  2. 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#

image-20260615211510387
image-20260615211510387