Project SEKAI 逆向笔记 持续更新

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

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

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

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

更新日志

  • 20250410 完成了 静态解包 第三方工具 等部分的大致整理
  • 20250411 完成了 提取 global-metadata.dat 部分的整理

静态解包

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

动态分析

提取 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 这三段内存

1
2
3
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 找这段内存

1
cat /proc/4958/maps | more
1
2
3
4
5
6
7
8
9
10
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--

找到

1
7b263a0000-7b2761f000 rw-p 00000000 00:00 0

dd dump 出这段内存

1
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-20250416002305548

LoadMetadataFile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
_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 的默认实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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

获取 AES key (成功)

配置工具链 root 物理机 + frida + frida-il2cpp-bridge

hook 下面脚本

脚本来自 Project SEKAI 逆向 - 笔记归档 - IL2CPP 动态调用 runtime

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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

1
2
3
4
5
# 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 并扫码连接

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

1
2
3
4
5
6
7
8
9
10
正常情况下
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)

1
2
3
4
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 加解密脚本如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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 均以该方法加密,本文的剩余部分将直接解密不再重复强调

[整活] 十连十彩 (成功)

在正式 API 分析前,整个活

目的:在不侵害原 colorful stage 任何利益的前提下,获得十连十彩的视频

观察发现抽卡的时候会向形如 https://production-game-api.sekai.colorfulpalette.org/api/user/1145141919810/gacha/803/gachaBehaviorId/3139?isPriorityUsePaidJewel=False 的 API 发送 request,并在 response 中返回一大堆东西,responseobtainPrizes 键下有十个卡牌信息,便是抽卡结果信息,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
"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 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# 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 均为:
1
2
3
4
{
"typ": "JWT",
"alg": "HS256"
}

速写了一段用于 JWT 分析(加解密函数略)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
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. 注:jwt 的签名密钥无从得知,如果需要在官方服务器和个人服务器之间反复测试的话,个人服务器不得分发新的 credential (但是可以重放),以免账号丢失

PS 编写

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

第三方工具

Sekai Viewer

众所周知的 Sekai Viewer

SekaiTools

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

参见 Github Bilibili

注入博客(以 hexo 为例)

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

资源工具 sssekai

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

__END__