ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java反序列化漏洞详解:从原理到防御的完整指南

Java反序列化漏洞详解:从原理到防御的完整指南

搞安全的朋友应该都有这个经历:第一次听说“Java反序列化攻击”的时候,满脑子都是“这玩意儿到底怎么就能让服务器被人控制?”字面意思都懂,就是把一串字节流变回对象,可就这么一个基础机制,怎么会变成安全圈绕不开的话题。这篇不是我教你搞破坏,而是把反序列化攻击这件事的前因后果、攻击链怎么串联、防御怎么落地,用大白话讲清楚。不管你是刚入行的开发、写代码顺手做安全自查的工程师,还是被老板要求“看看我们系统有没有这个问题”的运维,读完都能理解这件事的本质,并且知道下次看安全通告时该看哪些重点。

1. 先搞明白一件事:序列化到底在做什么

要理解攻击,先得理解正常功能。很多人一听到“序列化”三个字就觉得是高端知识,其实它干的事特别朴素。

1.1 用“打包快递”理解序列化

想象你要把一个玻璃杯寄给朋友。你不能直接把杯子扔到快递车厢里,而是要用气泡膜裹好、装进纸箱、贴上快递单,这个过程就是“序列化”——把内存里的对象变成可以传输、可以存储的一串字节。朋友收到包裹后拆开纸箱、撕掉气泡膜,把杯子完好地拿出来放回桌面,这个过程就是“反序列化”——把字节流恢复成内存中的对象。

为什么需要这套流程?因为对象在JVM里运行时,是一堆散落在堆内存中的数据加上对应的类定义。你想要把它存进数据库、写进Redis、发到消息队列、通过网络传给另一个服务,都不能直接传“内存地址”,必须要有一个“拍平”的过程,把它变成一串和平台无关的字节。等到了目的地,再把这串字节“还原”出来。

Java里最常用的序列化机制就是java.io.Serializable接口。一个类实现这个接口,就好像在包裹上印了“易碎品”标记,JVM就会允许你用ObjectOutputStream把它写出去,用ObjectInputStream把它读回来。这套机制从JDK 1.1就存在了,至今依然是很多中间件、RPC框架、缓存框架内部通信的底层基础。

1.2 Java里的序列化代码长什么样

光说不练假把式,我直接贴一段最基础的序列化和反序列化代码,你跑一遍就完全明白了。

