从 emuMMC 里提取 Switch 存档: 非标准 AES-XTS、FAT32 与 DISF 的实现

那台破解过的 Switch 已经不在我手上了, 只剩一张 512GB 的 SD 卡。我想做的事很具体: 把当年玩出来的游戏存档救出来, 导进 Android 上的 Eden(一个 yuzu 分支), 接着玩下去。

我第一反应是插上读卡器去翻 Nintendo/save/。里面确实有存档, 但只有两个, 文件名是 8000000000000000 和 8000000000000124, 都是 NAX0 加密的系统存档, 和游戏进度无关。整张卡能看到的目录翻了一遍, 找不到任何一处像游戏存档。这个结果很反直觉: 存档明明是这台机器产生的, 怎么会不在卡上。

任天堂的说法是, 游戏存档只存在主机的内部存储 eMMC 里, 不能复制到 microSD; SD 卡上只有游戏本体、更新、DLC、截图和系统存档。那破解机的存档在哪——emuMMC。

存档不在 SD 卡表面, 而在 emuMMC 镜像里

破解常用"虚拟系统"emuMMC: 把主机整块 eMMC 完整复制到 SD 卡, 开机时引导到这份副本。于是卡里 emuMMC/SD00/eMMC/ 下的 00 到 07 不是八个文件, 是同一块内部存储被 4GB 切开的分片(受 FAT 单文件上限所迫), 旁边还有 BOOT0、BOOT1 两个引导分区。这张 SD 卡里因此藏着一整台 Switch 的内部存储, 大约 30GB, 游戏存档就在镜像的 USER 分区。

顺着这条链, 路径就固定了:

把这条链拆开看, 每一环都对应一个反直觉的事实。

三样缺一不可的东西

镜像就是 emuMMC/SD00/eMMC/00..07。密钥在 atmosphere/automatic_backups/<序列号>_BISKEYS.bin, 同一目录下还有 *_PRODINFO.bin 主机证书。NAND 的分区用 AES-XTS 加密, 没有 BIS 密钥永远解不开; 而 NAND 用的又是这台主机专属的密钥, 换一台机器、换一张卡都解不开。

这一点决定了整件事的性质: 主机没了, 这张卡和上面那份 BISKEYS 就是唯一能还原数据的副本。所以在想要的东西全部导出来之前, 这张卡不能格式化, 也不能拿去做别的写入。我后来每做一步都把中间产物另存一份, 就是这个原因。

先拼 NAND 读 GPT: 分区表是明文

00..07 按顺序拼接就是完整 NAND。我写了个小 reader, 把八个文件当成一块连续的盘, 支持任意偏移读取, 后面所有操作都通过它:

def rd(self, off, n):
    out = bytearray()
    for f, st, sz in zip(self.files, self.starts, self.sizes):
        if off < st + sz and len(out) < n:
            with open(os.path.join(self.dir, f), "rb") as fh:
                local = max(0, off - st)
                fh.seek(local)
                want = min(n - len(out), sz - local)
                out += fh.read(want)
            off += min(n - len(out), sz - local)
    return bytes(out)

先读 GPT, 是因为解密是按分区做的, 而分区位置写在 GPT 里。这里有个好消息: GPT 分区表本身是明文的, 因为它必须在任何解密之前就被引导程序读到。所以这一步没有阻力, 直接拿到分区列表: PRODINFO、PRODINFOF、BCPKG2-1..6、SAFE、SYSTEM、USER。

其中 SYSTEM 约 2.5GB, 装的是系统存档(system-titles), 不是我要的; USER 约 26GB, 游戏存档就在它的 /save/ 下。这张卡的分区偏移是 SYSTEM 0x7800000、USER 0xA7800000, 拿到 offset = first_lba * 512 之后, 就可以只对 USER 做解密。

最难的一关: 任天堂的 AES-XTS 是非标准的

AES-XTS 可以理解成"给每个数据单元配一个不同 tweak 的 AES"。给定密文 C 和单元 tweak T, 解密是:

明文 = AES_dec(key1, C XOR T) XOR T
T 在每个 16 字节块之间按 GF(2^128) 乘 x 递推
单元的初始 T0 = AES_enc(key2, 单元编号)

我一开始按最常规的认知来: 数据单元 512 字节, tweak 是小端的单元编号。写完之后发现一个说不通的现象——分区的第 0 个扇区能解对, 后面全是乱码。

第 0 个单元编号为 0, 而小端和大端、512 和 16K 在"编号为 0"时结果完全一样, 所以它碰巧是对的; 从第 1 个单元起, 两个假设就同时错了。我去翻 switchbrew 的 Flash Filesystem 页面, 才确认任天堂的 XTS 是非标准的: 数据单元是 0x4000, 也就是 16KB, 不是 512; 而 tweak 用的是大端的扇区编号, 不是小端。

