逆向一种私有二进制序列化格式:从零到字节级解析器

学术研究声明 :本文仅用于学术研究与安全研究 目的,旨在探讨二进制序列化格式的一般性逆向分析方法,促进对数据格式、互操作性(interoperability)与软件安全的理解。文中所述技术仅供学习与研究使用,不得用于任何侵犯知识产权、绕过技术保护措施、未经授权访问或破坏他人系统的行为。任何因此产生的法律责任由使用者自行承担,与作者无关。请读者在遵守所在司法管辖区法律法规的前提下合理使用本文内容。
场景:某个视频剪辑工具的素材资源文件(prefab / scene / material / shader 描述文件),没有文档、没有 SDK,需要在没有源码的情况下把内容完整读出来。本文记录完整的格式推导过程与一个 200 行解析器的实现。文中所有哈希值、字节布局均来自对真实样本的静态分析。


1. 目标与约束

输入是四个二进制文件,唯一线索是文件开头一段可打印字符。要求:

  1. 判定整体容器结构;
  2. 定位记录(record)边界,做到字节级对齐校验(所有记录尺寸之和 == 文件大小);
  3. 还原每条记录内部的字段布局,把字符串、向量、四元数等字段读成可读值;
  4. 解析结果必须与配套明文资源(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_hash0xcac3c195 这种值,而不是字符串。字段名、类型名同样如此。这是典型的"编译期把名字散列掉、运行期只比对哈希"的做法。

借助多份样本中已知语义的值反推,可以确认散列算法是 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   ✓

命中的名字进入字典,查不到的则显示为占位符。这里有一个关键设计决策:查不到名字并不是错误,跳过这段数据即可------解析器绝不能因为"不认识"而中断,否则一个新类型就会让整个文件解析崩掉。

有两个哈希靠手猜没有命中,最后通过定向暴力枚举(候选词 × 大小写/前缀/命名空间变体)才破解出来:0x6e4174bclocalOrientation0x9836ceb1Quaternionf。因此字典可以多轮扩充,不必一次凑齐。


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------因此可以在三个层级各做一次闭合校验,任何一处错位都会立刻暴露:

  1. 容器层 payload_start + Σsize == file_size
  2. 对象层 对象头12B + Σ(8B字段头 + byte_len) == 记录size
  3. 字段层 定长类型 4(type) + 4(sub) + payload == byte_len,变长 String 12 + 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/.fs GLSL 源码。

资源引用链(已被后面的解析结果验证):

复制代码
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 验证"持续滚动扩充,架构上无需改动。
  • 未知类型一律跳过:遇到字典里没有的类型标签,解析器跳过该字段继续,保证新类型不会击穿整份文件的解析。

整套方法对任何"魔数 + 记录表 + 长度自描述字段"的私有二进制格式都适用:先定容器骨架,再确认散列算法把名字救回来,最后用多层长度校验把字段布局钉死。

相关推荐
luj_17681 小时前
罚球线右移破防新策略
c语言·开发语言·网络·经验分享·算法
vivo互联网技术1 小时前
SmartPhotoCrafter: 先思考后修图,统一理解-生成的图像优化新范式
人工智能·算法·计算机视觉
威联通安全存储1 小时前
TS-h1290FX 在模具制造设计与CAM场景的部署
python·制造
Jeremy_WW1 小时前
QSFP/QSFP-DD/OSFP 通用管理接口规范(CMIS)解读:11 Page 13h IV
网络·网络协议·信息与通信·光模块·cmis
吞下星星的少年·-·1 小时前
LeetCode 热题 100 两数之和(哈希表)
算法·leetcode·哈希算法
xixiaoyunya1 小时前
备份与还原:为什么还原能力才是评估备份方案的核心指标
网络
Shan12051 小时前
经典算法题示例与详解:飞地的数量(二)
java·数据结构·算法
网硕互联的小客服1 小时前
原生IP,住宅IP,ISPIP,机房IP分别都有什么区别?适合用于什么站点或者程序?
服务器·网络·tcp/ip
liliangcsdn1 小时前
IC计算-前向/远期收益计算和代码示例
开发语言·python·pandas