import java.io.*; public class DemoSerial { public static void main(String[] args) throws Exception { // 序列化:对象 -> 字节数组 ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos); oos.writeObject(new User("张三", 25)); oos.close(); byte[] data = bos.toByteArray(); System.out.println("序列化后的字节长度: " + data.length); // 反序列化:字节数组 -> 对象 ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data)); User u = (User) ois.readObject(); ois.close(); System.out.println("反序列化得到: " + u.name + ", " + u.age); } } class User implements Serializable { private static final long serialVersionUID = 1L; String name; int age; public User(String name, int age) { this.name = name; this.age = age; } }

运行结果就是“序列化后的字节长度: 大概几十个字节,取决于字段”“反序列化得到: 张三, 25”。这里有个细节值得注意:serialVersionUID。这个字段是类的“版本标识”,反序列化时JVM会比对当前类的UID和字节流里记录的UID,不一致就抛InvalidClassException。简单说,改类结构的时候没更新这个值,旧数据就反不回来了。

1.3 反序列化为什么遍地都是

你可能觉得,这个机制看起来挺折腾的,现在不是都流行用JSON通信吗?为什么还要用Java原生序列化?问得好。JSON确实占据了今天大部分Web接口的通信场景,但在某些特定领域,Java原生的序列化依然活得很好。

比如缓存场景:一个复杂的对象结构,用JSON要写一堆嵌套字段,用ObjectOutputStream一句搞定,反序列化回来直接就是目标类型,连类型转换都省了。很多高性能RPC框架内部为了降低调用延迟,也会在心跳检测、注册中心同步、分布式锁等场景里直接使用原生序列化。再比如消息队列,某些中间件的消息体默认就是Java对象序列化后的字节。还有登录态场景:早期的很多系统会把整个用户对象序列化之后做Base64编码,塞进Cookie或者Token里,服务端拿到后直接反序列化,省得每次去查数据库。

这正是问题所在。只要是“把外部输入的字节流变成对象”的操作,就会有一个打开着的窗口。而这个窗口后面,可能就是整个JVM的完整权限。攻击者盯上的,就是这条“输入到对象”的转换路径。

2. 漏洞的根源:不是序列化本身,而是“信任输入”

很多人会有个疑问:序列化不就是一个编码解码的过程吗?代码里编码解码的地方多了,为什么偏偏这里能变成漏洞?核心原因是:反序列化过程里埋着一个Java特有的“钩子”。

2.1 readObject是怎么被改写的

在ObjectInputStream读取字节流并恢复对象的时候,它会检查这个类里有没有一个特殊的方法,叫readObject。如果有,它就反射调用这个方法,让类自己来定义“怎么读”。这个设计的初衷,是让开发者可以对反序列化过程做一些自定义控制。比如某个字段不想通过默认机制恢复,或者恢复时要对数据进行额外校验,就可以重写这个方法。

听起来挺合理的对吧?问题在于,这个钩子就像给了每个类一把“自定义逻辑的钥匙”,而很多类在写自定义逻辑的时候,并没有安全意识。有的类会在readObject里去执行一些计算,有的会去查配置文件,有的会去加载什么资源。一旦这个类被“数据”驱动着执行了这些逻辑,而数据又是攻击者可控的,问题就出现了。

我举个例子,你在某处看到过类似这样的代码(这是反面教材,可别照着写):

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); // 危险:直接把字节流里读出来的“命令”拿去执行 String command = (String) in.readObject(); Runtime.getRuntime().exec(command); }

攻击者只要精心构造一个字节流,让其中的command字段内容是calc.exe、/bin/bash -c '反弹shell...',服务器在反序列化的那一瞬间,命令就执行了。你觉得现实中不会有人写这么蠢的代码?确实很少,但攻击者根本不需要找这么明显的入口,JDK自带的一些类和常见的基础组件库里,就藏着可以利用的类似逻辑。

2.2 为什么校验能力和执行能力不对等

这里还有一层更隐蔽的原因:反序列化攻击之所以有效,是因为“校验能力”和“执行能力”之间存在严重的不对等。

开发者通常只会在反序列化入口处做一层校验,比如检查这个字节流是不是来自合法来源、能不能Base64解码、白名单里有没有这个类。但攻击者构造的是一串精心组织的字节流,可以绕过这些表面检查。而执行的时候,JVM会把类的完整能力都交给字节流里的指令:可以反射调用任意类方法、可以访问文件、可以发起网络连接、甚至可以通过Runtime.exec执行系统命令。

打个比方,这就好比一台自动售货机,本来是按一下按钮,货物掉下来。结果有人往投币口塞了一个精心制作的“硬币”,这个硬币不是用来投币的,而是一个微型遥控器直接连到了售货机的主板上,想让它干什么就干什么。售货机根本不知道这个“硬币”是什么,它只是按流程接收了输入,然后信任了这个输入。反序列化的本质也一样:你对字节流的内容一无所知,却给了它完整的执行权限。

2.3 传入“二进制字节流”意味着什么

重点还在“二进制”这三个字上。如果是JSON、XML这类文本格式,即使攻击者构造了恶意数据,解析器也只能拿到字符串、数字、布尔值这类简单类型,顶多造成逻辑绕过,很难直接变成代码执行。但Java原生序列化的字节流里,可以携带完整“类名 + 字段值 + 类特殊方法行为”的信息。攻击者构造的不是一组数据,而是携带“类身份”的完整指令包。

这就好比对方给你寄了一个快递,快递单上写着“请帮我打开这个盒子”,你打开之后发现里面还有一个盒子,再打开,还有一个……每一层的打开动作都是盒子里某个类自己定义的,最终开到最里面那层,弹出了一个命令执行窗口。这就是利用链能成立的根本原因:你永远不知道你反序列化出来的对象,在自己的readObject或者后续方法里,到底干了什么。

到这里你就明白了,反序列化攻击的本质是“不可信输入被无条件信任,且输入具备完整的类方法触发能力”。所以,序列化机制本身不是病毒,真正的风险是你在代码里无条件接受了外部输入,然后又把它交给了反序列化API。

3. 利用链:攻击者是怎么把“不存在”的代码跑起来的

说了这么多“类可以自定义逻辑”,你可能还有个疑问:攻击者自己写了一个恶意类,难道服务器上会存在这个类吗?不会。攻击者的恶意类根本不在服务器上,可服务器反序列化的时候,必须根据字节流里的类名去加载这个类,加载不到就报ClassNotFoundException。那攻击怎么执行?

这里就引出了整个Java反序列化攻击里最巧妙的概念:利用链,也叫gadget chain。

3.1 为什么需要一条链

攻击者面对的现实是:他不能往目标服务器上传类文件,服务器上只存在目标应用自己和它依赖的各种第三方库里的类。但他可以构造字节流,让服务器在反序列化时实例化一个“服务器上真实存在的类”。这就有意思了:服务器上恰好存在某个类,这个类的方法在被调用时,会顺藤摸瓜地调用另一个类的方法,最终通过一系列看似无害的类方法串联,走到了一个能够执行命令的类上。

这个过程完全不需要攻击者自己的类存在,他只负责提供“导线”,把服务器上已有的类串联起来。就像你在一个仓库里发现了一堆零件,每个零件单独看都是正常设备的一部分,但你把某些零件按特定顺序接在一起,拼出了一个你没见过的机器。

所以利用链的“链”字特别贴切。它不是一秒钟就能完成的事,而是一环扣一环,每一环都是某个类库中真实存在的方法调用。这就是为什么攻击者喜欢找那种“类方法多、调用关系复杂、依赖广泛”的老牌库——零件多,组合方式就多。

3.2 一条链的三个环节

虽然具体的链条千变万化,但几乎所有利用链都可以抽象成三段:

第一段叫“入口类”。这个类就是反序列化时被直接恢复的那个类,它必须implements Serializable,而且它的readObject里调用了某些可进一步控制的方法。字节流的第一个目标,就是让这个类在反序列化时被加载,并且自动执行一段后续代码。

第二段叫“中间跳板”。入口类的readObject可能只调用了一两个方法,但这些方法被调用时,又会调用下一批对象的方法。攻击者需要在这一段里找到若干个“给个输入A,就能调用方法B”的转折点,把调用一步步推进下去。这段的技术含量最高,因为要控制对象之间的引用关系,让每一步调用参数都能精准衔接。

第三段叫“命令执行落地”。链的终点,通常是一个能够执行系统命令、发起网络请求或者读写文件的类。到这里,攻击已经从“反序列化恢复数据”变成了“系统命令执行”。

说起来你可能觉得有点玄乎,实际开发中你不会遇到哪个类能在readObject里直接执行命令。那攻击是怎么落地的?其实靠的是“间接调用”。某类里有个方法,这个方法里调用了Transformer接口的对象;某个Transformer实现又调用了InvokerTransformer;InvokerTransformer在transform方法里通过反射,动态调用任意类的任意方法。反射这个机制不像类型加载那样受限,它允许你运行时决定“我要调用那个类的那个方法”,攻击者就能引导它调用Runtime.getRuntime().exec方法。

3.3 版本依赖为什么如此致命

到这里你可能会问:既然服务器上有那么多类库,随便找几个不就能链起来吗?实际操作远没那么简单。利用链能成功的前提,是链条上每一环的类,它的全限定名、方法签名、字段结构都和攻击者构造的字节流完全一致。哪怕某个库只是从版本1.0升到了1.1,某个方法名改了或者返回值类型变了,这条链就断了。

这也解释了为什么安全团队在排查反序列化漏洞时,最重要的一步是:摸清目标应用到底用了哪些依赖库、具体是什么版本。一个老版本的应用如果用了某个广泛使用的公共组件库,而这个库的某个版本里恰好有一组能满足“入口-跳板-落地”条件的类,这条链就能成立。版本越旧、依赖越杂,链的选择就越多。

所以你以为攻击者在攻击服务器,其实他更像是在做一场精确的考古研究:先探测出目标服务器上有哪些“文物类”可用,然后组装出一条符合这些类实际结构的调用链,最后把它封装进字节流,往反序列化入口一扔。整个过程里,攻击者自己的代码一次都没有上过目标服务器。

4. 实验环境:自己动手复现一次(安全可控)

光讲原理,你肯定觉得还是悬。我建议你亲手搭一个本地实验环境,把整个攻击链路跑一遍。注意,这是在完全隔离的本地环境里做安全演示,目的是搞清楚原理,不是为了攻击任何真实系统。

4.1 准备一个本地靶场

你需要一台装了JDK的机器,最好是JDK 8某个老版本。然后创建一个最简单的Spring Boot项目,或者干脆用纯Java写一个HTTP接口,接口接收POST请求的body,直接调用ObjectInputStream.readObject()把请求体反序列化——这就是一个典型的“接收外部输入并反序列化”的靶子。

代码大概长这样:

@PostMapping("/deser") public String deser(@RequestBody byte[] data) throws Exception { ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data)); Object obj = ois.readObject(); ois.close(); return obj.toString(); }

