NekoparaVol.1的封包静态提取

逆向工程 实用技术 算法
129 编辑于

NEKOPARA Vol.1 (Steam) 加密 XP3 离线解包:分析与解决方案

一、问题

游戏目录下 9 个 .xp3(data / patch / patch2 / voice / adultsonly / emote / emotewin / emotewin_low / evimage)全部加密,现有工具无法离线提取:

  • KrkrExtract:可解,但是是运行时

  • GARbro:能识别魔数、能解析索引,但解密方案表(KnownSchemes)为空,选不到 任何方案;且内置 SmileCrypt 与本作有三处差异(见 2.3),手动指定也解不对。

二、分析

2.1 封包结构

  • 头部为标准 Krkrz 11 字节魔数 XP3\r\n \n\x1a\x8b\x67\x01 + 8 字节索引偏移;

  • 索引为链式块:1 字节 flag,flag&7==1 为 zlib 压缩块,flag&7==0 为原始块, flag&0x80 置位则块尾跟下一个索引链接;

  • 索引条目分两类:File(内含 adlr 内容哈希、segm 分段、info 加密标志) 与 eliF(hash → 游戏内路径的映射);

  • 分段读取:bZlib&1 置位先 zlib 解压,再对完整流解密;

  • 关键发现:adlr 就是明文内容的 Adler-32,因此解密是否正确可以逐文件硬校验。

2.2 加密算法

内容为整文件单字节 XOR 流,密钥由 adlr 派生:

python
fold4 = (adlr ^ (adlr >> 8) ^ (adlr >> 16) ^ (adlr >> 24)) & 0xFF
key   = fold4 ^ 0x1A
if key == 0:
    key = 0x23

要点:

  • 首字节无额外处理;

  • key==0 时回退 0x23(与 GARbro SmileCrypt 的 zero_xor 同款规则);

  • 个别条目带加密标志但内容为明文(如部分 startup.tjs),脚本先比原始数据 Adler-32 是否等于 adlr,不等才解密,无需硬编码文件名;

  • 解密后 zlib.adler32(明文) & 0xFFFFFFFF == adlr 对全部文件成立。

2.3 证据与 GARbro 差异

索引读取与标准 Krkrz 源码(krkrz/base/XP3Archive.cpp)一致:

cpp
// flag&7==1: I64 压缩大小 + I64 原始大小 + zlib 数据
// flag&7==0: I64 大小 + 原始数据
// flag&0x80: 块尾跟下一个索引链接
if (!(index_flag & TVP_XP3_INDEX_CONTINUE)) break;

GARbro 的 SmileCrypt(ArcFormats/KiriKiri/CryptAlgorithms.cs)核心:

csharp
uint hash = entry.Hash ^ m_key_xor;
byte key = (byte)(hash ^ (hash >> 8) ^ (hash >> 16) ^ (hash >> 24));
if (0 == key) key = m_zero_xor;
if (0 == offset && count > 0) {
    if (0 == (hash & 0xFF)) hash = m_first_xor;
    values[pos] ^= (byte)hash;              // 首字节额外异或
}
for (int i = 0; i < count; ++i) values[pos+i] ^= key;

与本作的差异就三条:流密钥多异或 0x1A没有首字节额外异或、zero 回退为 0x23。这就是"手动指定 SmileCrypt 也解不对"的原因。

另外已在脱壳 exe 的 .text 中确认容器层代码(0x00435d10 魔数校验:按 4+4+2+1 读取 58 50 33 0D | 0A 20 0A 1A | 8B 67 | 01,与 2.1 一致);过滤器本体位于 运行时解密的 .bind 打包区(熵≈8),无法静态提取,但算法正确性由全量校验独立 证明,不依赖该段代码。

三、解决方案

脚本 nekopara_xp3_unpack.py(见附录)纯 Python 标准库,零依赖。用法:

