学术研究声明 :本文仅用于学术研究与安全研究 目的,旨在探讨二进制序列化格式的一般性逆向分析方法,促进对数据格式、互操作性(interoperability)与软件安全的理解。文中所述技术仅供学习与研究使用,不得用于任何侵犯知识产权、绕过技术保护措施、未经授权访问或破坏他人系统的行为。任何因此产生的法律责任由使用者自行承担,与作者无关。请读者在遵守所在司法管辖区法律法规的前提下合理使用本文内容。
场景:某个视频剪辑工具的素材资源文件(prefab / scene / material / shader 描述文件),没有文档、没有 SDK,需要在没有源码的情况下把内容完整读出来。本文记录完整的格式推导过程与一个 200 行解析器的实现。文中所有哈希值、字节布局均来自对真实样本的静态分析。
1. 目标与约束
输入是四个二进制文件,唯一线索是文件开头一段可打印字符。要求:
- 判定整体容器结构;
- 定位记录(record)边界,做到字节级对齐校验(所有记录尺寸之和 == 文件大小);
- 还原每条记录内部的字段布局,把字符串、向量、四元数等字段读成可读值;
- 解析结果必须与配套明文资源(JSON / GLSL / 图片)能交叉对上。
不允许依赖原程序运行------纯静态分析。
2. 容器层:先认出"文件头 + 记录表"骨架
把文件头 64 字节铺开,结构几乎是自解释的:
偏移 内容
+0x00 20 字节魔数 "<FORMAT_TAG>\n" (可打印 ASCII, 含一个换行)
+0x14 u32 version 观测值恒为 2
+0x18 u32 record_count 本例 = 2
+0x1c u32 tail_area 观测值恒为 0
+0x20 32 字节保留区 (全 0)
+0x40 ── 记录表开始 ──
魔数一确认,后面三个 u32 的语义用多份样本交叉即定:version 是容器版本号,record_count 决定记录表条数,tail_area 预留(当前为 0)。
记录表 紧跟在 0x40 之后,每条 12 字节:
{ id:u32, type_hash:u32, size:u32 } × record_count
于是 payload(真正的对象数据)起点是:
payload_start = 0x40 + record_count * 12
这是第一个容易踩的坑 :payload 起点不是固定偏移。
record_count==2时它在0x58,==3时在0x64。必须用record_count动态算,不能写死。
payload 区就是各记录数据顺序拼接,没有额外分隔符。因此对整份文件有一条强校验:
payload_start + Σ size_i == file_size
四个样本全部满足------容器层骨架就此坐实。
3. 名字哪去了:所有标识符都是哈希
记录头里的 type_hash 是 0xcac3c195 这种值,而不是字符串。字段名、类型名同样如此。这是典型的"编译期把名字散列掉、运行期只比对哈希"的做法。
借助多份样本中已知语义的值反推,可以确认散列算法是 djb2:
python
def djb2(s):
h = 5381
for c in s.encode():
h = ((h << 5) + h + c) & 0xffffffff
return h
验证方式很直接:猜一个名字,算出哈希,再看是否命中文件中出现的值。例如:
djb2("name") == 0x7c9b0c46 ✓
djb2("enabled") == 0x6a23e990 ✓
djb2("localPosition") == 0xaaf52b45 ✓
djb2("localScale") == 0xf6617af8 ✓
djb2("children") == 0x1b9a142e ✓
djb2("String") == 0xd1ee9bdc ✓
djb2("Vector3f") == 0x27eb6071 ✓
命中的名字进入字典,查不到的则显示为占位符。这里有一个关键设计决策:查不到名字并不是错误,跳过这段数据即可------解析器绝不能因为"不认识"而中断,否则一个新类型就会让整个文件解析崩掉。
有两个哈希靠手猜没有命中,最后通过定向暴力枚举(候选词 × 大小写/前缀/命名空间变体)才破解出来:
0x6e4174bc→localOrientation,0x9836ceb1→Quaternionf。因此字典可以多轮扩充,不必一次凑齐。
4. 对象层:每条记录是一个"自描述"对象
容器层只管切记录,记录内部才是重点。以一条 175 字节的 Transform 记录为例,字节级排布是:
┌─ 对象头 12 字节 ─────────────────────────────┐
obj_type_hash :u32 (这条记录是什么类)
schema_ver :u32 (观测值 = 2)
field_count :u32 (本例 = 6)
└──────────────────────────────────────────────┘
然后 field_count 个字段, 每个字段:
field_name_hash :u32 (字段叫什么)
byte_len :u32 (字段体长度, 字节)
body :byte_len 字节
对象头 12 字节必须先读掉------这是第二个坑:早期版本把它当字段数据跳过,导致每个对象从一开始就少算 12 字节,结尾永远对不齐。
5. 字段体:类型标签 + 一个容易被漏掉的 sub_name
字段体的第一个 u32 永远是类型哈希,决定后续怎么读。这里藏着全文最核心的一个细节:
除 String 外,每种定长类型的字段体里,类型哈希之后还紧跟着一个
sub_name:u32,然后才是真正的 payload。
漏掉这 4 字节,后面所有 payload 全部错位------这正是"localPosition 读出来 16≠20"的成因。逐类型核对后的布局:
| 类型 | 字段体布局 | 总长 |
|---|---|---|
| String | {type, unk:u32, slen:u32, bytes[slen]} |
12 + slen |
| Bool | {type, sub=4, u8} |
9 |
| Vector2f | {type, sub, 2×f32} |
16 |
| Vector3f | {type, sub=7, 3×f32} |
20 |
| Quaternionf | {type, sub=15, 4×f32} |
24 |
| Double | {type, sub, f64} |
16 |
| Int64 | {type, sub, i64} |
16 |
| Vector(容器) | {type, sub=34, count:u32, ...} |
≥12 |
String 是唯一没有 sub_name 的------它的第二个字是个恒为 0 的未知字段,第三个字才是字符串长度。
sub_name 的实际含义是一个小整数 type-id(观测到 4 / 7 / 15 / 34),可理解为引擎内部 RTTI 给内建类型的编号。解析时只需读掉它,不必深究其语义。
字节级验证(那条 175 字节记录):
name String(unk=0, slen=18) = "noFilter_transform" → len 30 = 12+18
enabled Bool sub=4 val=1 → len 9
localPosition Vector3f sub=7 (0,0,0) → len 20
localScale Vector3f sub=7 (1,1,1) → len 20
localOrientation Quaternionf sub=15 (0,0,0,1) ← 单位四元数 → len 24
children Vector sub=34 count=0 → len 12
────────────────────────────────────────────────────────────────
12(对象头) + 30 + 9 + 20 + 20 + 24 + 12 = 175 ✓ 分毫不差
这正是一个主流游戏引擎里 Transform 组件的标准长相:名称 / 开关 / 位置 / 缩放 / 朝向(四元数)/ 子节点列表。
6. 解析器实现(约 200 行,纯标准库)
整体拆成四层,每层只做一件事:
python
import struct, sys, os, json
# ── ① 哈希字典 + djb2 校验 ─────────────────────────────
KNOWN = {
。。。。。。
}
def djb2(s):
h = 5381
for c in s.encode():
h = ((h << 5) + h + c) & 0xffffffff
return h
def nm(h): # 查不到 → 占位符, 不报错
return KNOWN.get(h, f"<{h:#010x}>")
# ── ② 小端游标 ─────────────────────────────────────────
class Reader:
。。。。。。
# ── ③ 容器层: 文件头 + 记录表 + 字节级对齐校验 ───────────
def parse(path, dump_fields=False):
。。。。。。
# ── ④ 对象层: 对象头 + 逐字段(含 sub_name) ──────────────
_FIXED = {0x7c832771:("Bool",1,"<B"), 0x27eb6050:("Vector2f",8,"<2f"),
0x27eb6071:("Vector3f",12,"<3f"), 0x9836ceb1:("Quaternionf",16,"<4f"),
0xae984700:("Double",8,"<d"), 0x0d66433a:("Int64",8,"<q")}
T_STRING, T_VECTOR = 0xd1ee9bdc, 0xd7d69b78
def _dump_object(buf, off, size, type_name):
。。。。。。
需要源码联系作者
7. 正确性靠什么保证
这套格式是"长度自描述"的------容器有 size、对象有 field_count、字段有 byte_len------因此可以在三个层级各做一次闭合校验,任何一处错位都会立刻暴露:
- 容器层
payload_start + Σsize == file_size - 对象层
对象头12B + Σ(8B字段头 + byte_len) == 记录size - 字段层 定长类型
4(type) + 4(sub) + payload == byte_len,变长 String12 + slen == byte_len
四份样本在三层全部闭合。交叉验证方面,二进制里解析出的 shader 路径、纹理槽位、材质属性表,与包内配套明文的 GLSL 源码、图片元数据、配置 JSON 能一一对应------说明还原出的不是"碰巧对齐的字节",而是真实语义。
8. 样本包文件架构
单个特效资源包的完整目录树(已隐去包名哈希),并标注每个文件是明文还是 %SerializedFormat%@ 二进制:
<package>/ ← 单个特效资源包
├── config.json [明文] 入口配置 {Link, version}
├── content.json [明文] 文件映射 {"prefab": "effect.prefab"}
├── sticker.config [明文] 运行参数(检测开关/模型名等)
│
├── effect.prefab [二进制] 预制体 (Prefab + Transform, 2 条记录)
├── main.scene [二进制] 场景 (Scene + 2×Transform, 3 条记录)
│
├── material/ 材质
│ ├── effect.material [二进制] 主材质 (Material + PropertySheet)
│ └── shadow.material [二进制] 阴影材质
│
├── xshader/ 着色器
│ ├── effect.xshader [二进制] 着色器清单 (XShader + 3×Shader, 4 条记录)
│ ├── shadow.xshader [二进制] 阴影着色器清单
│ ├── effect.vert [明文 GLSL] 顶点着色器
│ ├── effect.frag [明文 GLSL] 片元着色器(渐变/描边/贴图混合)
│ └── depth.fs [明文 GLSL] 深度 stub
│
├── texture/ 贴图
│ ├── lieheng.png [图片] 贴纸贴图
│ └── lieheng.png.meta [明文] 贴图导入设置
│
├── rt/
│ └── outputTex.rt [二进制] 渲染目标(RenderTexture 描述)
│
└── mesh/ (本包为空)
包内结构分两层:
- 明文调度层 (
config.json/content.json/sticker.config):声明资源类型、入口 prefab、运行参数。 - 引擎资源层 (
.prefab/.scene/.material/.xshader/.rt):全部是%SerializedFormat%@二进制;其中.xshader又按相对路径引用同目录的明文.vert/.frag/.fsGLSL 源码。
资源引用链(已被后面的解析结果验证):
content.json → effect.prefab
└─(挂载)→ material/effect.material
└─(引用)→ xshader/effect.xshader
├─ xshader/effect.vert
├─ xshader/effect.frag
└─ xshader/depth.fs
└─(采样)→ texture/lieheng.png
9. 解析结果:四个文件全量 dump
用上面的解析器对包内四个二进制资产逐一 -v 展开。三处"stopped early"是刻意不递归嵌套对象(见 §9),不影响容器层 ALIGNED ✓。
9.1 effect.prefab(2 条记录)
version=2 records=2 tail=0 size=0xc9a payload starts @ 0x58
[0] id=1 type=Prefab size=2963 @ 0x58..0xbeb
-- Prefab object: schema=2 fields=3 --
[0] name String('')
[1] entities Vector(count=1) [sub=34]
[2] <0x67a60549> len=0
! stopped early (嵌套 Entity 未递归)
[1] id=2 type=Transform size=175 @ 0xbeb..0xc9a
-- Transform object: schema=2 fields=6 --
[0] name String('noFilter_transform')
[1] enabled Bool 1 [sub=4]
[2] localPosition Vector3f (0, 0, 0) [sub=7]
[3] localScale Vector3f (1, 1, 1) [sub=7]
[4] localOrientation Quaternionf (0, 0, 0, 1) [sub=15]
[5] children Vector(count=0) [sub=34]
ok, 6 fields consumed exactly 175 bytes
end-of-objects @ 0xc9a (file size=0xc9a) ALIGNED ✓
9.2 main.scene(3 条记录)
version=2 records=3 tail=0 size=0xb81 payload starts @ 0x64
[0] id=1 type=Scene size=2497 @ 0x64..0xa25
-- Scene object: schema=2 fields=7 --
[0] name String('Sticker_empty')
[1] <0xedb30b19> String('V4')
[2] entities Vector(count=2) [sub=34]
[3] <0x7c618d53> Bool 1 [sub=4]
[4..6] 嵌套/未识别字段, 未展开
! stopped early (嵌套 Entity 未递归)
[1] id=3 type=Transform size=173 @ 0xa25..0xad2
-- Transform object: schema=2 fields=6 --
[0] name String('Camera_transform')
[1] enabled Bool 1 [sub=4]
[2] localPosition Vector3f (0, 0, 10) [sub=7] ← 相机后退 10
[3] localScale Vector3f (1, 1, 1) [sub=7]
[4] localOrientation Quaternionf (0, 0, 0, 1) [sub=15]
[5] children Vector(count=0) [sub=34]
ok, 6 fields consumed exactly 173 bytes
[2] id=6 type=Transform size=175 @ 0xad2..0xb81
-- Transform object: schema=2 fields=6 --
[0] name String('noFilter_transform')
[1] enabled Bool 1 [sub=4]
[2] localPosition Vector3f (0, 0, 0) [sub=7]
[3] localScale Vector3f (1, 1, 1) [sub=7]
[4] localOrientation Quaternionf (0, 0, 0, 1) [sub=15]
[5] children Vector(count=0) [sub=34]
ok, 6 fields consumed exactly 175 bytes
end-of-objects @ 0xb81 (file size=0xb81) ALIGNED ✓
场景里两个 Transform:一个
Camera_transform(位置 z=10,即相机),一个noFilter_transform(贴纸实体,位置原点)。entities count=2正好对应这两个实体。
9.3 xshader/effect.xshader(4 条记录)
version=2 records=4 tail=0 size=0xab6 payload starts @ 0x70
[0] id=1 type=XShader size=1960 @ 0x70..0x818
-- XShader object: schema=2 fields=3 --
[0] name String('sprite/xshader')
[1] <0xacaf2d4a> Int64 3030 [sub=3]
[2] <0x143d1a54> Vector(count=2) [sub=34]
! stopped early (pass 列表未递归)
[1] id=2 type=Shader size=121 @ 0x818..0x891
[2] <0xb82754c3> String('xshader/effect.vert') ← 顶点着色器路径
ok, 4 fields consumed exactly 121 bytes
[2] id=3 type=Shader size=441 @ 0x891..0xa4a
[2] <0xb82754c3> String('xshader/effect.frag') ← 片元着色器路径
ok, 4 fields consumed exactly 441 bytes
[3] id=4 type=Shader size=108 @ 0xa4a..0xab6
[2] <0xb82754c3> String('xshader/depth.fs') ← 深度着色器路径
ok, 4 fields consumed exactly 108 bytes
end-of-objects @ 0xab6 (file size=0xab6) ALIGNED ✓
.xshader记录里存的不是着色器源码,而是指向包内明文.vert/.frag/.fs的相对路径------这与 §7 目录树里的三个 GLSL 文件一一对应。
9.4 material/effect.material(1 条记录)
version=2 records=1 tail=0 size=0x1a4 payload starts @ 0x4c
[0] id=1 type=Material size=344 @ 0x4c..0x1a4
-- Material object: schema=2 fields=5 --
[0] name String('sprite_mat')
[1] <0x2cde3434> len=50 (着色器引用块)
[2] <0xeba6eb32> PropertySheet (8 个属性字段, 嵌套未展开)
! stopped early (PropertySheet 内部未递归)
end-of-objects @ 0x1a4 (file size=0x1a4) ALIGNED ✓
Material 的
PropertySheet里那 8 个属性字段,对应片元着色器里的 8 组编译开关(多段渐变色、内阴影、贴图混合等级等宏)。字段名哈希未全部破解,故此处只展开到类型层。
9.5 二进制 ↔ 明文交叉对账
| 二进制里解析出的值 | 包内明文文件 | 关系 |
|---|---|---|
Shader 记录 xshader/effect.vert |
xshader/effect.vert |
路径引用 ✓ |
Shader 记录 xshader/effect.frag |
xshader/effect.frag |
路径引用 ✓ |
Shader 记录 xshader/depth.fs |
xshader/depth.fs |
路径引用 ✓ |
XShader 纹理槽位 |
texture/lieheng.png + .meta |
采样贴图 ✓ |
Scene.name="Sticker_empty",entities count=2 |
两个 Transform 实体 | 场景挂载 ✓ |
content.json {"prefab":"effect.prefab"} |
effect.prefab |
入口映射 ✓ |
Material / PropertySheet |
material/effect.material |
容器与记录尺寸吻合 ✓ |
结论:二进制字段名、路径、数值与包内明文资源一一对应,还原出的是真实语义而非碰巧对齐的字节。
10. 已知边界与下一步
- 嵌套对象不递归 :
entities这类Vector<Entity>的元素本身是嵌入对象(同样带 12B 头),PropertySheet内部同理。当前对容器元素只skip,所以这几处会提示"stopped early"------那是刻意不展开,不是错位(容器层ALIGNED不受影响)。把_dump_object改成对容器元素递归调用即可补全。 - 哈希字典未穷尽:引擎用到的名字远不止已破解的这些,字典可以靠"猜词 + djb2 验证"持续滚动扩充,架构上无需改动。
- 未知类型一律跳过:遇到字典里没有的类型标签,解析器跳过该字段继续,保证新类型不会击穿整份文件的解析。
整套方法对任何"魔数 + 记录表 + 长度自描述字段"的私有二进制格式都适用:先定容器骨架,再确认散列算法把名字救回来,最后用多层长度校验把字段布局钉死。