注意路径里没有加任何过滤。然后你需要一个能生成恶意字节流的客户端。这一步很多人就直接下载一个现成的利用工具了,我个人建议别急着一键生成,先把手工构造理解清楚:用一个本地小Java程序,把“目标类对象”写好,调用ObjectOutputStream序列化出来,再把这个字节流POST到你刚才那个接口上,观察服务端发生了什么。

4.2 白名单校验为什么能挡住

我们在测试时,可以给靶场接口加一层白名单校验。Java的ObjectInputStream允许你重写resolveClass方法,在这个方法里检查类的全限定名,不在白名单里的直接拒绝反序列化,这样攻击者的类即使被字节流引用,也会在加载前被拦截。

private static final Set<String> ALLOWED = new HashSet<>(Arrays.asList( "com.example.model.User", "com.example.model.Order" )); public static Object safeRead(InputStream in) throws Exception { ObjectInputStream ois = new ObjectInputStream(in) { @Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String name = desc.getName(); if (!ALLOWED.contains(name)) { throw new InvalidClassException("Unexpected class: ", name); } return super.resolveClass(desc); } }; return ois.readObject(); }

你会发现,加了白名单之后,之前能正常打通的攻击payload全部失效。这就是为什么很多安全加固方案都强调“白名单而不是黑名单”:黑名单只能挡住已知的坏类,白名单把未知的、不需要的类全部挡在外面。正常的业务反序列化只涉及自己系统的几个DTO,白名单完全够用,攻击面直接缩小到几乎没有。