改成 16KB 单元加大端编号之后, USER 立刻解出了正确的 FAT 表 f8 ff ff 0f ... 和根目录 PRF2SAFE.RCV、save、saveMeta、Contents。

用已知明文对照, 而不是凭感觉

解密这种问题不能凭感觉。我准备了一组已知明文当参照: FAT32 引导扇区结尾必须是 55 AA、偏移 0x52 是 FAT32, FSInfo 扇区开头是 RRaA, FAT 表开头是 F8 FF FF 0F, 根目录里应该出现可读的目录名。

一开始这组参照一个都对不上, 说明"512 字节单元"和"小端编号"两个假设同时错了。把假设一个个换掉, 直到已知明文出现, 这是逆向里最通用的做法——不是猜, 是用能验证的已知值去排除。

向量化解密: pycryptodome 交给 C, numpy 处理 tweak

朴素 Python 逐 16 字节做 XOR, 26GB 要跑几十亿次循环, 慢到没法用。所以我拆成两部分: AES 本体交给 pycryptodome 的 ECB 批量处理, 走 C 的速度; tweak 的递推用 numpy 整片数组一起算。

GF(2^128) 里乘 x 可以向量化: 每个 16 字节 tweak 左移一位, 把每个字节的最高位补到下一个字节的最低位, 最高位溢出时异或约简多项式 0x87。

def _mul_alpha(T):                      # T: (N,16) uint8
    top = (T[:, 15] >> 7).astype(np.uint8)       # 最高位 = 进位
    out = ((T << 1) & 0xFF).astype(np.uint8)
    out[:, 1:] |= (T[:, :-1] >> 7)                # 低字节进位补到高字节
    out[:, 0] ^= (top * 0x87).astype(np.uint8)    # 溢出 → 异或 0x87
    return out

单元初始 tweak 的关键是大端:

iv = np.zeros((n, 16), np.uint8)
for k in range(8):                      # 大端塞进最后 8 字节
    iv[:, 15 - k] = (units >> np.uint64(8 * k)) & np.uint64(0xFF)
base = AES_ecb(key2).encrypt(iv)        # T0 = AES_enc(key2, 大端(单元号))

然后一次性算出每个单元 1024 个块的 tweak 序列, 批量套公式。顺带说一句, cryptography 库的 modes.XTS 在这里不好用: 它默认把整段输入当成一个数据单元, 超过 16MB 直接报错, 流式 update() 还不保持状态, 自己实现反而更省事也更快。

USER 是 FAT32, 簇大小正好等于一个 XTS 单元

解密后的 USER 是标准 FAT32, 任天堂内部就用 FAT。读它的引导扇区拿到参数: 每扇区 512 字节、每簇 32 扇区、保留 32 扇区、2 个 FAT、每个 FAT 13312 扇区、根目录簇号为 2。于是每簇 16384 字节, 数据区起点在 (rsvd + nfats * fatsz) * bps。

这里有个关键的对齐: 簇大小 16384 正好等于 XTS 单元的 0x4000, 任天堂就是这么排的。它带来一个很实用的性质——可以按簇解密, 簇 N 的字节偏移正好落在一个单元边界上, 读一个簇就是解一个单元:

def cluster_unit(n):
    return (DATA_START + (n - 2) * CLUSTER) // UNIT   # 正好整除
def read_cluster(n):
    u = cluster_unit(n)
    return xts.decrypt_units(rd(POFF + u * UNIT, UNIT), u)

遍历 FAT32 的逻辑是通用的: 读 FAT 表(按单元解密后拼起来), 每个条目 4 字节, 低 28 位是下一簇; 目录是簇链拼成的字节串, 每 32 字节一个条目; 名字要处理 VFAT 长文件名(属性 0x0F 的条目存 13 个 UTF-16 字符, 多个条目拼起来)。有个容易忽略的性能点: 必须把连续的簇合并成一次大读, 否则一个 1GB 的存档要发几千次小 I/O, 慢到无法接受。

最后在 USER:/save/ 下看到 95 个存档容器, 文件名是 8 字节 saveID 的十六进制。

存档容器: 一个套在文件里的小文件系统

每个 /save/<id> 都不是裸文件, 而是一整个微型文件系统。头部在偏移 0x100 处, 0x100 之前是 CMAC。头部里排着一串模块: DISF 是总布局, DPFS 是双缓冲, IVFC 是完整性校验树, JNGL 是日志, SAVE 是存档的文件系统, RMAP 是重映射, Extra data 里带 TitleID 和时间戳。

