School of Lust x0.9.6a 资源封装与离线解密分析
分析对象:School_of_Lust_Win_x0.9.6a
分析日期:2026-10-17
摘要
本文分析 Windows 平台游戏 School of Lust x0.9.6a 的资源封装。该发行包使用 NW.js 0.80 运行 RPG Maker MV 1.6.1 内容,不属于 Electron。游戏把数据库、图片和音频替换为自定义 .soldata 容器,并保留一批 RPG Maker MV 的 .rpgmvp、.rpgmvo 文件。本文从目录结构、原生启动器、混淆脚本和文件内容四个方向定位解密逻辑,得到两套 AES-256-CBC 参数和一套 16 字节掩码规则,并给出可编译的 C 实现与 x86-64 汇编实现。对 6,153 个 .soldata、256 个 .rpgmvp 和 427 个 .rpgmvo 做全量校验,失败 0 个。最终解包得到 9,620 个文件,约 1.2 GB,包含数据库、图片、音频、Live2D 模型和脚本。
关键词:NW.js;RPG Maker MV;AES-CBC;资源解包;逆向分析;x86-64 汇编
1 引言
样本目录带 nw.dll、nw_elf.dll 和 nw_100_percent.pak,读者容易把它误判为 Electron。根目录 package.json 把入口指定为 www/index.html,运行时是 NW.js 0.80。游戏本体是 RPG Maker MV 1.6.1,角色动画使用 Live2D Cubism 3。
打包器替换了资源层。www/data 下 403 个数据库和地图文件全部变成 .soldata,rpg_managers.js 仍然请求 Actors.json、Map001.json。www/img 和 www/l2d 的大部分图片也换成 .soldata,文件名变成 Base64 字符串。直接解压只能拿到密文。
容器由游戏自带的 Dhoom 插件读写。密钥和 IV 从文件属性派生。离线还原不需要运行游戏,也不需要第三方翻译工具。
2 分析对象与方法
2.1 样本规模
样本根目录 9,852 个文件,374 个目录,合计 1,602,621,679 字节。资源集中在 www,共 9,707 个文件,1,219,195,106 字节。
表 1 列出主要文件类型。
| 扩展名 | 数量 | 字节数 | 性质 |
|---|---|---|---|
.soldata | 6,153 | 974,200,720 | 自定义 AES 容器 |
.mtn | 1,780 | 7,042,417 | Live2D 动作,明文 |
.json | 804 | 2,393,407 | 配置、插件参数、模型引用 |
.rpgmvo | 427 | 113,486,112 | RPG Maker MV 音频容器 |
.rpgmvp | 256 | 29,794,539 | RPG Maker MV 图片容器 |
.js | 137 | 4,756,604 | 引擎与插件 |
.moc | 90 | 45,140,399 | Live2D 旧模型,明文 |
.moc3 | 6 | 22,520,576 | Live2D Cubism 3 模型,明文 |
.png | 39 | 16,045,191 | 明文图片 |
.soldata 分三处:www/data 403 个,www/img 5,311 个,www/l2d 439 个。
2.2 工具链
原生分析使用 radare2 6.1.8 的 rabin2 与 radare2。脚本分析使用 Node.js 24.21。C 代码使用 MSYS2 UCRT64 的 GCC 16.2 与 OpenSSL 的 libcrypto。验证脚本使用 Python 3.14 与 PyCryptodome。
REA 的会话没有绑定目标,当前只暴露 artifact、.NET 和 macOS 三个 provider。D:\Program\rea-main\ida-mcp.json 配置为 mode: headless,由 idalib-mcp.exe 自己加载文件,无法附着到已打开的 IDA GUI。原生层的结论由 rabin2 和 objdump 得到,不依赖 IDA 会话。
2.3 分析流程
文件清单和 magic 统计确定哪些文件是密文。rabin2 -zz 搜索启动器和运行时里的业务字符串,判断解密是否在原生层。受限 Node VM 加载混淆脚本,截获自解码后的字符串表和函数体,提取密钥、IV 规则和文件名映射。C、汇编与 Node 三套实现交叉验证,全量文件在内存中校验。
3 引擎与目录结构
3.1 引擎判定
SOL.exe 是 PE32+ x86-64 可执行文件,PDB 路径为 E:\nw80_win64\node-webkit\src\outst\nw\initialexe\nw.exe.pdb。版本资源写着 The NW.js Community、nwjs.io 和 Copyright 2023, The NW.js community and The Chromium Authors。导入表从 nw_elf.dll 取 GetInstallDetailsPayload、IsBrowserProcess、IsExtensionPointDisableSet 和 SignalChromeElf。
Electron 的典型目录 resources\app.asar、resources\app\、chrome_100_percent.pak 都不存在。存在的是 nw.dll、node.dll、nw_elf.dll、nw_100_percent.pak 和 nw_200_percent.pak。引擎是 NW.js 0.80。
根 package.json 内容如下:
{
"name": "School of Lust",
"main": "www/index.html",
"js-flags": "--expose-gc",
"window": { "width": 816, "height": 624, "icon": "www/icon/icon.png" }
}3.2 脚本构成
www/js 有 8 个引擎脚本和 105 个插件。rpg_core.js 头部注释为 rpg_core.js v1.6.1。插件以 Dhoom 系列为主,另有 GALV、YEP、SRD、MOG 等公开插件。
同一套自定义字符串数组壳包住 28 个文件,包括 rpg_core.js、DhoomPreCache.js、DhoomVariableHUD.js 和多数 Dhoom 插件。其余插件是明文。
4 封装格式
4.1 .soldata
6,153 个文件全部满足 size % 16 == 0。首 16 字节没有固定 magic,字节熵约 7.99,16 字节块没有重复。长度关系显示密文没有单独存 IV:对 26 个可以对照的 Live2D 纹理,.soldata 长度等于“明文 PNG 去掉 96 字节 tEXt 块后按 PKCS#7 补齐”的长度。
解密算法为 AES-256-CBC,PKCS#7 填充。数据文件和图片文件使用两把不同的密钥,IV 从原始文件名派生。
表 2 给出参数。
| 用途 | 密钥(JWK k,base64url) | 密钥(十六进制) |
|---|---|---|
www/data | XXXXmae0qBokfe91IvZZZZQBWDAnoDV8QQQQ938XXXX | 5d75d799a7b4a81a247def7522f659659401583027a0357c410410f77f175d75 |
www/img、www/l2d | HARDmae0qBokfe91IvTiTSQBWDAnoPOEPmr5938C0RE | 1c044399a7b4a81a247def7522f4e24d2401583027a0f3843e6af9f77f02d111 |
IV 规则:
- 数据文件取文件名词干的前 16 个 ASCII 字符,转为小写,不足 16 字节补
0x00。 - 图片文件取原始文件名词干的前 16 个 ASCII 字符,保留大小写,不足补
0x00。
数据文件的磁盘名不变,Actors.soldata 对应 Actors.json。图片文件的磁盘名是 LZString 压缩后的 Base64:
stem = LZString.decompressFromBase64("C4UwHsCuBOIPoAYAsQA="); // "texture_04"
diskName = LZString.compressToBase64(stem) + ".soldata";这个映射可逆。4,962 个 Base64 风格文件名都能还原成可读英文名,例如 panel_bg、people_02、protagonist_faces4、park、texture_08。LZString 实现就在 www/js/libs/lz-string.js。
解密后的内容:403 个数据文件全部是合法 JSON,5,750 个图片文件全部是 PNG。
4.2 .rpgmvp 与 .rpgmvo
文件头 16 字节:
52 50 47 4D 56 00 00 00 00 03 01 00 00 00 00 00头部之后是 16 字节字段,再往后是数据。RPG Maker MV 常见的写法是整段数据与密钥循环异或。这个发行包只处理前 16 字节:
plain[0..15] = field[0..15] XOR M
plain[16..] = blob[32..]
M = 0B A9 E8 88 58 EB 6F 1D 2B 52 1C A7 05 9F 96 BF示例文件 img/achievements/background.rpgmvp 的字段与掩码异或后得到 89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52,正好是 PNG 的签名和 IHDR 头。重建后的 IHDR CRC 为 06 D8 5B FD,与文件内记录一致。
全量结果:256 个 .rpgmvp 全部解出 PNG,427 个 .rpgmvo 全部解出 Ogg。没有 .rpgmvm。
4.3 Live2D 与其他文件
Live2D 的 .moc3、.moc、.mtn、.model3.json、.physics3.json 和 .motion3.json 都是明文。加密的只有 .2048 目录下的纹理。92 个模型目录中有 6 个 Cubism 3 模型,其余是旧版 .moc 加 .mtn。
locales/*.pak、resources.pak、nw_*.pak 是 Chromium 语言包和 UI 资源,magic 为 05 00 00 00 01 00 00 00,与游戏内容无关。根目录的 TrsData.bin 是 MTool 翻译数据,Use me to open the Tool.bat 指向缺失的 Tool\MTool.exe。这两者不参与资源解密。
5 解密实现
5.1 C 实现
C 版本只依赖 OpenSSL 的 libcrypto。核心步骤是选择密钥、构造 IV、调用 EVP_DecryptUpdate 和 EVP_DecryptFinal_ex。PKCS#7 去填充由 EVP_DecryptFinal_ex 完成。
static void iv_from_stem(const char *stem, int lower, unsigned char iv[16]) {
size_t i;
memset(iv, 0, 16);
for (i = 0; i < 16 && stem[i]; i++)
iv[i] = lower ? (unsigned char)tolower((unsigned char)stem[i])
: (unsigned char)stem[i];
}
static int aes_cbc_decrypt(const unsigned char key[32], const unsigned char iv[16],
const unsigned char *in, int inlen,
unsigned char *out, int *outlen) {
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
int len = 0, total = 0;
if (!ctx) return 0;
if (EVP_DecryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, key, iv) != 1) goto fail;
if (EVP_DecryptUpdate(ctx, out, &len, in, inlen) != 1) goto fail;
total = len;
if (EVP_DecryptFinal_ex(ctx, out + total, &len) != 1) goto fail;
*outlen = total + len;
EVP_CIPHER_CTX_free(ctx);
return 1;
fail:
EVP_CIPHER_CTX_free(ctx);
return 0;
}数据文件调用时传 lower = 1 和 DATA_KEY。图片文件传 lower = 0 和 IMG_KEY,文件名由 LZString 还原。完整可编译版本见附录 B。
实测输出:
data len=9369 first16=5b0a6e756c6c2c0a7b226964223a312c (JSON 开头)
image len=742715 first16=89504e470d0a1a0a0000000d49484452 (PNG 开头)5.2 x86-64 汇编实现
.rpgmvp 的掩码还原适合用 SSE2。下面这段代码按 Win64 调用约定编写,rcx 是目标缓冲区,rdx 是源文件,r8 是文件总长度。
.intel_syntax noprefix
.text
.globl rpgmv_unmask_asm
# Win64 ABI: rcx = dst, rdx = src, r8 = total file length
rpgmv_unmask_asm:
movdqu xmm0, [rdx + 16] # 头部之后的 16 字节字段
movdqu xmm1, [rip + mask16] # 固定掩码
pxor xmm0, xmm1
movdqu [rcx], xmm0 # 还原明文前 16 字节
lea r9, [rdx + 32] # 源数据起点
lea r10, [rcx + 16] # 目标数据起点
sub r8, 48 # 去掉 16B 头、16B 字段、16B 已还原块
jbe done
copy:
mov al, [r9]
mov [r10], al
inc r9
inc r10
dec r8
jne copy
done:
ret
.section .rdata
.align 16
mask16:
.byte 0x0b,0xa9,0xe8,0x88,0x58,0xeb,0x6f,0x1d
.byte 0x2b,0x52,0x1c,0xa7,0x05,0x9f,0x96,0xbfmovdqu 允许非对齐访问,源字段位于偏移 16,目标缓冲区通常来自 malloc,不能假定 16 字节对齐。pxor 一次处理 16 字节,正好覆盖掩码长度。数据区从偏移 32 开始,前 16 字节已经在 XMM0 中还原,所以复制长度是总长度减 48。
这个上界在第一版代码里写成了 sub r8, 32,结果比目标缓冲区多写 16 字节。测试程序在 img/achievements/background.rpgmvp 上直接崩溃。改成 sub r8, 48 后,输出首 16 字节为 89504e470d0a1a0a0000000d49484452,与 PNG 头一致。
汇编函数与 C 主程序一起链接,使用 GCC 16.2 编译。完整的 C 调用代码见附录 B。
5.3 原生层反汇编
SOL.exe 的入口在 0x14015bc50。objdump -d -M intel 的前 20 条指令如下:
000000014015bc50:
14015bc50: 48 83 ec 28 sub rsp,0x28
14015bc54: e8 0b 00 00 00 call 0x14015bc64
14015bc59: 48 83 c4 28 add rsp,0x28
14015bc5d: e9 7a fe ff ff jmp 0x14015badc
14015bc62: cc int3
14015bc63: cc int3
14015bc64: 48 89 5c 24 20 mov QWORD PTR [rsp+0x20],rbx
14015bc69: 55 push rbp
14015bc6a: 48 8b ec mov rbp,rsp
14015bc6d: 48 83 ec 20 sub rsp,0x20
14015bc71: 48 8b 05 00 5f 09 00 mov rax,QWORD PTR [rip+0x95f00]
14015bc78: 48 bb 32 a2 df 2d 99 movabs rbx,0x2b992ddfa232
14015bc82: 48 3b c3 cmp rax,rbx
14015bc85: 75 74 jne 0x14015bcfb
14015bc87: 48 83 65 18 00 and QWORD PTR [rbp+0x18],0x0
14015bc8c: 48 8d 4d 18 lea rcx,[rbp+0x18]
14015bc90: ff 15 72 64 08 00 call QWORD PTR [rip+0x86472]
14015bc96: 48 8b 45 18 mov rax,QWORD PTR [rbp+0x18]
14015bc9a: 48 89 45 10 mov QWORD PTR [rbp+0x10],rax
14015bc9e: ff 15 34 63 08 00 call QWORD PTR [rip+0x86334]0x14015bc64 是 MSVC CRT 的 __security_init_cookie。它调用 GetSystemTimeAsFileTime、GetCurrentThreadId、GetCurrentProcessId 和 QueryPerformanceCounter,把结果异或后写入 0x1401f1b78。入口随后跳到 0x14015badc,也就是 CRT 的常规启动流程。
rabin2 -zz 在 SOL.exe 和 nw.dll 中搜索 soldata、rpgmv、decrypt、AES、MTool,没有命中。导入表里没有文件解密相关的 API。原生层没有处理 .soldata 的代码。
5.4 混淆脚本的还原
壳的结构类似下面的形式,变量名每个文件不同:
(function(){
var t='', n=413-402;
function D(s){ /* 字符置换 */ }
var name=D('...').substr(0,n);
var body='<打乱的字符串>';
var F=D[name]; // name 解出来是 "constructor"
var fn=F('', D(body)); // 等价于 Function('', decodedCode)
fn(D('<第二段字符串>'));
})()D[name] 取到的是 Function 构造函数。直接替换 globalThis.Function 不起作用,壳走的是 D.constructor。在 VM 中同时替换 Function.prototype.constructor 才能截获:
Object.defineProperty(Function.prototype, 'constructor', {
value: new Proxy(Function, {
apply(t, self, args) {
captured.push(args[args.length - 1]);
return Reflect.apply(t, self, args);
},
construct(t, args) {
captured.push(args[args.length - 1]);
return Reflect.construct(t, args);
}
}),
configurable: true,
writable: true
});截获的代码里出现 Dhoom_DataEncryption、Dhoom_ImageEncryption、finExtension、.soldata、A256CBC、AES-CBC、importKey、getIv 等字符串。两把密钥写在 importKey 的 JWK 参数里。getIv 的原文如下:
$["getIv"] = function (f) {
f = f.toLowerCase().replace(this.finExtension, this.extension);
f = this.split(f)[1];
var d = new Uint8Array(16);
for (var c = 0; c < f.length; c++) {
d[c] = f.charCodeAt(c);
if (c === 15) break;
}
return d;
};图片插件 Dhoom_VariableHUD.js 的对应函数没有 toLowerCase()。这个差异决定了两套 IV 规则。
6 验证
6.1 全量内存校验
校验脚本逐文件读入内存,解密后检查 JSON 解析、PNG 签名、IHDR CRC 和 Ogg 头,不写磁盘。
| 项目 | 数量 | 结果 |
|---|---|---|
.soldata 数据 | 403 | JSON 全部解析成功 |
.soldata 图片 | 5,750 | PNG 全部通过签名与 CRC |
.rpgmvp | 256 | PNG 全部通过 |
.rpgmvo | 427 | Ogg 全部通过 |
| 失败 | 0 |
PNG 尺寸分布以 1296×768(2,773 张)、2592×1536(739 张)、2048×2048(369 张)为主。
6.2 异常文件
两个 .soldata 的磁盘文件名与加密 IV 编号不一致:
| 磁盘名解压结果 | 实际 IV 前缀 | 处理 |
|---|---|---|
sc_st_a_17_r_head2_face_lewd_m0 | sc_st_a_18_r_hea | 恢复 IV,IHDR CRC 通过 |
_playerbullet7 | _playerbullet8 | 恢复 IV,输出为 _playerbullet8.png |
AES-CBC 的 IV 只影响第一块。利用 PNG 固定的 16 字节头可以反推正确 IV:
IV_correct = IV_from_name XOR P_got[0..15] XOR PNG_HEAD[0..15]解包器在检测到“PNG 签名正确但 IHDR 或 CRC 失败”时执行这一步。
basketball-score17 同时存在 .soldata 和 .rpgmvp 两个版本,尺寸都是 383×264,字节不同。游戏通过 Dhoom 插件加载 .soldata,所以解包器保留 .soldata 版本为 basketball-score17.png,把 .rpgmvp 版本另存为 basketball-score17.rpgmvp.png。
6.3 解包结果
最终输出目录为 \unpack。
| 项目 | 数量 |
|---|---|
| 输出文件总数 | 9,620 |
| 总大小 | 1.2 GB |
| 解出的 JSON | 403 |
| 解出的 PNG | 5,959 |
| 解出的 OGG | 427 |
| 复制的明文文件 | 2,871 |
| 失败 | 0 |
| 完全相同的重复资源 | 89 |
| 同名不同内容的变体 | 28 组 |
5,959 张 PNG 由 5,920 张解密图、26 张明文纹理变体和 13 张原样复制的图标、光标、系统图组成。26 张 Live2D 纹理同时保留了解密版和 ZIP 自带明文版,文件名带 .soldata.png 或 .copy.png 后缀,内容只差一个 tEXt 标记块。l2d/class_gs/class_gs.model3.json 引用的 .moc3、8 张纹理和 motions/ 均已就位,可以直接用 Cubism Viewer 打开。
7 讨论
解密逻辑位于 Dhoom 插件,不在原生运行时。SOL.exe 和 nw.dll 的版本资源、PDB 路径和导入表都指向原版 NW.js 0.80,字符串搜索没有发现自定义解密代码。样本附带的 MTool 批处理和 TrsData.bin 属于第三方翻译工具,与资源加密无关。缺少 Tool\MTool.exe 不影响解包。
两把密钥在插件里以 XXXX 和 HARD 开头,_key 字段还写着 Testing AES Key That Need to be Replaced。这些是开发者留下的占位串,当前版本实际生效的是 JWK 里的 k 值。
脚本壳只做字符串数组混淆与控制流混淆,没有反调试或虚拟机保护。28 个混淆文件都能在 Node VM 中还原。原生层分析用于排除错误方向,不参与算法提取。
版本移植需要重新确认三处:Dhoom_PreCache.js 的 importKey 的 k、Dhoom_VariableHUD.js 的 k、以及 getIv 是否仍然按文件名词干取前 16 字节。先用 --check 跑一遍,失败集中在 .soldata 就说明密钥或 IV 规则变了。
尚未处理的部分:TrsData.bin 的正文编码,明文 PNG 的 80 字符 tEXt 标记含义,以及解包后的目录能否直接运行。插件重写了 DataManager,固定读取 .soldata,直接删除密文会让游戏加载失败。要恢复可运行状态,需要同时禁用 Dhoom_PreCache 和 Dhoom_VariableHUD 的加密分支,或者保留原始密文目录。
8 结论
School of Lust x0.9.6a 使用 NW.js 0.80 承载 RPG Maker MV 1.6.1 内容。资源层由 Dhoom 插件实现自定义保护:数据与图片使用 AES-256-CBC,图片文件名用 LZString 压缩,IV 从文件名词干派生;RPG Maker MV 容器改为只异或前 16 字节。两套密钥、IV 规则和掩码规则均已通过全量校验,C 与 x86-64 汇编实现可编译运行。6,836 个加密文件全部还原,解包输出 9,620 个文件,覆盖数据库、图片、音频、Live2D 模型、脚本和存档。
参考文献
[1] NW.js. NW.js Documentation. https://nwjs.io/
[2] RPG Maker MV. rpg_core.js v1.6.1,样本文件 www/js/rpg_core.js。
[3] pieroxy. lz-string: LZ-based compression algorithm. https://github.com/pieroxy/lz-string
[4] OpenSSL. EVP Symmetric Encryption and Decryption. https://docs.openssl.org/master/man3/EVP_EncryptInit/
[5] radare2. radare2 Reverse Engineering Framework. https://rada.re/n/
[6] 样本文件. School of Lust x0.9.6a,C:\Users\Kino\Program2\hetunren\sample\School_of_Lust_Win_x0.9.6a。
附录 A 复现命令
只读校验,不写文件:
node md/src/unpack_sol.js --www "C:/Users/Kino/Program2/hetunren/sample/School_of_Lust_Win_x0.9.6a/www" --check解出加密资源:
node md/src/unpack_sol.js --www "C:/Users/Kino/Program2/hetunren/sample/School_of_Lust_Win_x0.9.6a/www" --out "C:/Users/Kino/Program2/hetunren/unpack"补齐明文资源:
node md/src/unpack_sol.js --www "C:/Users/Kino/Program2/hetunren/sample/School_of_Lust_Win_x0.9.6a/www" --out "C:/Users/Kino/Program2/hetunren/unpack" --mode full --overwrite编译 C 与汇编测试程序:
gcc -O2 -o soldata_test.exe soldata_decrypt.c rpgmv_unmask.S -lcrypto附录 B C 源码
以下代码与汇编函数一起编译,已在样本上验证。
#include <openssl/evp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
static const unsigned char DATA_KEY[32] = {
0x5d,0x75,0xd7,0x99,0xa7,0xb4,0xa8,0x1a,0x24,0x7d,0xef,0x75,0x22,0xf6,0x59,0x65,
0x94,0x01,0x58,0x30,0x27,0xa0,0x35,0x7c,0x41,0x04,0x10,0xf7,0x7f,0x17,0x5d,0x75
};
static const unsigned char IMG_KEY[32] = {
0x1c,0x04,0x43,0x99,0xa7,0xb4,0xa8,0x1a,0x24,0x7d,0xef,0x75,0x22,0xf4,0xe2,0x4d,
0x24,0x01,0x58,0x30,0x27,0xa0,0xf3,0x84,0x3e,0x6a,0xf9,0xf7,0x7f,0x02,0xd1,0x11
};
extern void rpgmv_unmask_asm(unsigned char *dst, const unsigned char *src, size_t len);
static void iv_from_stem(const char *stem, int lower, unsigned char iv[16]) {
size_t i;
memset(iv, 0, 16);
for (i = 0; i < 16 && stem[i]; i++)
iv[i] = lower ? (unsigned char)tolower((unsigned char)stem[i])
: (unsigned char)stem[i];
}
static int aes_cbc_decrypt(const unsigned char key[32], const unsigned char iv[16],
const unsigned char *in, int inlen,
unsigned char *out, int *outlen) {
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
int len = 0, total = 0;
if (!ctx) return 0;
if (EVP_DecryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, key, iv) != 1) goto fail;
if (EVP_DecryptUpdate(ctx, out, &len, in, inlen) != 1) goto fail;
total = len;
if (EVP_DecryptFinal_ex(ctx, out + total, &len) != 1) goto fail;
*outlen = total + len;
EVP_CIPHER_CTX_free(ctx);
return 1;
fail:
EVP_CIPHER_CTX_free(ctx);
return 0;
}
static unsigned char *read_file(const char *path, int *size) {
FILE *f = fopen(path, "rb");
unsigned char *buf;
long n;
if (!f) return NULL;
fseek(f, 0, SEEK_END);
n = ftell(f);
fseek(f, 0, SEEK_SET);
buf = (unsigned char *)malloc((size_t)n);
if (!buf || fread(buf, 1, (size_t)n, f) != (size_t)n) {
free(buf);
fclose(f);
return NULL;
}
fclose(f);
*size = (int)n;
return buf;
}
int main(int argc, char **argv) {
unsigned char *in, *out;
int n, outlen, i;
const unsigned char *key;
unsigned char iv[16];
if (argc < 3) return 2;
in = read_file(argv[2], &n);
if (!in) return 3;
if (strcmp(argv[1], "rpgmv") == 0) {
if (n < 32) return 4;
out = (unsigned char *)malloc((size_t)n - 32);
rpgmv_unmask_asm(out, in, (size_t)n);
printf("rpgmv plain first16: ");
for (i = 0; i < 16; i++) printf("%02x", out[i]);
printf("\n");
free(out);
free(in);
return 0;
}
if (argc < 4) return 2;
key = strcmp(argv[1], "data") == 0 ? DATA_KEY : IMG_KEY;
iv_from_stem(argv[3], strcmp(argv[1], "data") == 0, iv);
out = (unsigned char *)malloc((size_t)n);
if (!aes_cbc_decrypt(key, iv, in, n, out, &outlen)) {
fprintf(stderr, "decrypt failed\n");
return 5;
}
printf("%s len=%d first16=", argv[1], outlen);
for (i = 0; i < 16 && i < outlen; i++) printf("%02x", out[i]);
printf("\n");
free(out);
free(in);
return 0;
}解包源码
#!/usr/bin/env node
// Unpacker / verifier for School of Lust x0.9.6a (NW.js + RPG Maker MV repack)
// Usage:
// node unpack_sol.js --www <www dir> --out <output dir> [--mode encrypted|full]
// node unpack_sol.js --www <www dir> --check (read-only, writes nothing)
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const crypto = require('crypto');
const IMG_KEY = Buffer.from('HARDmae0qBokfe91IvTiTSQBWDAnoPOEPmr5938C0RE'.replace(/-/g, '+').replace(/_/g, '/') + '=', 'base64');
const DATA_KEY = Buffer.from('XXXXmae0qBokfe91IvZZZZQBWDAnoDV8QQQQ938XXXX'.replace(/-/g, '+').replace(/_/g, '/') + '=', 'base64');
const RPGMV_MASK = Buffer.from('0ba9e88858eb6f1d2b521ca7059f96bf', 'hex');
const PNG_SIG = Buffer.from('89504e470d0a1a0a', 'hex');
const PNG_HEAD = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
function usage() {
console.log('Usage: node unpack_sol.js --www <www dir> --out <output dir> [options]');
console.log(' node unpack_sol.js --www <www dir> --check');
console.log(' --mode encrypted|full encrypted = decrypted assets only (default); full = also copy plaintext assets');
console.log(' --filter <substr> only process relative paths containing this text');
console.log(' --limit <N> stop after N files');
console.log(' --overwrite overwrite existing output files');
console.log(' --check verify everything in memory; writes nothing');
process.exit(1);
}
function parseArgs(argv) {
const o = { mode: 'encrypted', filter: null, limit: Infinity, overwrite: false, check: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--www') o.www = argv[++i];
else if (a === '--out') o.out = argv[++i];
else if (a === '--mode') o.mode = argv[++i];
else if (a === '--filter') o.filter = argv[++i];
else if (a === '--limit') o.limit = parseInt(argv[++i], 10);
else if (a === '--overwrite') o.overwrite = true;
else if (a === '--check') o.check = true;
else usage();
}
if (!o.www || !['encrypted', 'full'].includes(o.mode)) usage();
if (!o.check && !o.out) usage();
return o;
}
function loadLZString(www) {
const p = path.join(www, 'js', 'libs', 'lz-string.js');
if (!fs.existsSync(p)) throw new Error('missing ' + p);
const ctx = {};
ctx.window = ctx;
ctx.global = ctx;
vm.createContext(ctx);
vm.runInContext(fs.readFileSync(p, 'utf8'), ctx);
if (!ctx.LZString) throw new Error('LZString not exported by ' + p);
return ctx.LZString;
}
function ivFor(stem, lower) {
const b = Buffer.alloc(16);
b.write((lower ? stem.toLowerCase() : stem).slice(0, 16), 'latin1');
return b;
}
function aesDecrypt(buf, key, iv) {
const d = crypto.createDecipheriv('aes-256-cbc', key, iv);
return Buffer.concat([d.update(buf), d.final()]);
}
function sniff(buf) {
if (buf.length >= 8 && buf.slice(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) return { kind: 'png', ext: '.png' };
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { kind: 'jpg', ext: '.jpg' };
if (buf.length >= 12 && buf.slice(0, 4).toString() === 'RIFF' && buf.slice(8, 12).toString() === 'WEBP') return { kind: 'webp', ext: '.webp' };
if (buf.length >= 3 && buf.slice(0, 3).toString() === 'GIF') return { kind: 'gif', ext: '.gif' };
if (buf.length >= 4 && buf.slice(0, 4).toString() === 'OggS') return { kind: 'ogg', ext: '.ogg' };
if (buf.length >= 8 && buf.slice(4, 8).toString() === 'ftyp') return { kind: 'm4a', ext: '.m4a' };
if (buf.length >= 3 && buf.slice(0, 3).toString() === 'ID3') return { kind: 'mp3', ext: '.mp3' };
if (buf.length >= 1 && (buf[0] === 0x5b || buf[0] === 0x7b)) return { kind: 'json', ext: '.json' };
return { kind: 'bin', ext: '.bin' };
}
function validatePng(buf) {
if (buf.length < 33) return { ok: false, error: 'too short' };
if (!buf.slice(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) return { ok: false, error: 'bad signature' };
const len = buf.readUInt32BE(8);
const type = buf.slice(12, 16).toString('latin1');
if (len !== 13 || type !== 'IHDR') return { ok: false, error: 'no IHDR' };
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
const crcStored = buf.readUInt32BE(29);
const crcCalc = crc32(Buffer.concat([buf.slice(12, 16), buf.slice(16, 29)]));
if (crcStored >>> 0 !== crcCalc >>> 0) return { ok: false, error: 'IHDR CRC' };
return { ok: true, width, height };
}
const CRC_TABLE = (() => {
const t = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function validateOgg(buf) {
if (buf.length < 27) return { ok: false, error: 'too short' };
if (buf.slice(0, 4).toString() !== 'OggS') return { ok: false, error: 'bad signature' };
if (buf[4] !== 0) return { ok: false, error: 'bad version' };
return { ok: true };
}
function walk(dir, root, acc, filter) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
const rel = path.relative(root, p).replace(/\\/g, '/');
if (e.isDirectory()) walk(p, root, acc, filter);
else if (!filter || rel.includes(filter)) acc.files.push(p);
}
return acc;
}
function main() {
const opt = parseArgs(process.argv);
const www = path.resolve(opt.www);
const LZ = loadLZString(www);
const acc = { files: [] };
walk(www, www, acc, opt.filter);
const files = acc.files;
let out = null;
if (!opt.check) {
out = path.resolve(opt.out);
fs.mkdirSync(out, { recursive: true });
}
const report = {
mode: opt.check ? 'check' : opt.mode,
www,
counts: {
soldataJson: 0, soldataImage: 0, rpgmvp: 0, rpgmvo: 0,
copied: 0, skippedExisting: 0, failed: 0, ivRecovered: 0,
duplicateIdentical: 0, duplicateDifferent: 0
},
shapes: { jsonArray: 0, jsonObject: 0, pngDims: {}, ogg: 0 },
failures: [],
recoveredIv: [],
samples: []
};
const written = new Map();
let processed = 0;
function sha(buf) {
return crypto.createHash('sha256').update(buf).digest('hex');
}
function altName(relPath, tag) {
const ext = path.posix.extname(relPath);
return relPath.slice(0, -ext.length) + '.' + tag + ext;
}
function writeOut(relPath, buf) {
if (opt.check) {
if (report.samples.length < 12) report.samples.push(relPath + ' (' + buf.length + ' B)');
return true;
}
const dest = path.join(out, relPath);
fs.mkdirSync(path.dirname(dest), { recursive: true });
if (fs.existsSync(dest)) {
const old = sha(fs.readFileSync(dest));
if (old === sha(buf)) {
report.counts.skippedExisting++;
return false;
}
if (!opt.overwrite) {
report.counts.duplicateDifferent++;
report.failures.push({ file: relPath, error: 'existing file differs' });
return false;
}
}
fs.writeFileSync(dest, buf);
return true;
}
function emit(relPath, buf, label) {
const h = sha(buf);
const prev = written.get(relPath);
if (!prev) {
written.set(relPath, { hash: h, label });
return writeOut(relPath, buf);
}
if (prev.hash === h) {
report.counts.duplicateIdentical++;
return true;
}
report.counts.duplicateDifferent++;
if (prev.label === 'rpgmvp' && label === 'soldata') {
const alt = altName(relPath, 'rpgmvp');
if (!opt.check) {
const dest = path.join(out, relPath);
const altDest = path.join(out, alt);
if (fs.existsSync(dest)) {
fs.mkdirSync(path.dirname(altDest), { recursive: true });
if (!fs.existsSync(altDest)) fs.renameSync(dest, altDest);
}
}
written.set(alt, { hash: prev.hash, label: prev.label });
written.set(relPath, { hash: h, label });
report.failures.push({ file: relPath, error: 'duplicate content differs', resolution: 'soldata wins; rpgmvp -> ' + alt });
if (opt.check) return true;
fs.writeFileSync(path.join(out, relPath), buf);
return true;
}
let alt = altName(relPath, label);
let n = 2;
while (written.has(alt)) {
alt = altName(relPath, label + '_' + n);
n++;
}
written.set(alt, { hash: h, label });
report.failures.push({ file: relPath, error: 'duplicate content differs', resolution: 'kept ' + prev.label + '; ' + label + ' -> ' + alt });
return writeOut(alt, buf);
}
for (const f of files) {
if (processed >= opt.limit) break;
const rel = path.relative(www, f).replace(/\\/g, '/');
const ext = path.extname(f).toLowerCase();
try {
if (ext === '.soldata') {
const isData = rel.startsWith('data/');
const stem = isData ? path.basename(f, ext) : LZ.decompressFromBase64(path.basename(f, ext));
if (typeof stem !== 'string' || !stem) throw new Error('name decode failed');
const blob = fs.readFileSync(f);
const iv = ivFor(stem, isData);
let pt = aesDecrypt(blob, isData ? DATA_KEY : IMG_KEY, iv);
let sig = sniff(pt);
let outStem = stem;
if (isData) {
if (sig.kind !== 'json') throw new Error('data payload is ' + sig.kind);
const parsed = JSON.parse(pt.toString('utf8'));
if (Array.isArray(parsed)) report.shapes.jsonArray++;
else report.shapes.jsonObject++;
report.counts.soldataJson++;
emit(path.posix.join(path.posix.dirname(rel), stem + '.json'), pt, 'data');
} else {
if (sig.kind === 'png') {
let v = validatePng(pt);
if (!v.ok && pt.slice(0, 8).equals(PNG_SIG)) {
const ivCorr = Buffer.alloc(16);
for (let i = 0; i < 16; i++) ivCorr[i] = iv[i] ^ pt[i] ^ PNG_HEAD[i];
const pt2 = aesDecrypt(blob, IMG_KEY, ivCorr);
const v2 = validatePng(pt2);
if (v2.ok) {
pt = pt2;
v = v2;
const recPrefix = ivCorr.toString('latin1').replace(/\0.*$/, '');
outStem = stem.length <= 16 ? recPrefix : recPrefix + stem.slice(16);
report.counts.ivRecovered++;
report.recoveredIv.push({
file: rel, diskStem: stem, trueStem: outStem,
ivPrefix: ivCorr.toString('latin1').replace(/[^\x20-\x7e]/g, '.')
});
}
}
if (!v.ok) throw new Error('png ' + v.error);
const key = v.width + 'x' + v.height;
report.shapes.pngDims[key] = (report.shapes.pngDims[key] || 0) + 1;
} else if (!['jpg', 'webp', 'gif'].includes(sig.kind)) {
throw new Error('image payload is ' + sig.kind);
}
report.counts.soldataImage++;
emit(path.posix.join(path.posix.dirname(rel), outStem + sig.ext), pt, 'soldata');
}
processed++;
} else if (ext === '.rpgmvp' || ext === '.rpgmvo' || ext === '.rpgmvm') {
const blob = fs.readFileSync(f);
if (blob.length < 32) throw new Error('truncated RPGMV container');
const first = Buffer.alloc(16);
for (let i = 0; i < 16; i++) first[i] = blob[16 + i] ^ RPGMV_MASK[i];
const pt = Buffer.concat([first, blob.slice(32)]);
const sig = sniff(pt);
if (ext === '.rpgmvp') {
const v = validatePng(pt);
if (!v.ok) throw new Error('png ' + v.error);
const key = v.width + 'x' + v.height;
report.shapes.pngDims[key] = (report.shapes.pngDims[key] || 0) + 1;
report.counts.rpgmvp++;
} else if (ext === '.rpgmvo') {
const v = validateOgg(pt);
if (!v.ok) throw new Error('ogg ' + v.error);
report.shapes.ogg++;
report.counts.rpgmvo++;
}
emit(rel.slice(0, -ext.length) + sig.ext, pt, ext === '.rpgmvp' ? 'rpgmvp' : 'rpgmvo');
processed++;
} else if (opt.mode === 'full') {
emit(rel, fs.readFileSync(f), 'copy');
report.counts.copied++;
processed++;
}
if (processed % 500 === 0) console.error('processed', processed);
} catch (e) {
report.counts.failed++;
report.failures.push({ file: rel, error: String(e && e.message || e) });
}
}
const dims = Object.entries(report.shapes.pngDims).sort((a, b) => b[1] - a[1]);
report.shapes.pngDims = Object.fromEntries(dims.slice(0, 12));
if (!opt.check) fs.writeFileSync(path.join(out, '_unpack_report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
}
main();