text
python nekopara_xp3_unpack.py "<NEKOPARA Vol.1 游戏安装目录>" <输出目录> --verify
  • --verify:逐文件 Adler-32 复核(>64MB 条目跳过校验但仍写出);

  • --no-write:只校验不落盘;

  • 输出按游戏内路径还原,如 asset/bgm/bgm_01.oggasset/image/*.png

  • 超长文件名自动截断,Windows 下兼容长路径;

  • 实测:data.xp3 全量解包(约 1.2GB)数秒,全部封包校验约 1 分钟内。

验证结果(2026-08 实测):9 个封包 8051 个文件全部通过 adler32(明文)==adlr, 零失败;OGG/PNG/WMV 均为标准格式可直接打开。

注意:

  • 每个封包带 .sig(SHA256/PSS/RSA)完整性签名,提取不受影响

  • 封包用kirikiritools,封包请用此工具,常规封包会破坏sig

  • 本方案针对 Vol.1;0 / 2 / Extra 同为 SmileCrypt 家族但参数不同(存在首字节 特判),需按同法重新拟合常量。

附录:完整脚本

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NEKOPARA Vol.1 (Steam) 加密 XP3 离线解包器

逆向结论(全部通过 adler32 全量校验):
  * 9 个 .xp3 全部使用 wamsoft 魔改 Krkrz 的 XP3 变体:
      11 字节标准魔数 "XP3\\r\\n \\n\\x1a\\x8b\\x67\\x01" + 8 字节小端 IndexOffset,
      魔改体现在链式索引编排与内容加密过滤器
  * 索引: 1 字节 flag + 大小字段 + 索引数据, 按 flag 位解释:
      flag&7 == 1 (ZLIB): I64 压缩大小 + I64 原始大小 + zlib 数据
      flag&7 == 0 (RAW) : I64 大小 + 原始数据
      flag&0x80 置位     : 本块后面跟下一个索引链接, 链式继续读
    索引解压后按 "File"/"eliF" 大块解析:
      - File:  内嵌 adlr(hash), segm(每段 28 字节: bZlib:4+Offset:8+OrigSize:8+ArcSize:8), info
      - eliF:  Magic+Size+Hash(4)+NameLen(2)+UTF-16 虚拟路径, 与 File.adlr 对应
  * 内容加密: 单字节 XOR 流, 密钥由内容 adler32(即索引里的 adlr) 派生:
      key = (adlr ^ (adlr>>8) ^ (adlr>>16) ^ (adlr>>24)) & 0xFF  ^  0x1A
      if key == 0: key = 0x23          # 空密钥回退, 与 GARbro SmileCrypt 同款规则
    首字节无额外处理; startup.tjs 按明文处理(引擎惯例)。
  * 解密后 adler32(明文) == 索引中的 adlr, 本脚本 --verify 模式逐文件复核。

用法:
  python nekopara_xp3_unpack.py <game目录或.xp3路径> <输出目录> [--verify] [--no-write]
"""

import hashlib, os, re, struct, sys, zlib

MAGIC = b"XP3\r\n \n\x1a\x8b\x67\x01"
MAX_VERIFY = 64 * 1024 * 1024   # 校验模式跳过超大文件的字节数上限
XOR_TABLES = [bytes(b ^ k for b in range(256)) for k in range(256)]


def r64(b, o):
    return struct.unpack_from("<Q", b, o)[0]


def read_index_blob(path, base):
    """返回解压后的索引字节串"""
    with open(path, "rb") as f:
        f.seek(base + len(MAGIC))
        link = struct.unpack("<Q", f.read(8))[0]
        f.seek(base + link)
        chunks = []
        while True:
            flag = f.read(1)
            if len(flag) < 1:
                break
            flag = flag[0]
            method = flag & 7
            if method == 1:                       # ZLIB: 压缩大小 + 原始大小 + 数据
                comp_size, orig_size = struct.unpack("<QQ", f.read(16))
                chunks.append(zlib.decompress(f.read(comp_size)))
            elif method == 0:                     # RAW: 大小 + 数据
                size = struct.unpack("<Q", f.read(8))[0]
                chunks.append(f.read(size))
            else:
                raise ValueError(f"未知索引编码方法 flag={flag:#x}")
            if not (flag & 0x80):                 # 无 CONTINUE 位则结束
                break
            link = struct.unpack("<Q", f.read(8))[0]   # 链式: 块尾的下一个索引链接
            f.seek(base + link)
        return b"".join(chunks)


def parse_index(index):
    entries, name_map = [], {}
    pos = 0
    while pos + 12 <= len(index):
        magic = index[pos:pos+4]
        size = r64(index, pos+4)
        payload = index[pos+12:pos+12+size]
        pos += 12 + size
        if magic == b"File":
            q, adlr, segs, enc = 0, None, [], None
            while q + 12 <= len(payload):
                m = payload[q:q+4]
                s = r64(payload, q+4)
                p = payload[q+12:q+12+s]
                q += 12 + s
                if m == b"adlr":
                    adlr = struct.unpack_from("<I", p)[0]
                elif m == b"segm":
                    for off in range(0, s, 28):
                        bz, ofs, osize, asize = struct.unpack_from("<IQQQ", p, off)
                        segs.append((bz, ofs, osize, asize))
                elif m == b"info" and s >= 22:
                    enc = struct.unpack_from("<I", p)[0]
                    nlen = struct.unpack_from("<H", p, 20)[0]
                    nm = p[22:22+nlen*2].decode("utf-16-le", "replace")
            if adlr is not None and segs:
                entries.append([adlr, segs, nm or "", enc])
        elif magic == b"eliF" and size >= 6:
            h = struct.unpack_from("<I", payload, 0)[0]
            nlen = struct.unpack_from("<H", payload, 4)[0]
            name_map[h] = payload[6:6+nlen*2].decode("utf-16-le", "replace")
    return entries, name_map


def stream_key(adlr):
    k = (adlr ^ (adlr >> 8) ^ (adlr >> 16) ^ (adlr >> 24)) & 0xFF
    k ^= 0x1A
    return k if k else 0x23


def safe_name(name, root="asset"):
    name = name.replace("\\", "/").replace("%", "_")
    parts = []
    for x in name.split("/"):
        x = re.sub(r'[<>:"|?*]', "_", x)
        if len(x) > 180:                       # 规避 Windows 255 字符组件上限
            stem, ext = os.path.splitext(x)
            x = stem[:150] + "_" + hashlib.md5(x.encode("utf-8")).hexdigest()[:8] + ext[:20]
        parts.append(x)
    return os.path.join(root, *parts) if parts else os.path.join(root, "_")


def extract(path, outdir, verify=False, write=True):
    with open(path, "rb") as f:
        head = f.read(len(MAGIC))
        if head != MAGIC:
            raise ValueError(f"{path}: 不是 NEKOPARA 自定义 XP3 (魔数不符)")
        entries, name_map = parse_index(read_index_blob(path, 0))
        print(f"[{os.path.basename(path)}] 条目 {len(entries)} 个")
        ok = bad = skipped = 0
        for i, (adlr, segs, nm, enc) in enumerate(entries):
            real = name_map.get(adlr, nm)
            if not real:
                real = f"unnamed_{i:04d}"
            total = sum(a for (_, _, _, a) in segs)
            verify_entry = verify and total <= MAX_VERIFY
            if verify and total > MAX_VERIFY:
                skipped += 1
            chunks = []
            for (bz, ofs, osize, asize) in segs:
                f.seek(ofs)
                raw = f.read(asize)
                if bz & 1:
                    raw = zlib.decompress(raw)
                chunks.append(raw)
            data = b"".join(chunks)
            if enc:
                # 先试原始数据(引擎有明文特例, 如部分 startup.tjs),
                # adler32 不等于 adlr 才按公式解密
                if zlib.adler32(data) & 0xFFFFFFFF != adlr:
                    data = data.translate(XOR_TABLES[stream_key(adlr)])
            if verify_entry and enc:
                if zlib.adler32(data) & 0xFFFFFFFF == adlr:
                    ok += 1
                else:
                    bad += 1
                    print(f"  FAIL adler32: {real} (hash={adlr:08X})")
            if write:
                out = os.path.join(outdir, safe_name(real))
                if os.name == "nt":
                    out = "\\\\?\\" + os.path.abspath(out)
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "wb") as o:
                    o.write(data)
        mode = "校验通过 %d, 失败 %d, 跳过 %d" % (ok, bad, skipped) if verify else "提取完成"
        print(f"[{os.path.basename(path)}] {mode}")


def main():
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    verify = "--verify" in sys.argv
    write = "--no-write" not in sys.argv
    if len(args) < 2:
        print(__doc__)
        sys.exit(1)
    src, out = args[0], args[1]
    os.makedirs(out, exist_ok=True)
    if os.path.isdir(src):
        for name in sorted(os.listdir(src)):
            if name.lower().endswith(".xp3"):
                try:
                    extract(os.path.join(src, name), out, verify, write)
                except Exception as e:
                    print(f"[{name}] 错误: {e}")
    else:
        extract(src, out, verify, write)


if __name__ == "__main__":
    main()

本文仅作格式研究与学习交流用途

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

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