读取这些容器其实是层层映射, 而不是直接按文件偏移读:

任天堂把它做得这么复杂, 是因为存档不能丢, 用了三层保障。Duplex 把数据写两份 A/B, 用位图标记哪份有效, 提交后翻转位图, 写到一半断电也不会损坏; Journal 类似数据库的 WAL, 先写日志再改主数据, 保证原子性; IVFC 是分层哈希, 用来检测和定位损坏。

我实现的时候做了一步简化。IVFC 那一层只负责校验, 还有一个"哈希全 0 就当空块"的稀疏优化; 而已经分配的文件块一定是真实数据, 哈希必然非 0。既然校验不影响读取结果, 就可以整棵校验树跳过, 直接从数据层读——这省掉了移植一整套哈希树的工作量。工具里没有实现 IVFC 不是漏了, 是它不影响正确性。

最正统的做法是用 hactool -t save -k prod.keys <容器> --outdir out; 这份 Python 实现, 是把 hactool save.c 里的 remap、duplex、journal、SAVE FS 四条核心逻辑翻译过来的。两种方式结果一致。

导入 Android 的 Eden: 三个坑

Eden 在 Android 上的存档目录是 /sdcard/Android/data/dev.eden.eden_emulator/files/nand/user/save/0000000000000000/<容器ID>/<TITLEID>/, 把解出来的文件放进 <TITLEID>/ 就行。实际操作里有三个坑, 每一个都是 Android 的系统特性造成的。

  1. adb push 进 Android/data 的子目录会失败, 报 remote secure_mkdirs failed: Operation not permitted。这是受保护目录, adb 的 secure_mkdirs 在它下面被拒。改法是本地打 tar, push 到普通目录, 再在设备上用 tar xf 就地展开。
  2. adb push/tar 出来的文件属主是 shell, 而 Eden 用读写方式(O_RDWR)打开存档, 0644 对"其他人"只有读权限, 写会被拒, 表现为游戏读档失败甚至卡死。解法是把权限放开: find <存档目录> -mindepth 1 -exec chmod 777 {} +。
  3. macOS 的 tar 会混入 ._* 的 AppleDouble 垃圾文件。打包时加 COPYFILE_DISABLE=1, 或事后 find … -name '._*' -delete。

另外, Eden 里可能已经有后来玩出来的存档, 覆盖前先 adb pull 备份一份。导入之后我还会逐文件比 md5, 本地和设备一致才敢说成功——磁盘迁移最大的风险是"看起来对、其实差几个字节"。

把四层合成一个工具

四层流水线我合并成了一个单文件脚本 switch_save_tool.py, 依赖只有 numpy 和 pycryptodome:

# 看分区
python switch_save_tool.py info    --emmc "<SD>/emuMMC/SD00/eMMC"
# 列出所有存档(含 TitleID、时间、大小)
python switch_save_tool.py list    --emmc <eMMC目录> --titledb titledb.json
# 导出全部存档容器
python switch_save_tool.py extract --emmc <eMMC目录> --out ./containers
# 解包成游戏存档文件
python switch_save_tool.py unpack-all --containers ./containers --out ./saves

密钥可以自动探测: 工具会在 SD 根的 atmosphere/automatic_backups/*BISKEYS.bin 里逐把试, 用"能不能把 SYSTEM 分区第 0 扇区解成 FAT 引导扇区"来判定哪把对。代码结构就对应前面四层: Nand 负责拼 NAND 和读 GPT, Xts 负责非标准 XTS, UserFs 负责 FAT32 遍历和导出容器, SaveContainer 负责解包装。

第 0 个扇区能解对, 反而是最危险的结果

整个事情做完, 我印象最深的不是代码, 而是"第 0 个扇区能解对"那一下。它看起来像解密成功了, 其实是最有欺骗性的失败——因为编号 0 是所有假设的公共点, 大端小端、512 还是 16K, 在它身上都退化成同一个结果。后来遇到任何"部分成功"的解密, 我都会先找一组已知明文, 确认是自己对了还是碰巧。

那张卡的 BIS 密钥和 PRODINFO 和主机身份绑定, 文章里全部省略。主机没了以后, 卡里除了存档, 还有相册截图、用户资料、游玩时长, 以及那份完整 NAND 快照; 游戏本体本身是按主机加密的, 换机用不了。真正值得抢救的是这些个人数据, 越早导出越好。

文章链接:

https://time-friend.com/zh/archive/switch-emummc-save-extraction/

# 相关文章推荐