School_of_Lust_x0.9.6a解包技术报告

逆向工程 实用技术 算法
34
kinotern

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 列出主要文件类型。

扩展名数量字节数性质
.soldata6,153974,200,720自定义 AES 容器
.mtn1,7807,042,417Live2D 动作,明文
.json8042,393,407配置、插件参数、模型引用
.rpgmvo427113,486,112RPG Maker MV 音频容器
.rpgmvp25629,794,539RPG Maker MV 图片容器
.js1374,756,604引擎与插件
.moc9045,140,399Live2D 旧模型,明文
.moc3622,520,576Live2D Cubism 3 模型,明文
.png3916,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 内容如下:

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/dataXXXXmae0qBokfe91IvZZZZQBWDAnoDV8QQQQ938XXXX5d75d799a7b4a81a247def7522f659659401583027a0357c410410f77f175d75
www/img、www/l2dHARDmae0qBokfe91IvTiTSQBWDAnoPOEPmr5938C0RE1c044399a7b4a81a247def7522f4e24d2401583027a0f3843e6af9f77f02d111

IV 规则:

  • 数据文件取文件名词干的前 16 个 ASCII 字符,转为小写,不足 16 字节补 0x00。
  • 图片文件取原始文件名词干的前 16 个 ASCII 字符,保留大小写,不足补 0x00。

数据文件的磁盘名不变,Actors.soldata 对应 Actors.json。图片文件的磁盘名是 LZString 压缩后的 Base64:

js
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 完成。

c
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 是文件总长度。

asm
    .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,0xbf

movdqu 允许非对齐访问,源字段位于偏移 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 条指令如下:

asm
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 混淆脚本的还原

壳的结构类似下面的形式,变量名每个文件不同:

js
(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 才能截获:

js
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 的原文如下:

js
$["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 数据403JSON 全部解析成功
.soldata 图片5,750PNG 全部通过签名与 CRC
.rpgmvp256PNG 全部通过
.rpgmvo427Ogg 全部通过
失败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_m0sc_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
解出的 JSON403
解出的 PNG5,959
解出的 OGG427
复制的明文文件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 复现命令

只读校验,不写文件:

bash
node md/src/unpack_sol.js --www "C:/Users/Kino/Program2/hetunren/sample/School_of_Lust_Win_x0.9.6a/www" --check

解出加密资源:

bash
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"

补齐明文资源:

bash
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 与汇编测试程序:

bash
gcc -O2 -o soldata_test.exe soldata_decrypt.c rpgmv_unmask.S -lcrypto

附录 B C 源码

以下代码与汇编函数一起编译,已在样本上验证。

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();

本文版权遵循 CC BY-NC 协议 和 本站版权政策

(。>︿<。) 已经一滴回复都不剩了哦~