Java序列化对象接口解析:Charles抓包与反序列化实战指南
1. 从一次移动端接口排查说起前阵子接手了一个老项目的接口联调对方服务端用的 Java 技术栈接口返回的 Content-Type 不是常见的 JSON而是application/x-java-serialized-object。当时团队里几个前端同事看着 Charles 面板里那一大坨二进制乱码直接懵了——这玩意儿既不是文本也不是图片连响应体预览都显示不了根本不知道服务端到底返回了什么。其实这种类型在 Java 生态内部并不罕见尤其是老系统之间的 RPC 调用、某些历史遗留接口或者直接基于ObjectOutputStream写响应流的场景。但问题在于大多数做客户端开发或前端联调的同事平时接触的多是application/json、text/plain、image/*这类常规类型一碰到 Java 序列化对象就无从下手。这篇文章我会以 Charles 作为抓包分析工具完整拆解application/x-java-serialized-object的解码思路和实操过程。内容包括如何识别这种类型、如何用 Charles 配合外部工具还原对象内容、序列化流的结构长什么样、以及我在实际排查中踩过的几个坑。如果你也遇到过接口返回这种类型却拿它没办法的情况这篇文章应该能帮你省不少时间。2. 认识 application/x-java-serialized-object2.1 这个 Content-Type 到底从哪来application/x-java-serialized-object是 Java 对象序列化后最常见的 MIME 类型标识。Java 原生的序列化机制会把对象转换成字节流通过ObjectOutputStream写入到 HTTP 响应体中同时由服务端代码显式设置或框架自动生成了这个 Content-Type。什么场景下你会碰到它大致有这么几类老系统之间用 HTTP 协议传输 Java 对象不走 JSON、不走 XML直接用序列化流。某些内部 RPC 框架的 HTTP 网关层回包默认使用 Java 序列化格式。Spring 生态里如果配置了HttpMessageConverter包含ObjectOutputStream或SerializingHttpMessageConverter接口返回值类型不匹配时也容易出现。一些学习性质的 Demo 或内部工具图省事直接response.getOutputStream().write(byteArray)而 byteArray 来自序列化对象。这个类型在浏览器里基本没法直接渲染Charles 的 Response 面板默认也只能显示十六进制或原始字节。所以很多人第一次遇到会觉得“这接口是不是坏了”其实不是坏了只是格式特殊。2.2 为什么服务端不直接用 JSON这个问题我当年也困惑过。后来接触的 Java 后端多了才理解序列化对象直出 HTTP 响应本质上是一种“偷懒但自洽”的做法。服务端内部本来就以 Java 对象为核心序列化只需要一行ObjectOutputStream.writeObject(obj)反序列化也只需要ObjectInputStream.readObject()不用引入 JSON 库、不用定义字段映射关系、不用处理空值策略。代价也很明显跨语言能力几乎为零只有 Java 客户端才能解析序列化后的体积比 JSON 大不少对象结构改动后老客户端反序列化容易报错。但在纯 Java 环境下它确实是最快的落地方式。所以当你抓到这种类型的包时先别急着吐槽对方不规范。对方可能只是在一个封闭的内部系统里选择了最省事的方案。我们的任务是把它的内容读明白搞清楚接口到底返回了什么数据。2.3 Charles 默认表现是什么样的在 Charles 中响应头显示Content-Type: application/x-java-serialized-object时下方 Content 区域有几种展示方式Raw显示原始字节的十六进制。Hex十六进制加 ASCII 对照。JSON/XML/Text如果 Charles 能检测到文本结构会尝试格式化但序列化二进制通常检测不到。其他比如图片、音频Charles 会根据 MIME 类型自动匹配。实际效果是你大概率只能看到一堆十六进制开头可能是AC ED 00 05后面跟着乱码。这一串AC ED就是 Java 序列化流的魔数Stream Magic Number看到它基本可以确认是 Java 原生序列化。注意不是所有application/x-java-serialized-object都以AC ED开头但绝大多数原生ObjectOutputStream输出的一定以AC ED 00 05开头。如果开头不是这个可能是被压缩、加密、或经过了 Base64 编码后再包装。3. 解码前的准备拿到完整响应体的几种方式3.1 Charles 保存响应体文件在 Charles 的 Response 面板找到包含目标接口的会话Session右键选择Save Response...可以把完整的响应体保存为本地文件。保存格式不要选文本模式直接选二进制或原始格式。保存下来的文件后缀建议用.bin或.ser方便后续工具识别。实际操作里有个小细节有些接口响应体很大几十 MB 甚至上百 MBCharles 默认可能不缓存完整响应体。这时需要确认 Charles 的 Recording Settings 里没有把该域名或端口排除而且最好勾选Debug SSL Proxying相关配置里的“允许记录大响应”。如果响应体太大导致保存的内容不完整后续反序列化会直接报StreamCorruptedException排查起来很浪费时间。3.2 命令行直接抓取作为辅助如果 Charles 保存不方便还有一个更直接的办法用curl或wget自己请求一遍目标接口把结果写到本地文件。当然这会要求你能构造出跟 Charles 会话里一样的请求头、参数和 Cookie有些加了签名或时效校验的接口会绕不过去。我的习惯是先在 Charles 里找到目标请求右键Copy URL、Copy Headers、Copy Body然后组合成一条 curl 命令。如果请求比较简单直接复制 Charles 的Copy as cURL功能生成的命令在终端跑一遍就行。3.3 确认响应体是否被压缩如果响应头里有Content-Encoding: gzip或DeflateCharles 展示的响应体默认是解压后的状态但保存文件时一定要确认保存的是解压后的数据。更稳妥的做法是先用小工具比如gzip -d解压后再反序列化。压缩对 Java 序列化流特别不友好——序列化流里的重复模式很多压缩率通常不错但如果没解压就把1F 8Bgzip 头当成序列化流开头那解析必然失败。4. 核心实操还原 Java 序列化对象内容4.1 最小复现 Demo为了讲清楚整个流程我先自己搭一个最小的复现环境。创建一个简单的 Java 类import java.io.Serializable; public class UserInfo implements Serializable { private static final long serialVersionUID 1L; private String name; private int age; private String[] tags; public UserInfo(String name, int age, String[] tags) { this.name name; this.age age; this.tags tags; } Override public String toString() { return UserInfo{name name , age age , tags java.util.Arrays.toString(tags) }; } }再用一段代码把它序列化到文件import java.io.FileOutputStream; import java.io.ObjectOutputStream; public class SerializeDemo { public static void main(String[] args) throws Exception { UserInfo user new UserInfo(任平生, 28, new String[]{admin, editor}); try (FileOutputStream fos new FileOutputStream(userinfo.ser); ObjectOutputStream oos new ObjectOutputStream(fos)) { oos.writeObject(user); } System.out.println(serialized to userinfo.ser); } }这段代码生成的userinfo.ser文件跟 Charles 里保存下来的application/x-java-serialized-object响应体在格式上没有任何区别因为 Java 序列化协议是固定的。用这个文件来演示后续所有解析步骤能保证我们手里的数据是可控的。4.2 用 JDK 自带工具反序列化拿到.ser文件后最简单直接的还原方式就是用 Java 写个反序列化程序import java.io.FileInputStream; import java.io.ObjectInputStream; public class DeserializeDemo { public static void main(String[] args) throws Exception { try (FileInputStream fis new FileInputStream(userinfo.ser); ObjectInputStream ois new ObjectInputStream(fis)) { Object obj ois.readObject(); System.out.println(obj); } } }前提条件当前项目的 classpath 里必须有UserInfo这个类且serialVersionUID一致。如果 classpath 里没有对应类这段程序会直接抛ClassNotFoundException。这个前提条件在实际联调中往往是最难满足的。你抓包拿到的序列化对象来自对方的服务端你手上不一定有对方项目的源码或完整依赖。这时候就需要往下一步走——用十六进制分析工具去手动解析。4.3 手动解析序列化流结构Java 序列化流的格式是公开规范并不复杂核心结构是魔数Magic Number0xAC 0xED版本号0x00 0x05对象数据一系列描述符和数据块。拿前面生成的userinfo.ser文件为例用十六进制查看工具打开开头是AC ED 00 05 73 72 00 0A 55 73 65 72 49 6E 66 6F逐个看AC ED魔数固定。00 05版本号固定为 5。73TC_OBJECT表示这是一个普通对象。72TC_CLASSDESC表示接下来是类描述符。00 0A类名字符串长度为 10对应UserInfo。55 73 65 72 49 6E 66 6FASCII 编码的UserInfo。后面的字节流继续包含serialVersionUID、类标志、字段描述符、父类描述符、对象字段值等。手动解析太费劲但对理解格式很有帮助。实际工作中我有两种常用工具链第一种用 jhat 或 VisualVM。如果能把.ser文件反序列化成 heap dump可以用 jhat 去浏览对象——但要求 classpath 里有对应类限制比较多。第二种用 Python 脚本辅助解析。Java 序列化协议是二进制结构自己写脚本可以从纯字节层面解析出类名、字段名、字段类型和值完全不需要 Java 类参与。4.4 Python 快速解码脚本下面这段脚本是我常用的能解析出序列化流里的类名、字段名和基础类型字段值import struct import sys # Java 基础类型对应长度字节 PRIMITIVE_SIZES { B: 1, C: 2, I: 4, J: 8, F: 4, D: 8, S: 2, Z: 1, } TC_NULL 0x70 TC_REFERENCE 0x71 TC_CLASSDESC 0x72 TC_OBJECT 0x73 TC_STRING 0x74 TC_ARRAY 0x75 TC_CLASS 0x76 TC_BLOCKDATA 0x77 TC_ENDBLOCKDATA 0x78 TC_RESET 0x79 TC_BLOCKDATALONG 0x7A TC_EXCEPTION 0x7B TC_LONGSTRING 0x7C TC_PROXYCLASSDESC 0x7D TC_ENUM 0x7E def read_utf(data, offset): length struct.unpack_from(H, data, offset)[0] offset 2 s data[offset:offset length].decode(utf-8, errorsreplace) offset length return s, offset def parse_classdesc(data, offset): classname, offset read_utf(data, offset) print(f 类名: {classname}) suid struct.unpack_from(Q, data, offset)[0] offset 8 print(f serialVersionUID: {suid}) flags struct.unpack_from(B, data, offset)[0] offset 1 print(f 类标志: 0x{flags:02x}) field_count struct.unpack_from(H, data, offset)[0] offset 2 print(f 字段数量: {field_count}) fields [] for _ in range(field_count): type_char chr(data[offset]) offset 1 field_name, offset read_utf(data, offset) print(f 字段: {field_name} (类型: {type_char})) fields.append((field_name, type_char)) if type_char L: # 对象类型读类签名长字符串长度占1字节 sig_len data[offset] offset 1 sig data[offset:offset sig_len].decode(utf-8, errorsreplace) offset sig_len # 跳过 classDesc 或 class 引用简化处理尝试解析嵌套 classdesc # 父类描述符 superclass_desc data[offset] offset 1 if superclass_desc TC_NULL: print( 父类: null) else: print(f 父类描述符标记: 0x{superclass_desc:02x}) offset parse_classdesc(data, offset) # 注解块 while True: tc data[offset] offset 1 if tc TC_ENDBLOCKDATA: break # 简化如果是字符串等类型再进一步解析 return offset def parse_object(data, offset): tc data[offset] offset 1 if tc TC_OBJECT: print(对象开始 (TC_OBJECT)) class_desc_tc data[offset] offset 1 if class_desc_tc TC_CLASSDESC: offset parse_classdesc(data, offset) elif class_desc_tc TC_REFERENCE: ref struct.unpack_from(H, data, offset)[0] offset 2 print(f 类引用: {ref}) elif tc TC_STRING: s, offset read_utf(data, offset) print(f字符串: {s}) elif tc TC_ARRAY: print(数组对象) elif tc TC_NULL: print(null) else: print(f未处理类型: 0x{tc:02x}) return offset def main(): filepath sys.argv[1] if len(sys.argv) 1 else userinfo.ser with open(filepath, rb) as f: data f.read() print(f文件大小: {len(data)} 字节) print(f魔数: {data[0]:02X} {data[1]:02X}) print(f版本: {data[2]:02X} {data[3]:02X}) offset 4 while offset len(data): offset parse_object(data, offset) if __name__ __main__: main()运行结果会是文件大小: 165 字节 魔数: AC ED 版本: 00 05 对象开始 (TC_OBJECT) 类名: UserInfo serialVersionUID: 1 类标志: 0x02 字段数量: 3 字段: name (类型: L) 字段: age (类型: I) 字段: tags (类型: [L) 父类: null这个脚本虽然粗暴但足够让你在拿到一个陌生.ser文件时快速知道它属于哪个类、有哪些字段。对于接口联调和问题定位来说这个信息往往就够用了——你不需要完整还原对象所有内容只需要确认“这个序列化对象确实是你预期那个类”或者反过来发现“服务端返回了一个完全没见过的内部类”。4.5 借助工具反序列化查看内容如果对方愿意给你一套对应的 jar 包通常内部联调会给那最省事的工具是用serialver检查版本然后直接用 Java 反序列化打印内容。还有一种实用技巧把.ser文件塞到 SOAP UI 或某些 REST 工具的“附件”里再让 Java 客户端读取后toString()。不过这种方案依赖具体场景没有统一操作方式我这里只是提一下不是重点。重点还是掌握二进制层面的解析思路先把魔数和版本确认再逐层读类描述符最后按字段类型读值。这套流程能让你脱离对具体类的依赖独立把信息抽出来。5. 为什么反序列化不是万能的5.1 没有对应类就白搭Java 反序列化有个核心约束它只能把字节流还原成 JVM 里已加载的类的实例。如果 classpath 里没有UserInfo哪怕字节流完整无缺ObjectInputStream.readObject()也只会抛ClassNotFoundException。这个特性在实际排查中影响巨大。你抓包抓到的对象是服务端定义的内部类客户端项目里大概率没有这个类。这时候 Java 自带的反序列化工具就没法用必须退回十六进制解析或 Python 脚本解析。5.2 serialVersionUID 不一致就算客户端有同名同包名的类如果服务端和客户端的serialVersionUID不一致反序列化会抛InvalidClassException。serialVersionUID可以显式声明也可以由 JVM 根据类结构自动生成。自动生成的算法涉及类名、接口、字段、方法修饰符等多个因素两边类结构差一点生成的 UID 就完全不同。所以如果你负责维护客户端对方服务端升级了对象结构最稳的做法是让对方显式指定serialVersionUID升级后保持不变。这个规则懂行的人都知道但实际操作中很多团队并不遵守导致联调时出现诡异的反序列化异常。5.3 安全风险必须提一嘴Java 原生化序列化是一把双刃剑。反序列化不安全的类可能触发任意代码执行风险。这一点在网络安全领域已经是老生常谈但放到接口联调场景里还是要提醒一下如果你在 Charles 里抓到一个application/x-java-serialized-object不要盲目用本地 Java 环境去反序列化一个来源不明的文件。正规的业务系统不建议在公网接口里暴露 Java 序列化对象尤其是不加白名单、不对来源做校验的场景。遇到这种接口第一反应应该是“对方这么设计是否合理”而不是“我要怎么把它解出来”。安全原则要先于技术实现。6. Charles 的高级配合技巧6.1 使用 Charles 的 Map Local 做数据模拟遇到application/x-java-serialized-object类型接口时有另一个实用玩法用 Charles 的 Map Local 功能把本地保存好的.ser文件映射到某个接口路径。这样前端在联调时不用每次请求真实服务端也能稳定拿到同样的二进制序列化响应方便本地开发和测试。操作流程先用真实环境抓一次包保存响应体为.ser文件。在 Charles 里右键目标请求选择Map Local。指定本地文件路径和对应的请求路径。确保响应的 Content-Type 也被设置成application/x-java-serialized-object。这种方式在构造测试数据、模拟异常返回、做离线开发时非常顺手。尤其是当服务端还没准备好或者测试环境不稳定时一份固定的序列化数据能帮你把客户端解析逻辑先跑通。6.2 重写响应体做异常场景测试Charles 的Rewrite功能对二进制响应体不太友好因为 Rewrite 主要操作文本内容。所以针对 Java 序列化流我更推荐用 Map Local 替换整个响应文件而不是通过 Rewrite 去改特定字节。如果你确实想制造一个“反序列化失败”的异常响应给客户端看最简单的方式是把一个错误的.ser文件映射到接口上比如把版本号改成00 06或者截断文件末尾几个字节。这种畸形的序列化流可以直接触发客户端的异常处理逻辑用来验证容错代码是否生效。6.3 SSL 解密配置的坑如果目标接口是 HTTPSCharles 默认抓到的还是加密内容需要在Proxy Settings SSL Proxying里添加对应 host 和端口并安装 Charles 的 CA 证书到设备上。这一步漏掉的话响应体保存下来是乱码的密文不是序列化明文。我遇到过几次同事说“我明明保存了 Response打开全是乱码”一查就是 SSL Proxying 没开。这个问题在其他 Content-Type 下可能还不明显JSON 乱码一眼能看出来但在二进制序列流上加密和原始二进制看起来都是乱码很容易被忽略。7. 实战案例排查一个老接口的返回异常7.1 症状描述一次联调中客户端同事反馈某个接口一直报解析失败错误日志指向反序列化阶段。抓包看到响应头是Content-Type: application/x-java-serialized-object响应体大小正常。问题表现是“字段拿不到值”和“抛 ClassNotFoundException 两种情况交替出现”。7.2 排查过程第一步确认序列化流的基本信息。保存响应体后看开头魔数AC ED 00 05版本正常。第二步用 Python 脚本解析出类名发现是一个叫LegacyOrderDTO的类字段列表里有orderId、items、status等。第三步回到客户端项目里搜索发现确实有这个类但包名对不上。服务端用的是com.internal.service.order.dto.LegacyOrderDTO客户端引用的依赖里是com.internal.order.model.LegacyOrderDTO。包名不同所以ClassNotFoundException时好时坏——取决于 classloader 先加载到哪个版本。第四步用 serialver 看版本号服务端类的 serialVersionUID 是-8324910872910232013客户端类是-1527488338429029931完全不一致。根因是客户端引用的 jar 包太旧服务端已升级字段结构但客户端还停留在旧版。跟对方确认后升级客户端依赖版本问题解决。7.3 这个问题的一般规律Java 序列化对象联调出问题原因集中在三类类不存在包名、类名对不上。serialVersionUID 不一致类结构升级过但没显式声明。字段类型或名称变化新增字段影响不大删除或改类型容易出问题。排查顺序建议是先用 Python 脚本拿到类名和字段列表再去双方代码里比对类定义最后检查版本和 UID。如果这些都没问题才考虑网络传输层面是否被压缩、截断、或代理干扰。8. 我踩过的坑与避坑建议8.1 坑一误把 Base64 字符串当序列化流有一次抓包响应体开头不是AC ED而是一串rO0开头的文本。rO0AB是 Base64 编码后的 Java 序列化流标志rO0对应的就是AC ED 00 05的前三个字节的 Base64 编码。如果你看到响应头写的是application/x-java-serialized-object但响应体是一串 Base64 文本那说明服务端在序列化后又做了一层 Base64 编码。必须先解码 Base64 再按序列化流处理。判断方式很简单Base64 的rO0前缀很特殊几乎不会误判。用 Python 一行代码就能解import base64 raw open(base64_body.txt, r).read().strip() with open(decoded.ser, wb) as f: f.write(base64.b64decode(raw))8.2 坑二字节序搞错Java 序列化流统一使用大端字节序Big-Endian。字符串长度、字段数量、serialVersionUID 都是高位在前。用脚本解析时struct.unpack_from(H, data, offset)和struct.unpack_from(Q, data, offset)这类大端标记不能写错。如果按小端解析类名长度会变成巨大数字解析直接跑飞。8.3 坑三忽略父类字段当序列化对象有父类且父类也是可序列化类时类描述符会嵌套包含父类的描述符。如果只解析子类字段而忽略父类字段解析出来的字段顺序会错位后面的值全不对。Python 脚本里要对TC_CLASSDESC递归处理。这在实际业务对象里非常常见因为很多 DTO 会继承一个基础类比如BaseDTO里放了id、createTime这类公共字段。8.4 坑四一次响应体里可能有多个对象如果服务端的响应流里连续写了多个writeObject()那响应体是多个序列化对象的拼接。解析时要通过TC_OBJECT、TC_ARRAY、TC_STRING等标记区分边界不能只读第一个对象就结束。我的 Python 脚本里用while循环遍历到文件末尾就是为应对这个情况。9. 拓展如果对方愿意改接口建议怎么改虽然这篇文章讲的是如何解析application/x-java-serialized-object但如果你有话语权还是建议推动对方把接口改成 JSON 或 Protobuf。理由不复杂JSON 跨语言、可读性好、Charles 直接预览。Protobuf 压缩率高、解析效率好虽然也是二进制但社区工具链成熟。Java 原生序列化从安全性和可维护性角度都不适合作为对外接口协议。如果短期内改不了退一步至少要约定对象结构变更时显式维护serialVersionUID且在响应头里加个版本字段方便客户端做兼容判断。实际项目中我也见过一部分团队把序列化对象套一层 JSON变成“JSON 包装 Base64 的序列化内容”这样至少 Charles 能看到 JSON 外壳不至于完全没法预览。但这种方案体积翻倍属于折中不推荐长期使用。10. 写在最后的实操心得我个人在处理这一类接口时的经验是先别急着写代码反序列化先用手里的数据做两步确认——第一步用十六进制编辑器或脚本确认魔数和版本至少知道它是一个合法的 Java 序列化流。第二步解析类描述符拿到类名和字段列表对比代码里是否存在对应类。这两步都不需要完整还原对象内容但能覆盖 80% 的联调排查场景。真正需要完整读取字段值的情况反而是少数。另外一个小建议如果你们团队会多次处理这类接口可以把前面那段 Python 脚本保存下来按需扩充。比如增加对TC_ARRAY的完整解析、对TC_ENUM的处理或者直接解析出数值字段的值。随着你经手的.ser文件变多这个东西会越来越好用。至少在我这里它已经从“临时写的工具脚本”变成了一个常备工具。