4.3 从流量角度看攻击特征

在这个靶场实验里,我最推荐的观察方式,不是看控制台输出,而是抓包看流量。用Wireshark或者tcpdump抓本地回环流量,对比一下正常反序列化请求和恶意反序列化请求的字节流特征。你会看到,正常的类名就那么几个,而恶意payload的类名字符串往往又长又怪,频繁出现反射相关的类名。

做几次实验之后,你就对“恶意反序列化流量长什么样”有了直觉。这个直觉比背一百条规则都有用,以后你在排查日志或者抓包时,一眼就能看出哪段流量不对劲。我自己带人做这个实验时,都会要求他们把恶意字节流用十六进制导出来,对着ASCII看看类名区域,印象特别深。

5. 防御实操:别想着“一锤子买卖”

理解了攻击原理之后,防御思路就自然浮现了。很多人以为防御反序列化攻击就是加一个过滤器,实际上真正有效的防护一定是从多个层面同时下手。

5.1 五层防护怎么落

第一层是入口收敛。能不用Java原生反序列化就别用。接口对外传输数据,优先考虑JSON、Protobuf这类格式,它们不接受“类名”作为传输内容,天然不给攻击者提供“类加载”的机会。

第二层是依赖管理。定期扫描项目依赖,关注那些常用基础组件库的安全通告。把老版本库升级到安全版本,很多已知利用链就直接失效了。要特别注意那些间接依赖的传递链,项目里很多你根本没直接用到的库,其实也被带进来了。

第三层是类过滤。对于必须使用原生反序列化的场景,用白名单机制限定允许反序列化的类。这是成本最低、见效最快的一招。

第四层是运行时检测。部署RASP或者类似的能力,在JVM层面监控反序列化过程中是否出现了可疑的类调用、反射调用、命令执行行为。这种方式不需要改动业务代码,属于运行时兜底。

第五层是流量监控。在网关、WAF层面对反序列化流量做特征检测。虽然攻击者可以编码混淆绕过,但对大多数业余攻击者,这层防线已经能拦截掉绝大部分试探。

5.2 代码审计里找反序列化入口的清单

