ZKar引用句柄系统揭秘:TCReference与Handler如何在序列化流中解析对象引用
【免费下载链接】zkarZKar is a Java serialization protocol analysis tool implement in Go.项目地址: https://gitcode.com/gh_mirrors/zk/zkar
ZKar 是一款用 Go 语言编写的 Java 序列化协议分析工具(0xACED 0x0005字节流的解析器与重建器),无需 JDK、无需 CGO。本文揭秘 ZKar 的引用句柄系统:TCReference与 Handler 如何在序列化流中解析对象引用——这正是理解 Java 序列化"对象只写一次、后面反复引用"机制的核心。
为什么 Java 序列化需要"句柄"?
Java 序列化流(由ObjectOutputStream写出)有一个重要特性:共享与循环引用。
当一个对象在流中出现第二次时,JVM 不会再次完整写出它,而是写一个 5 字节的"回指":
0x71 00 7E 00 000x71是TC_REFERENCE标签(常量定义见 serz/model.go 中的JAVA_TC_REFERENCE)- 后面 4 字节是句柄(Handler),即该对象第一次被写出时领取的"门牌号",例如
0x7E0000(十进制 8257536)
因此解析器必须维护一张"门牌号 → 对象"的登记表,这正是 ZKar 中TCReference+ Handler 系统要解决的问题。
快速上手:用 ZKar Dump 一个真实载荷
克隆仓库后即可体验(仓库地址:https://gitcode.com/gh_mirrors/zk/zkar):
git clone https://gitcode.com/gh_mirrors/zk/zkar cd zkar go run main.go dump -f testcases/ysoserial/CommonsCollections6.serCLI 入口实现在 main.go,dump子命令会把字节流解析成一棵结构树打印出来。对象节点上都能看到@Handler字段,例如:
TC_OBJECT - 0x73 @Handler - 8257538 ... TC_REFERENCE - 0x71 @Handler - 8257538TC_REFERENCE后面跟的 Handler,指回的就是之前@Handler相同的那个对象——这就是 ZKar 帮你还原的"对象引用关系图"。
TCReference:一个 5 字节的"回指指针"
解析TC_REFERENCE的入口是readTCReference(位于 serz/tc_reference.go),流程只有三步:
- 读标签:消费 1 字节
0x71 - 读句柄:按大端序读 4 字节,得到 Handler 值
- 查表回指:调用
stream.GetReference(handler)从登记表中取出对象,并根据对象的具体类型(TCObject、TCClass、TCClassDesc、TCProxyClassDesc、TCString、TCArray、TCEnum)设置引用类型标记
TCReference结构体本身并不内嵌对象的副本,而是持有指向已有对象的指针。若 Handler 在表中查不到,解析器会直接报错object reference %v is not found——这种"前向引用"在合法流中是不存在的,报错能帮你快速定位畸形载荷。
Handler:流的"身份证登记处"
句柄登记表的维护者是ObjectStream(serz/buffer.go),它有三个关键成员:
| 成员 | 作用 |
|---|---|
handler | 当前可分配的句柄号,初始为JAVA_BASE_WRITE_HANDLE = 0x7E0000 |
references map[uint32]Object | 句柄 → 对象的登记表 |
AddReference(obj) | 给对象发放门牌号并登记,然后句柄号 +1 |
GetReference(handler) | 按门牌号查表(供TCReference回指使用) |
ZKar 严格模拟 Java 写端的行为:每个对象在第一次被写出时登记,而不是被引用时。以普通对象为例,readTCObject读完类指针后立即调用stream.AddReference(obj)(serz/tc_object.go);字符串(serz/tc_string.go)、数组、枚举、类描述符、代理类描述符同理。这种"发放时机"一旦错位,回指就会全部对不上——这也是该项目测试套件坚持逐字节回环(解析后再序列化,必须与原文件完全一致)的原因。
另外,Java 协议定义了TC_RESET(0x79)用于重置整个句柄上下文,ZKar 将其作为一等内容类型处理(见 serz/tc_content.go),不会与相邻记录合并。
引用的另一大场景:类描述符复用
对象引用并不只出现在流的顶层。Java 允许类描述符被引用:多个对象如果是同一个类,第二个对象后面只需要写TC_REFERENCE指回类描述符的句柄,不必重复写出整个类元数据。
这一场景由TCClassPointer(serz/tc_classpointer.go)承载——它表示"一个类的指针",可以是全新的类描述符,也可以是一个TCReference。当 ZKar 需要读取对象字段时,FindClassBag方法会先判断指针类型:如果是指向句柄的引用,就从登记表里取出真正的类描述符,再沿SuperClassPointer递归收集完整类链。
如何优雅遍历:Walk 防环机制
由于引用会造成对象图出现环(A 引 B、B 又引 A),朴素的递归遍历会死循环。ZKar 的解法很克制:
TCReference.Walk直接返回,不再深入——它指向的对象在前面已经被访问过,源码注释写得很明白:"We don't walk into TCReference, because its field are all the pointer that walked before"- 通用访问器
FindObject/FindClassDesc(serz/walker.go)支持按条件搜索任意节点,命中后返回哨兵错误StopWalkError短路退出
这让你可以方便地回答"流里有没有某个类描述符"这类问题,而不用担心引用成环。
字节级回环:引用也能被忠实重建
ZKar 不只读,也能写。TCReference.ToBytes()只做一件事:输出0x71+ 4 字节大端句柄,保证序列化载荷在"解析 → 重建"后逐字节还原。项目内置了大量真实 ysoserial 载荷作为基准(testcases/ysoserial/),测试断言重建结果与原始文件bytes.Equal——引用句柄系统的正确性正是靠这套"黄金基准"钉死的。
相关源码地图
| 文件 | 职责 |
|---|---|
| serz/tc_reference.go | TCReference定义与解析 |
| serz/buffer.go | ObjectStream:句柄登记表与发放逻辑 |
| serz/model.go | TC_*标签常量与起始句柄0x7E0000 |
| serz/tc_content.go | 顶层标签分发器(引用也是其一) |
| serz/tc_classpointer.go | 类描述符引用与类链收集 |
| serz/walker.go | 防环遍历与节点搜索 |
| serz/parser.go | 流头部(魔数+版本)与主解析循环 |
小结 🎯
ZKar 的引用句柄系统用三个概念还原了 Java 序列化的对象引用机制:ObjectStream按"首次写出即登记"的原则发放从0x7E0000起递增的 Handler;TCReference作为 5 字节的回指节点,通过查表把 4 字节数字还原成真实的对象指针;Walk 防环设计则让带引用的对象图可以安全遍历。理解这套机制后,你阅读 ZKar 打印出的任何结构树时,都能一眼看懂每个TC_REFERENCE到底"指"向谁。
【免费下载链接】zkarZKar is a Java serialization protocol analysis tool implement in Go.项目地址: https://gitcode.com/gh_mirrors/zk/zkar
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考