免责声明:本文为个人技术学习与工程实践笔记,所涉操作仅应在获得授权的环境中进行。因不当使用造成的后果由使用者自行承担。
在分析 Java 反序列化风险时,serialVersionUID 不一致是绕不开的一个现象:同一个类在不同依赖版本之间存在差异,当序列化流中记录的类描述与接收端实际的类定义不匹配时,反序列化会被 JVM 直接拒绝。
这也就是一个依赖可能有多个版本,而每个版本中的同名类,可能会有变化。serialVersionUID(后面简称suid)就是为了解决这个问题而出现的。这表现在,本地通过cb1.9.4生成的利用链,到服务端cb1.8.3就打不了 - Java反序列化链探测
serialVersionUID(下称 SUID)本身是 Java 提供的一致性校验机制,用于确保序列化流与接收端的类定义在二进制层面兼容。这一点对防御方同样关键:SUID 只解决兼容性问题,不提供任何安全能力,它既不能作为阻断反序列化攻击的依靠,也不能作为判断系统是否受影响的依据。
为什么 SUID 会不一致
JVM 在判断两个类是否相同时,除了比较类型,还会比较 SUID,具体分两种情况:
- 显式声明:类中定义了
private static final long serialVersionUID = 1L;。此时只要该值不变,即使类成员发生变化,反序列化仍会继续(但可能出现字段类型不匹配导致的异常)。 - 隐式计算:类中没有显式声明时,编译器会根据类名、方法名、属性、修饰符等细节,按规范中的算法生成一个 64 位哈希值作为 SUID。这也意味着任何看似无关的成员调整都可能改变该值。
因此,依赖升级、编译方式差异,乃至构建产物不一致,都可能让同一段数据在本地可以正常反序列化,而在目标环境失败。反过来说,一旦在生产日志中看到此类不一致,它往往意味着有一条针对反序列化入口的输入被送进了系统,这一点在后面的检测部分会详细展开。
如何查看与比对 SUID
在应急响应或流量分析场景中,如果捕获到可疑的序列化数据,可以用 SerializationDumper 之类的工具在本地做静态解析。它的输出会逐层列出流中每个类的名称与 SUID,便于与自身的依赖清单逐项比对。
1 | java -jar SerializationDumper-v1.14.jar <样本文件> |

以一组常见的链式结构为例,其 SUID 大致如下:
1 | java.util.PriorityQueue |
这些数值本身只是类定义的指纹信息,其防御价值体现在两点:一是可以据此判断一段捕获到的数据是由哪个版本的类构造的,从而辅助定位攻击面所在的依赖;二是当同类数据反复出现且与业务无关时,可以作为入侵尝试的判定依据。
版本不一致时的异常,是最直接的检测信号
版本不一致时,日志中会出现含义非常明确的异常,形式通常是 local class incompatible: stream classdesc serialVersionUID = X, local class serialVersionUID = Y。这条信息包含两个层面的含义:
- 它是一次攻击特征。正常业务极少向服务端提交与其类定义不一致的序列化数据,出现该异常说明有外部输入进入了反序列化路径,因此它适合作为一条独立的告警规则接入监控。
- 它同时是一次信息泄露。如果该异常连同完整堆栈一并返回给客户端,等于把服务端的依赖版本信息直接交给外部,使其可以据此调整数据构造方式,而这一点本可以避免。
下面两张图来自本地验证环境,分别对应依赖版本一致与依赖版本不一致两种情况下的结果对照。


为什么 SUID 不能当作安全边界
一个常见的误解是:既然版本不一致会导致反序列化失败,那么”环境不匹配”本身就可以起到缓解作用。这个判断并不成立。
SUID 是随类描述一起写在序列化流中的数据,由于参与序列化的类与生成流的工具都在攻击者一侧,缺少一致性的问题在本地即可对齐:只要把参与序列化的类的 SUID 调整为与目标环境一致,重新生成的数据流就能够被目标接受并继续执行。也就是说,SUID 不一致只会暴露版本差异,并不会真正阻断攻击链。在本地验证环境中,将参与序列化的类的 SUID 调整为与目标一致后,同一条链即恢复正常,下图是调整前后的对照记录。


除此之外还有两个容易被忽略的方向:
- 并非所有链都强依赖第三方库的版本匹配。以只依赖 JDK 核心类的链为例(如常被称为 URLDNS 的一类结构),JDK 核心类的 SUID 在不同次要版本间通常保持高度稳定,因此”目标环境依赖版本不一致”并不足以说明风险不存在。
- 这类稳定结构同样会留下痕迹:其效果通常体现为一次 DNS 查询或一次外部连接,因此 DNS 与出网日志是发现此类探测行为的有效位置。
修复与加固
治理思路应放在消除不可信数据的反序列化路径上,而不是依赖版本差异:
- 避免使用 Java 原生序列化。对外接口优先使用 JSON 等数据格式;确需使用时,应启用 JDK 内置的序列化过滤器(JEP 290,可通过
jdk.serialFilter系统属性或ObjectInputFilter配置),以类白名单、数组长度、对象深度与引用数量等维度做限制。上线时可以先用”仅记录不拦截”模式观察一段时间,确认白名单不会影响既有业务后再切换为拦截。 - 收紧反序列化入口。梳理所有
ObjectInputStream、readObject以及”接收字符串后解码再反序列化”的代码位置,禁止不可信数据进入其中;对确实需要反序列化的内部通道,使用独立鉴权与签名。 - 依赖治理。对 commons-beanutils、commons-collections 这类历史悠久、历史上多次成为链式结构起点的组件,逐一核对版本与实际用途,能移除的移除,能替换的替换;无法移除的纳入 SCA 持续跟踪。
- 建立依赖资产清单。要判断某套环境是否存在风险,前提是知道它到底引入了哪些依赖、什么版本,因此清单应覆盖源码依赖、容器镜像内依赖与中间件自带依赖三个来源。
- 信息最小化。生产环境不返回完整异常堆栈与依赖版本,关闭调试错误页与对外暴露的运维端点,避免为探测行为提供免费的反馈。
- 分层控制。应用账户按最小权限运行、限制写入敏感目录与加载动态类的能力;对出网与 DNS 请求做集中采集与管控;在主机与应用层部署 RASP 一类运行时防护作为补充。
排查清单
结合上述分析,可按以下步骤确认系统现状:
- 检索代码中所有反序列化调用点,确认其数据来源,尤其是经由参数、Cookie、缓存或消息队列传入的位置。
- 检查是否配置了
jdk.serialFilter或框架等价的白名单机制;未配置的,先以观察模式接入。 - 核对依赖清单中相关组件的版本与用途,重点确认是否存在仅为满足传递依赖而保留的老旧库。
- 在历史日志中检索
local class incompatible、InvalidClassException等关键字,确认是否已有此类尝试。 - 检查错误页与接口异常返回,确认不会向客户端泄露依赖版本与堆栈。
- 确认 DNS 与出网日志已集中采集,并能与业务基线对比,用于发现探测性流量。