如果你是做安全自查的人,我建议你按下面这份清单,在全项目里搜索反序列化入口,比盲扫更高效:

  • 搜ObjectInputStream:直接看哪里调用了readObject(),这些是核心危险入口。
  • 搜Serializable接口实现类:重点是哪些类不仅实现了序列化,还重写了readObject或readResolve,这一类的自定义逻辑越多越可疑。
  • 搜XMLDecoder、XStream、fastjson等解析类:这些库如果使用默认配置,同样存在反序列化风险。
  • 搜网络输入和反序列化的连接点:比如Controller层直接接收byte[],MQ消费消息直接readObject,这类直接把外部输入交给反序列化的情况优先级最高。

我自己审计过一个模拟项目,发现漏洞往往不是写在显眼的“接收上传文件”处,而是藏在一个看着人畜无害的“缓存工具类”里。那个工具类对外提供了一个getFromCache(String key)方法,内部用Java反序列化从Redis里恢复对象,而Redis里存的对象又是外部接口写入的。一条完整的链路就这样被拼起来了。

5.3 一个实用的“输入白名单”示例

如果你需要在项目里快速落地一层防护,我给你一个基本可以照着用的白名单反序列化工具类。这个类的核心就是重写resolveClass,只允许特定包下的类被恢复。

public class SafeObjectInputStream extends ObjectInputStream { private static final String[] ALLOWED_PREFIX = { "com.yourcompany.model.", "java.lang.", "java.util.", "java.math." }; public SafeObjectInputStream(InputStream in) throws IOException { super(in); } @Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className = desc.getName(); boolean allowed = false; for (String prefix : ALLOWED_PREFIX) { if (className.startsWith(prefix)) { allowed = true; break; } } if (!allowed) { throw new InvalidClassException("Unexpected deserialization class", className); } return super.resolveClass(desc); } }

使用的时候只要把new ObjectInputStream(input)替换成new SafeObjectInputStream(input)就行。注意一点:白名单列表要尽量短,只放业务确实会反序列化的类。放多了等于没放。

6. 常见问题速查表

我整理了实战中反序列化相关最常遇到的问题,做成一张速查表,方便你排查时对照。

问题现象可能原因排查思路
反序列化时报ClassNotFoundException字节流里引用了一个当前环境不存在的类先确认是不是自己的类没在依赖里,再怀疑是不是攻击payload引用了你没装完整的高版本库
自己构造的payload打不通依赖库版本不匹配,链上一环方法签名变了核对每个依赖库的实际版本,锁定精确版本再找对应的链,别猜
加了白名单后正常功能也挂了白名单里漏了业务真正用的类打开反序列化日志,找到被拒绝的类名,补充到白名单里,按需加
同一套代码在一个环境能打,另一个环境不行两个环境的JDK版本、依赖版本不一致用依赖锁定文件统一版本,重新做一次环境比对
日志里看到大量异常堆栈但服务没挂有攻击者在持续探测利用链别慌,先断外网、抓流量、看堆栈是哪个环节断的,再决定升级还是加固

排查时最容易漏掉的地方有两个。一是间接依赖:你项目里没直接用某个库,但某个框架传递依赖把它带进来了,攻击者照样能利用。第二种是自定义类加载器:如果项目里有自定义的类加载逻辑,反序列化的类可能不在常规依赖里,隐蔽性更强。遇到这种场景,白名单机制就更有必要了。

还有个小技巧:给反序列化入口打上详细日志,记录每一次反序列化的类名、调用来源、耗时。平时这个日志可能没什么用,但真出问题的时候,它能帮你快速还原攻击者的试探路径,省下大量排查时间。

我个人在实际排查这类问题的过程中,最深的体会是:反序列化漏洞不是“某个函数写错了”这么简单,它是Java生态设计哲学和真实世界安全需求之间的结构性冲突。你没法指望通过修一个bug来解决它,唯一可靠的策略就是把“反序列化”这件事当成一个高危操作来对待,像处理“执行SQL”“执行系统命令”一样,先做权限评估,再做输入校验,最后再加运行时监控。

最后分享一个实用的小技巧:如果你要快速判断一个类里有没有可能存在可利用的反序列化入口,不用通读全部代码,直接搜这个类有没有重写readObject和readResolve这两个方法,再看这两个方法里有没有调用其他类的方法。如果调用了,就把调用链跟下去。很多情况下,你以为的“底层实现细节”,恰恰就是攻击者眼里最值钱的“链中间层”。这个观察维度,能帮你在代码审计和漏洞研判中节省大量时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进