逆向一个被遗忘的DVD游戏格式:从DES加密到Rust模拟器

📅 2026/6/29 20:40:16
逆向一个被遗忘的DVD游戏格式:从DES加密到Rust模拟器
我用 Rust 逆向了这个格式并实现了模拟器。记录一下技术细节。[图1: FHUI 主菜单界面 — 七个游戏分类]文件格式所有文件共享同一二进制格式魔数头_YUVGamemaker 1.3.12。偏移 内容 0x00 SWFT 缩略图可选跳过 --- _YUV / ARGB 色彩空间标记 --- 生成器字符串48字节如 Resolution_320x240 --- 基础偏移量colorspace 0x60 --- 加密头部32字节 ← 资源偏移量藏在这里 --- 光标数据 --- 声音表 → 帧表 → 图像表 → 动作表 → 影片表 → 按钮表32 字节加密头部是关键——里面藏了资源表的偏移量。不解密就什么都读不出来。资源对象类型Image(1)、Movie(2)、Button(3)、Action(4)、Sound(5)。两种使用方式独立游戏— 单个.smf文件包含所有资源如教育类游戏跳板场景— 11KB 的.smf跳板文件 多个.ssl场景文件如赤刃DES 加密不是标准 DES文件头 32 字节用 DES ECB 加密。我直接用了标准 DES 库解出来全是乱码。又试了 3DES、DESede都不对。对比参考实现的输入输出后发现凌阳用的是自定义 DES。算法框架和标准 DES 一样初始置换 → 16 轮 Feistel → 最终置换但所有置换表和 S-boxes 全部不同。密钥来自常量aber3801和芯片型号 SPHE8202 相关。需要手写的查找表一共 8 组非标准查找表pub const INITIAL_MESSAGE_PERMUTATION: [u8; 64] [ 0x3a, 0x32, 0x2a, 0x22, 0x1a, 0x12, 0x0a, 0x02, 0x3c, 0x34, 0x2c, 0x24, 0x1c, 0x14, 0x0c, 0x04, // ... ]; pub const FINAL_MESSAGE_PERMUTATION: [u8; 64] [ /* 64个值 */ ]; pub const MESSAGE_SHUFFLE: [u8; 48] [ /* 48个值 */ ]; pub const RIGHT_SUB_MESSAGE_PERMUTATION: [u32; 32] [ /* 32个值 */ ]; pub const INITIAL_KEY_PERMUTATION: [u8; 56] [ /* 56个值 */ ]; pub const SUB_KEY_PERMUTATION: [u8; 48] [ /* 48个值 */ ]; pub const DES_SBOXES: [[u8; 64]; 8] [ /* 8×64 512个值 */ ]; pub const KEY_SHIFT_SIZES: [u8; 16] [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];验证方式对比参考实现的输入输出对逐个表核对。任何一个值错了解密结果就是错的。DES 解密是整个项目的守门人——解不开就什么都做不了。这一步花了我整个项目三分之一的时间。Action 虚拟机指令集36 个操作码的栈式虚拟机。操作码值和 Flash ActionScript 高度重合GotoFrame0x81,Push0x96,If0x9d但字节码编码不同。凌阳大概参考了 AS 的设计但自己搞了一套。操作码分类帧控制: NextFrame(0x04), PreviousFrame(0x05), Play(0x06), Stop(0x07) 算术: Add(0x0a), Subtract(0x0b), Multiply(0x0c), Divide(0x0d) 比较: Equals(0x0e), Less(0x0f), StringEquals(0x13), StringLess(0x29) 逻辑: And(0x10), Or(0x11), Not(0x12) 变量: GetVariable(0x1c), SetVariable(0x1d) 精灵: CloneSprite(0x24), RemoveSprite(0x25) 跳转: GotoFrame(0x81), Jump(0x99), If(0x9d), Call(0x9e) 宿主: Push(0x96), GetUrl2(0x9a)宿主调用GetUrl2操作码是 Native32 特有的扩展。Flash 中GetUrl2用于加载网页Native32 用它来调用平台 APISSLSSL_PlayNext路径→ 加载下一个场景SSLSSL_SaveSSLData变量→ 保存存档SSLSSL_GetSSLData变量→ 读取存档LoadImage精灵D路径→ 加载 .dat 名称横幅LoadImage精灵J路径→ 加载 .dat 预览截图StartGame路径→ 启动游戏GetFileNum目录→ 返回游戏数量GetFirstFile目录→ 获取第一个游戏名GetNextFile目录→ 获取下一个游戏名需要实现VmHosttrait 来桥接 VM 和宿主系统。独立桌面端和 libretro 核心各自实现这个 trait。VM 骨架pub struct ActionVM { stack: VecValue, vars: HashMapString, Value, pc: usize, bytecode: Vecu8, } impl ActionVM { pub fn execute_frame(mut self, host: mut dyn VmHost) - Result() { loop { let opcode self.read_u8()?; match opcode { 0x00 return Ok(()), 0x04 host.next_frame(), 0x96 self.push()?, 0x99 self.jump()?, 0x9a { let url self.pop_string()?; host.get_url2(url)?; } 0x9d self.conditional_jump()?, _ return Err(anyhow!(Unknown opcode: 0x{:02x}, opcode)), } } } }VM 本身不复杂——36 个 match 分支一个栈一个变量表。麻烦的是宿主调用的实现每个调用都需要理解原机的行为语义。图像解码YUV 4:2:0Y 通道全分辨率U/V 半分辨率需 2x 垂直插值。压缩用 packbits RLE 混合编码。插值的边界处理有点 tricky——边缘像素不能简单复制否则色块边界很明显。参考实现用了一种借用策略U/V 值为 0 时借用相邻行的值。fn interpolate_y(data: [u8], w: usize, h: usize) - Vecu8 { let h1 h * 2; let mut result vec![0u8; w * h1]; for y in 0..h { for dy in 0..2 { for x in 0..w { let val if dy 0 { if y 0 || get(y * w x) ! 0 { get(y * w x) } else { get((y - 1) * w x) } } else { if y h - 1 || get(y * w x) ! 0 { get(y * w x) } else { get((y 1) * w x) } }; result[(y * 2 dy) * w x] val; } } } result }ARGB155516 位格式直接位移解码。5 位分量乘 8 扩展到 8 位。SSL 多文件系统大游戏的加载流程EBBLADE.smf (11KB 跳板) → NALOGO.mpgLogo 视频 → BBSTART.SSL标题1.8MB → BBMENU.SSL主菜单3.5MB → BBPLAY10.SSL第1关2.6MB → BBPLAY20 → BBPLAY30 → ... → BBFINISH.SSL / BBOVER.SSL.ssl和.smf用完全相同的二进制格式。区别仅在用途。存档格式.ssl_sav文件纯文本数字字符串。比如1109600000002。NA32SSL目录下有两套完全独立的游戏集——CHINESE中文教育和 ENGLISH英文动作不是