1. 从一个线上事故说起:为什么每个开发者都该搞懂序列化
去年冬天,我接手了一个紧急故障排查。一个跑了两年多的订单系统突然开始间歇性报错,日志里全是ClassCastException和InvalidClassException,重启能好一阵,过几个小时又复发。团队里有人怀疑是数据库连接池泄漏,有人怀疑是缓存穿透,折腾了大半天没找到根因。最后定位到问题的时候,所有人都沉默了——三个月前有人在订单实体类里加了一个字段,但忘了更新serialVersionUID,而那个类恰好被放进了分布式缓存里做会话共享。老版本序列化的数据被新版本的类反序列化时,直接对不上号。
这件事让我意识到,序列化和反序列化这个看起来"基础得不能再基础"的知识点,实际上是很多线上事故的隐形杀手。你可能每天都在用它——往 Redis 里写对象、通过接口传 JSON、把配置存到文件、用消息队列传数据——但真正理解它底层机制的人并不多。更关键的是,这几年反序列化攻击已经成了安全领域最热门的漏洞类型之一,从 Java 的 Commons Collections 到 Fastjson,从 PHP 的unserialize()到 Python 的 pickle,几乎每一种语言都在这上面栽过跟头。
这篇文章我想把序列化和反序列化这件事彻底讲透。不管你是刚入行的新手,还是写了几年业务代码的老兵,我都会从最基础的概念讲起,一路讲到 Java 原生序列化的坑、JSON 序列化工具的选型、Redis 序列化方案怎么定、PHP 反序列化漏洞的原理,以及反序列化攻击到底是怎么发生的、我们该怎么防。文章会比较长,但每一段都是我实际踩过坑或者帮别人排查过问题后总结出来的,你可以当成一份速查手册,遇到具体问题时翻到对应章节看。
2. 序列化与反序列化的本质:把对象"冻起来"再"解冻"
2.1 用生活化的类比理解这两个概念
先抛开代码,我们用生活里的场景来理解。假设你有一个乐高拼好的城堡,你想把它寄给远方的朋友。但快递不能直接寄一个"立体的城堡",你得把它拆成一块块零件,按说明书编号打包进盒子,这个过程就是序列化。朋友收到盒子后,按照说明书把零件重新拼成城堡,这个过程就是反序列化。
在这个类比里:
- 乐高城堡= 内存中的对象(有属性、有状态、有引用关系)
- 拆解打包= 序列化(把对象转成字节流或字符串)
- 快递运输= 网络传输或持久化存储
- 按说明书重组= 反序列化(把字节流或字符串还原成对象)
- 说明书= 序列化协议/格式(Java 原生、JSON、XML、Protobuf 等)
关键点在于:序列化后的数据必须包含足够的信息,让反序列化能完整还原对象。如果说明书丢了几个零件的编号,朋友就拼不出完整的城堡——这就是为什么改类结构会导致反序列化失败。
2.2 序列化到底解决了什么问题
从技术角度看,序列化主要解决三个核心问题:
第一,跨进程通信。两个独立的进程(比如你的订单服务和库存服务)运行在不同的内存空间里,它们没法直接访问对方内存中的对象。要传数据,必须先把对象转成字节流,通过网络发过去,对方再反序列化成对象。RPC 框架、消息队列、分布式缓存,底层都依赖这个机制。
第二,持久化存储。内存里的对象断电就没了。如果你想把它存到磁盘文件、数据库 BLOB 字段、或者 Redis 里,就必须先序列化。比如用户会话(Session)要存到 Redis 做共享,就得把 Session 对象序列化成字节或字符串。
第三,跨语言数据交换。你的后端是 Java,前端是 JavaScript,两边要传数据。Java 对象没法直接被 JS 理解,但 JSON 字符串可以。所以 Java 端把对象序列化成 JSON,JS 端解析 JSON 拿到数据,这就是跨语言序列化的典型场景。
注意:序列化和"编码"不是一回事。编码(如 UTF-8、Base64)解决的是"字符怎么变成字节",序列化解决的是"对象结构怎么变成字节"。两者经常配合使用,但概念上要分清。
2.3 序列化协议的分类与选型逻辑
市面上的序列化协议大致可以分成三类,选型时主要看四个维度:可读性、性能、跨语言支持、安全性。
| 协议类型 | 代表方案 | 可读性 | 性能 | 跨语言 | 安全性 |
|---|---|---|---|---|---|
| 文本类 | JSON、XML、YAML | 高 | 中 | 好 | 中(需防注入) |
| 二进制类 | Java原生、Protobuf、Thrift | 低 | 高 | 差/好 | 低/中 |
| 混合类 | Hessian、Kryo、MessagePack | 低 | 高 | 中 | 中 |
选型的核心逻辑是这样的:如果是对外 API,优先选 JSON,因为可读性好、调试方便、跨语言无障碍;如果是内部高性能 RPC,选 Protobuf 或 Thrift,体积小、序列化快;如果是临时缓存,看团队习惯,但一定要避开 Java 原生序列化;如果是配置文件,YAML 或 JSON 都行,看可读性需求。
我个人的经验是:除非有极致的性能要求,否则 JSON 是默认选择。原因很简单——出问题时你能直接看懂数据长什么样,排查效率高一个数量级。二进制协议虽然快,但一旦出问题,你得写额外的工具去解析字节流,调试成本太高。
3. Java 原生序列化:方便但坑最多的方案
3.1 怎么用:Serializable 接口与 serialVersionUID
Java 原生序列化的使用门槛极低,只要让类实现java.io.Serializable接口就行:
public class Order implements Serializable { private static final long serialVersionUID = 1L; private Long orderId; private String userId; private BigDecimal amount; // getter/setter 省略 }然后用ObjectOutputStream写、ObjectInputStream读:
// 序列化 try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("order.dat"))) { oos.writeObject(order); } // 反序列化 try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("order.dat"))) { Order order = (Order) ois.readObject(); }看起来很简单对吧?但这里藏着第一个大坑:serialVersionUID。
这个字段是 Java 序列化机制用来做版本校验的。序列化时会把serialVersionUID写进字节流,反序列化时会拿字节流里的值和当前类的值比对,不一致就直接抛InvalidClassException。如果你不显式声明,JVM 会根据类的结构(字段、方法、修饰符等)自动生成一个,只要类结构有任何变化,这个值就会变。
我开头讲的那个线上事故,根因就在这里。所以我的建议是:只要类实现了 Serializable,就显式声明 serialVersionUID,并且把它当成接口契约来管理。加字段、删字段、改方法,只要不影响兼容性,就不要动这个值。
3.2 transient 关键字:哪些字段不该被序列化
有些字段是不应该被序列化的,比如密码、临时计算结果、数据库连接对象。这时候用transient修饰:
public class User implements Serializable { private static final long serialVersionUID = 1L; private String username; private transient String password; // 不会被序列化 private transient Connection conn; // 连接对象本来也不该序列化 }反序列化后,transient字段会是默认值(对象是 null,int 是 0)。这一点在会话共享场景要特别注意——如果你把用户密码放在 Session 里又标了 transient,反序列化后密码就丢了,可能导致鉴权失败。
3.3 Java 原生序列化的四个致命缺陷
用了这么多年,我总结 Java 原生序列化有四个绕不开的问题:
第一,安全风险极高。这是最严重的。Java 反序列化会调用对象的readObject()方法,而攻击者可以构造恶意的字节流,让反序列化过程触发任意代码执行。著名的 Commons Collections 漏洞链就是利用了这个机制。只要你的应用反序列化了不可信来源的数据,就等于把代码执行权限交给了攻击者。
第二,性能差、体积大。Java 原生序列化会把类的元数据、字段描述符全都写进去,一个简单的对象序列化后可能几百字节,而 JSON 只要几十字节。序列化速度也比 JSON 慢不少。
第三,跨语言几乎不可能。Java 序列化的字节流是 Java 私有的格式,其他语言没法解析。这在微服务架构下是致命的——你的 Java 服务没法把序列化数据传给 Go 或 Python 服务。
第四,版本兼容性脆弱。前面说的serialVersionUID问题只是冰山一角。字段类型变了、继承关系变了、甚至字段顺序变了,都可能导致反序列化失败。
实操心得:新项目里,我基本不用 Java 原生序列化。如果非要用(比如某些框架强制要求),至少做到两点:一是所有类显式声明 serialVersionUID,二是绝对不反序列化外部传入的数据。
4. JSON 序列化工具选型:Jackson、Gson、Fastjson 怎么选
4.1 三大主流工具的横向对比
JSON 是当下最通用的序列化格式,Java 生态里主流的工具有三个:Jackson、Gson、Fastjson。我做过一个简单的性能测试,序列化一个包含 20 个字段的对象 100 万次,结果大致如下:
| 工具 | 序列化耗时 | 反序列化耗时 | 安全性 | 社区活跃度 |
|---|---|---|---|---|
| Jackson | 中等 | 中等 | 高 | 非常活跃 |
| Gson | 较慢 | 较慢 | 高 | 活跃 |
| Fastjson | 快 | 快 | 历史漏洞多 | 活跃(国内) |
Jackson是目前 Spring Boot 默认的 JSON 工具,功能最全,注解体系完善,安全性记录良好。缺点是 API 稍微繁琐,配置项多。
Gson是 Google 出的,API 最简洁,适合小项目快速上手。但性能一般,而且对复杂泛型的支持不如 Jackson。
Fastjson是阿里出的,性能确实好,国内用得很广。但它的历史漏洞实在太多了——fastjson+序列化+不包括转义字符这个热搜词背后就是一堆因为autoType特性导致的反序列化攻击。虽然 Fastjson 2.x 做了大量安全改进,但我的建议是:新项目优先选 Jackson,除非你有明确的性能压测数据证明 Fastjson 更合适。
4.2 Jackson 的实战配置与常见坑
Jackson 用起来有几个必须注意的点。首先是空值处理,默认情况下 Jackson 会把 null 字段也序列化出来,如果你不想这样:
ObjectMapper mapper = new ObjectMapper(); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);其次是日期格式,默认会序列化成时间戳,通常我们需要可读的字符串:
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));第三个坑是循环引用。如果对象之间有双向关联(比如订单里有用户,用户里有订单列表),Jackson 序列化时会无限递归导致栈溢出。解决办法是用@JsonIgnore或@JsonManagedReference/@JsonBackReference打断循环。
第四个坑是泛型反序列化。mapper.readValue(json, List.class)这种写法拿到的是List<LinkedHashMap>,不是List<Order>。正确写法是用TypeReference:
List<Order> orders = mapper.readValue(json, new TypeReference<List<Order>>() {});4.3 Fastjson 的安全配置要点
如果你不得不用 Fastjson,务必关掉autoType:
ParserConfig.getGlobalInstance().setAutoTypeSupport(false);autoType是 Fastjson 为了方便反序列化多态类型而设计的特性,但它允许 JSON 里指定要反序列化的类名,攻击者可以借此加载恶意类。fastjson+序列化+不包括转义字符这个热搜词,说的就是某些场景下 Fastjson 对特殊字符处理不当,导致攻击者能绕过校验注入恶意内容。
注意:即使关了 autoType,老版本 Fastjson 仍可能通过其他方式被绕过。生产环境建议升级到 Fastjson 2.x,或者直接换 Jackson。
5. Redis 序列化方案:选错了性能差十倍
5.1 RedisTemplate 的四种序列化器
用 Spring Data Redis 的时候,RedisTemplate默认用的是JdkSerializationRedisSerializer,也就是 Java 原生序列化。这就是为什么你用redis-cli去看存进去的数据,看到的是一堆乱码——因为它是 Java 私有格式。
Spring Data Redis 提供了四种常用的序列化器:
- JdkSerializationRedisSerializer:默认,Java 原生序列化,可读性差、体积大、有安全风险
- StringRedisSerializer:字符串序列化,可读性好,但只能存字符串
- Jackson2JsonRedisSerializer:JSON 序列化,可读性好,推荐
- GenericJackson2JsonRedisSerializer:JSON 序列化,会额外存储类信息,支持多态
我的推荐配置是这样的:
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class); ObjectMapper mapper = new ObjectMapper(); mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL); serializer.setObjectMapper(mapper); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; }5.2 为什么不要用 JDK 序列化存 Redis
三个理由。第一,可读性。用 JSON 存,你redis-cli一看就知道数据长什么样;用 JDK 序列化,你只能看到乱码,排查问题极其痛苦。第二,体积。同样的对象,JDK 序列化后体积通常是 JSON 的 2-3 倍,Redis 内存成本直接翻倍。第三,兼容性。前面说过,改类结构会导致反序列化失败,而 Redis 里的数据可能存了很久,一旦类变了,老数据全读不出来。
我见过一个团队因为用 JDK 序列化存 Session,结果升级 JDK 版本后所有用户被迫重新登录——因为不同 JDK 版本的序列化格式有细微差异。
5.3 序列化方案变更时的数据迁移
如果你已经在用 JDK 序列化,想换成 JSON,不能直接切——老数据读不出来。稳妥的做法是双写过渡:新数据用 JSON 写,读的时候先尝试 JSON,失败再回退到 JDK。等老数据自然过期后,再彻底切掉 JDK 序列化。
6. PHP 反序列化漏洞:从原理到实战
6.1 PHP 序列化格式解析
PHP 的序列化和 Java 思路类似,但格式更简单。用serialize()把对象转成字符串,用unserialize()还原:
class User { public $name = "admin"; public $role = "guest"; } $u = new User(); echo serialize($u); // 输出:O:4:"User":2:{s:4:"name";s:5:"admin";s:4:"role";s:5:"guest";}这个格式的含义是:O表示对象,4是类名长度,User是类名,2是属性个数,后面是每个属性的键值对。s表示字符串,i表示整数,a表示数组。
php序列化中文是个常见坑:PHP 序列化字符串时,长度是按字节算的,不是按字符算的。一个中文字符在 UTF-8 下占 3 个字节,所以s:3:"中";才是正确的,写成s:1:"中";会反序列化失败。
6.2 PHP 反序列化漏洞原理
PHP 反序列化漏洞的核心在于魔术方法。当unserialize()还原对象时,会触发一系列魔术方法:
__wakeup():反序列化时自动调用__destruct():对象销毁时自动调用__toString():对象被当字符串使用时调用
如果这些方法里存在危险操作(比如执行命令、读写文件),攻击者就能通过构造恶意序列化字符串来触发。这就是php反序列化漏洞原理的核心。
举个经典例子:
class FileReader { public $filename; public function __destruct() { echo file_get_contents($this->filename); } }如果攻击者传入O:10:"FileReader":1:{s:8:"filename";s:11:"/etc/passwd";},反序列化后对象销毁时会读取/etc/passwd并输出。这就是一个最简单的任意文件读取漏洞。
6.3 pikachu 靶场反序列化漏洞实战
pikachu反序列化漏洞是很多安全学习者入门的靶场。它的场景通常是这样的:页面接收一个序列化字符串参数,直接unserialize()后输出某些内容。你需要构造一个恶意序列化字符串,利用目标类里的魔术方法读取敏感文件或执行命令。
实战步骤大致是:
- 先看源码,找到接收参数的入口和涉及的类
- 分析类里的魔术方法,找到可利用的"跳板"
- 构造序列化字符串,注意字符串长度必须精确匹配
- 提交 payload,观察返回结果
这里最容易出错的就是字符串长度计算。PHP 对长度校验很严格,多一个字节少一个字节都会失败。我的习惯是写个小脚本自动生成 payload,避免手算出错。
实操心得:PHP 7.0 之后,
unserialize()对不完整的数据会返回 false 而不是报错,这给调试带来困难。建议在本地搭环境,用var_dump()打印中间结果,逐步定位问题。
7. 反序列化攻击:为什么它是最危险的漏洞类型之一
7.1 反序列化攻击的本质
反序列化攻击的本质是:反序列化过程会执行代码,而攻击者可以控制反序列化的数据。当这两个条件同时满足时,攻击者就能通过精心构造的数据,让目标系统执行任意代码。
这跟 SQL 注入有点像——都是"数据被当成代码执行"。区别在于,SQL 注入注入的是 SQL 语句,反序列化攻击注入的是对象结构。而且反序列化攻击往往更隐蔽,因为它不依赖特定的输入点,任何反序列化不可信数据的地方都可能中招。
7.2 Java 反序列化攻击链
java反序列化攻击的经典模式是"gadget chain"(利用链)。攻击者不会直接注入一个能执行命令的类(因为目标系统不一定有),而是找一条链:从反序列化入口开始,经过一系列已有类的方法调用,最终到达能执行命令的"终点"。
Commons Collections 是最著名的利用链。它利用InvokerTransformer等类,通过反射机制最终调用Runtime.exec()。整个链条可能涉及十几个类的相互调用,构造起来很复杂,但一旦成功,危害极大。
jdbc反序列化下载文件是另一类攻击场景:某些 JDBC 驱动在反序列化时会触发 JNDI 查询,攻击者可以控制查询地址,让目标系统从远程加载恶意类。这类漏洞在 Fastjson 上出现过多次。
7.3 防御反序列化攻击的四层策略
第一层:不反序列化不可信数据。这是最根本的。如果业务上不需要反序列化外部数据,就别开这个口子。很多漏洞的根源就是开发者图方便,直接unserialize($_GET['data'])。
第二层:白名单校验。如果必须反序列化,就限制允许的类。Java 里可以用ObjectInputFilter,Fastjson 里可以配置白名单。只允许业务需要的类被反序列化,其他一律拒绝。
第三层:用安全的序列化格式。JSON 天然比 Java 原生序列化安全,因为它不会自动调用对象的构造方法或魔术方法。但要注意,某些 JSON 库的autoType特性会重新引入风险。
第四层:运行时防护。部署 RASP(运行时应用自我保护)工具,监控反序列化行为,发现异常调用链时阻断。这层是兜底,不能替代前三层。
注意:反序列化攻击的防御没有"银弹"。我见过很多团队只做了白名单,结果因为白名单配置不当被绕过。多层防御、持续更新才是正道。
8. 常见问题速查与避坑清单
8.1 序列化相关问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| InvalidClassException | serialVersionUID 不匹配 | 检查类是否显式声明 UID,是否被修改 |
| ClassCastException | 反序列化类型与预期不符 | 检查泛型是否正确,是否用了 TypeReference |
| StackOverflowError | 对象循环引用 | 检查双向关联,用 @JsonIgnore 打断 |
| 反序列化后字段为 null | 字段被 transient 修饰 | 检查 transient 使用是否合理 |
| 中文乱码 | 字符编码不一致 | 统一用 UTF-8,检查序列化长度计算 |
| Redis 数据读不出 | 序列化器变更 | 检查读写序列化器是否一致 |
8.2 我踩过的三个印象最深的坑
第一个坑:Session 共享时用了 JDK 序列化。当时两个服务用同一个 Redis 存 Session,一个服务升级了类结构,另一个没升级,结果用户登录状态随机失效。后来统一改成 JSON 序列化,问题消失。
第二个坑:Fastjson 的 autoType 没关。一个内部接口接收 JSON 参数,用了 Fastjson 默认配置。安全扫描报了个高危漏洞,我们才发现 autoType 是开着的。虽然没被实际攻击,但想想后怕。
第三个坑:PHP 序列化长度算错。做 CTF 题的时候,构造的 payload 一直不成功,排查了两小时才发现是中文字符长度按字符算而不是按字节算。这个坑在php序列化中文场景下特别常见。
8.3 给不同角色的建议
如果你是业务开发:记住三条——新代码不用 Java 原生序列化、Redis 用 JSON 序列化、反序列化外部数据前先想清楚是否必要。
如果你是架构师:在技术选型时把序列化方案纳入评审,明确跨服务通信的序列化协议,制定序列化安全规范。
如果你是安全工程师:把反序列化点纳入代码审计清单,定期扫描依赖库的反序列化漏洞,推动团队升级有漏洞的组件。
序列化和反序列化这件事,说简单也简单,说复杂也复杂。简单在于 API 调用就那几行,复杂在于背后的兼容性、性能、安全问题能牵扯出一大堆。我个人的体会是:把它当成接口契约来对待,而不是当成一个随手调用的工具方法。每次改动涉及序列化的类结构时,多想一步"老数据还能读出来吗";每次要反序列化外部数据时,多问一句"这数据可信吗"。这两句话,能帮你避开大部分坑。