资讯动态

Java反序列化安全风险与防御:从机制原理到白名单过滤器实战

发布时间:2026/10/22 17:13:21 来源:尧图企业网站定制
前阵子跟一个做中间件的朋友聊起安全评审他提到一个很典型的场景他们团队把一个老系统纳入内网安全扫描结果清单上赫然写着“存在Java反序列化远程代码执行风险”。翻代码一看确实有个接口直接拿着客户端传来的字节流去调readObject()。这种问题在Java技术栈里太常见了——Java原生序列化用起来简单但它的安全模型非常古老一旦出现在不可信边界基本等于给攻击者递了一把钥匙。这篇文章我想把Java序列化从“是什么”讲到“怎么防”先拆解序列化和反序列化的底层机制再聊攻击者那条臭名昭著的行为链是怎么串起来的最后给出我实际用过的几种加固方案包括自定义过滤、JDK官方过滤器、以及架构层面的迁移思路。无论你是后端开发、中间件开发者还是安全工程师这轮内容应该都能用得上。1. 先把机制讲透序列化与反序列化到底干了什么1.1 序列化的定位把内存里的对象变成字节但不只是“变字节”Java序列化的本意很简单让对象可以脱离JVM存活。你把它写入文件它就是一段字节你把它发到另一台机器它也是一段字节。另一个JVM拿到这段字节后可以把对象“恢复”出来。从实现上看Java原生序列化做的事情比表面复杂得多。一个实现了Serializable接口的类不用写任何额外代码JVM就会自动把这几类信息写进字节流类的描述信息类名、serialVersionUID、字段名、字段类型、是否自定义了writeObject/readObject方法。实例字段的值。对象之间的引用关系也就是对象图。比如一个Order对象里挂着ListItem序列化时会把整棵对象树都写进去包括A和B互相引用这种环状结构。这里有个容易被忽略的点Serializable只是个标记接口它没有方法。真正的工作全部由ObjectOutputStream和ObjectInputStream完成。读的时候ObjectInputStream会解析字节流里的类描述用反射去加载类、创建实例、填充字段。另外就是serialVersionUID。它相当于类的“版本指纹”。如果你不显式声明JVM会根据类名、字段、方法等信息自动算一个hash。问题在于类一旦加了字段、删了字段、改了方法签名这个自动算出来的UID就会变。客户端用老版本序列化服务端用新版本反序列化两边UID对不上直接抛InvalidClassException。所以实践上我一般建议所有实现Serializable的类都显式声明serialVersionUID别把版本控制交给JVM的自动计算。这虽然看起来是个“工程规范”但和安全也有关系——攻击者可以通过分析UID变化来猜测服务端的类结构有时候这会是攻击链的第一步。1.2 反序列化的“隐式执行”才是问题核心为什么反序列化比序列化危险得多关键就在readObject()的语义。很多人对反序列化的理解是“把字节流还原成对象”好像它就是个赋值操作。但真正发生的是JVM读取字节流中的类名解析并加载这个类然后创建一个实例。在创建实例的过程中类里一些“特殊方法”会被自动调用包括readObject(ObjectInputStream)自定义反序列化逻辑。readResolve()替换返回对象。readObjectNoData()在字节流里没有当前类数据时被调用。这些方法不是业务代码显式调用的而是反序列化机制自动触发的。这意味着什么攻击者不需要知道你业务里有什么方法他只需要往字节流里塞一个“指定类名”的类描述让readObject()去加载它。只要这个类存在于JVM或加载路径上并且它的某个特殊方法里包含了可被利用的逻辑攻击者就赢了一半。用一个生活类比你收到一个快递拆开箱子时里面每个小盒子都会自动弹开弹开的动作又会触发下一个机关。反序列化就是这样——你不是在“读一封信”你是在“拆一堆带机关的组合包裹”。更要命的是Java里的ObjectInputStream在解析时还会处理动态代理类。代理类在反序列化时可以指定一个InvocationHandler而代理对象的方法调用会被转发给这个handler去执行。这种灵活性是Java反射体系的特点也确实让攻击链的构造变得异常灵活。1.3 高风险场景盘点哪些地方最容易中招我整理了一个高风险场景表基本覆盖了Java生态里最常被扫出反序列化漏洞的入口场景风险来源常见路径RMI远程调用RMI协议的消息体就是Java原生序列化字节流注册中心、远程接口HTTP会话持久化会话对象需要存库或跨进程共享常走原生序列化身份会话、购物车状态消息队列消息体部分中间件直接把Java对象作为消息体序列化队列消费者入口分布式缓存存储复杂对象序列化后写入缓存再从缓存反序列化回业务对象热点数据、临时状态自定义RPC协议团队内部图省事直接ObjectOutputStream收发内网端口服务每个场景的共性都是同一个不可信数据直接进入了readObject()。HTTP会话那个尤其要提因为很多开发者觉得“会话数据是服务端自己写的总能信吧”。但只要攻击者能拿到会话数据的存储位置或者能伪造一个会话序列化字节流同样的风险就存在。所以不能只盯着“来自外网的请求”更要看“反序列化这个动作的输入是否完全可控”。2. 攻击者的利用思路一条真实但危险的“行为链”2.1 打个比方快递炸弹与触发开关安全圈里有个术语叫gadget chain直译是“小工具链”。我理解的攻击过程是这样的攻击者不会真的往你的字节流里塞一个他写的恶意类——因为你的JVM根本不会加载一个不存在的类。他做的是另一件事扫描你的运行环境里已经存在的类找出那些内部逻辑可以被拼装成“自动执行”序列的组件。还是用快递类比。攻击者不需要自己制造炸弹他只需要知道你的仓库里有哪些“自带弹簧的盒子”“带火石的盒子”“里面装了易燃物的小瓶”。他做的只是把这些现成的零件按一定顺序装进一个大快递箱然后寄给你。你收货时拆一层一个零件触发另一个零件最后一环触发了Runtime.exec()这类能执行系统命令的类。这个链条的关键叫“触发点”。在Java反序列化漏洞里最常见的触发点就是readObject()或readResolve()这类隐藏钩子。某处钩子里如果调用了某个可以被篡改属性的方法而那个方法又继续调用另一个对象的危险方法链条就被逐级点燃了。我在正文里不愿意贴完整payload一是安全合规问题二是这种东西看了也没啥帮助。你真正需要理解的是它的组织逻辑而不是复制一串字节流。理解了逻辑防御才有方向。2.2 触发条件拆解三个缺一不可我复盘过很多次漏洞利用场景发现反序列化攻击要成立基本要同时满足三个条件存在反序列化入口应用直接或间接对不可信输入调用了ObjectInputStream.readObject()、readUnshared()等操作。运行环境包含可利用的类JDK本身、某集合工具库、某JSON处理库、某模板引擎只要有一个类的逻辑能被拼进链里就有机会。缺少有效的过滤或白名单机制应用没有对可反序列化的类做限制也没有对字节流深度、引用数量做约束。这三个条件缺一不可。如果入口被封死再好的链子也派不上用场如果环境里没有可利用的类链子就组装不起来如果加了白名单就算链子拼好了第一步就会被拦下。所以很多团队问“要不要把某个依赖去掉”我的回答通常很直接先看你的入口在哪里。依赖瘦身是对的但如果你有个端口直接对外接受反序列化数据去依赖治标不治本。2.3 为什么黑名单式防御永远不够有些人会想既然攻击者用的是某些已知危险类那我写个黑名单把这些类全部拒掉不就行了这个思路的问题在于黑名单是静态的攻击是动态的。Java生态环境里类库巨大一个人畜无害的类换一种组合方式就可能变成攻击链里的一环。今天你拉黑了几个出名类明天攻击者换一条新链路绕过你这些规则。而且Java 8/11/17的JDK自带类库本身就很大总能在里面找到新的“零件”。我的态度很明确反序列化过滤这事默认拒绝一切只放行明确可信的类才谈得上安全。黑名单是应急手段不是长期防线。3. 方案一尽量别让Java原生序列化暴露在边界3.1 数据交换格式怎么选先讲横向对比如果你现在还有机会做技术选型或者老系统改造的窗口比较长我强烈建议你把边界上的Java原生序列化替换成“无执行逻辑”的格式。所谓“无执行逻辑”就是这类格式在被解析时只会产生纯数据对象不会自动触发类方法、类加载器这类动态能力。我一般这样对比格式类型信息跨语言解析时是否自动触发类逻辑适用场景Java原生序列化强直接携带类名差是仅限可信内部链路且有过滤JSON弱字段名值好否绝大多数接口、数据存储XML中好取决于解析器需防外部实体配置、文档类交换结构化二进制协议强通过IDL描述好否高性能、高并发RPC这里不是要把Java原生序列化说得一无是处。它确实快、简单、类型信息完整很适合短距离可信链路。但“可信”这两个字在分布式系统里越来越难保证。一台内网机器被攻破后攻击者横向移动时最喜欢找的就是这种没人设防的“可信链路”。所以我的建议是边界处一律不用原生序列化内部链路能不用也尽量不用实在要用就加过滤。3.2 迁移时不能踩的坑兼容性才是大难题从原生序列化迁移到JSON或结构化二进制协议技术实现本身不难难的是兼容老数据、老客户端。我见过不少团队在这步翻车所以列几个重点区分传输对象和领域对象别把领域对象直接序列化暴露出去。先定义一个独立的DTO层只保留需要传输的字段。这样将来改协议时领域模型不会跟着震动。双通道兼容策略老客户端还在用Java序列化新客户端用新格式新旧并存一段时间。反序列化入口先尝试解析新格式解析失败再落到老逻辑。等老客户端全部升级完把老逻辑删掉。字段版本管理文本格式更容易被人看懂但也容易被忽略版本。我建议在消息体里显式加一个version字段后续加字段时默认值设为老版本避免新老两端出现字段缺失。类型强约束JSON反序列化时很多库会把{ id: 1 }自动转成Integer但目标字段是Long于是产生类型错误。这种问题在迁移期特别常见提前在DTO上用明确的类型注解能省很多排查精力。3.3 传输层安全也要补上不只在序列化层使劲序列化格式换掉之后不要觉得安全就完事了。字节流在网络上传输时仍然可能被窃听、被篡改。所以传输层必须叠加三件套签名对消息体做签名接收方校验签名防止中间人篡改。加密敏感数据一定加密防止被读取。压缩不是安全问题但能减少带宽我在大消息场景里一般会加。这些都是独立于序列化格式的方案叠加起来不会互相冲突。很多团队只盯着“反序列化漏洞”这一个点结果数据被拖走也没发现因为日志里全是正常格式。安全从来不是一个单点而是多层防线。4. 方案二必须用原生序列化时给读入过程装闸机4.1 过滤思路先想清楚你允许哪些类有些场景确实绕不开原生序列化。比如你的老系统维护了五六年客户端遍布各地你要让所有客户端一夜之间全升级成新协议不现实。又或者某个中间件内部就是靠Java序列化传数据的你替换不了。这时候要做的是给ObjectInputStream装一道“安检门”。安检门的核心不是“哪些类危险”而是“哪些类是我们业务真的会反序列化的”。我接手的项目里安全的业务系统通常只会反序列化自己模块的DTO、一些JDK基础类型比如String、ArrayList、HashMap。除此之外任何类都不应该出现在字节流里。如果某个类真的需要被反序列化它应该出现在白名单里并且经过代码评审。4.2 自定义ObjectInputStream在resolveClass那一层拦下来最直接的做法是写一个SafeObjectInputStream重写resolveClass()方法。为什么选这个方法因为ObjectInputStream在解析字节流里的类描述时最先调用的就是resolveClass。在这里做校验可以在类加载之前把它拦住。一个可以落地的示例package com.example.security; import java.io.IOException; import java.io.InputStream; import java.io.InvalidClassException; import java.io.ObjectInputStream; import java.io.ObjectStreamClass; import java.util.List; /** * 白名单过滤的 ObjectInputStream * 只允许指定的基础包和业务DTO包被反序列化 */ public class SafeObjectInputStream extends ObjectInputStream { private static final ListString ALLOWED_PREFIXES List.of( java.lang., java.util., java.time., com.example.dto. ); public SafeObjectInputStream(InputStream in) throws IOException { super(in); } Override protected Class? resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className desc.getName(); boolean allowed ALLOWED_PREFIXES.stream() .anyMatch(className::startsWith); if (!allowed) { throw new InvalidClassException( 反序列化被拒绝类不在白名单中: className); } return super.resolveClass(desc); } }这个类的逻辑不复杂读取字节流里的类名检查它是不是以java.lang.、java.util.等前缀开头不是就抛异常。注意这里用前缀匹配是相对宽松的规则。如果你对安全性要求更严可以改成精确的类名白名单集合private static final SetString ALLOWED_CLASSES Set.of( java.lang.String, java.util.ArrayList, java.util.HashMap, com.example.dto.OrderDTO );两种写法各有取舍。前缀匹配省事但要小心java.lang.reflect这类子包里的类——虽然它们也以java.lang开头但不一定是你想放行的。精确匹配更安全但维护成本高一些每新增一个DTO都要记得加。4.3 使用JDK官方过滤器ObjectInputFilter是更优解自定义resolveClass是“手工挡”而JDK从9开始提供了官方的过滤器机制对应JEP 290。好消息是这个功能被回溯到了8u121也就是说很多存量JDK 8服务也能用。JDK 17又通过JEP 415对一批常用于攻击的内部类加了默认限制明显降低了基础风险。官方过滤器叫ObjectInputFilter可以理解为给反序列化过程加一个全局/流级别的过滤回调。它的能力比单纯拦类名更全面还能限制深度、引用数、数组长度、字节数——这些指标能防住“不加载类但撑爆内存”的拒绝服务攻击。一种配置方式是JVM参数全局生效-Djdk.serialFilterjava.base/*;com.example.dto.*;maxdepth20;maxrefs5000;maxbytes1048576;!*这段pattern的含义是放行java.base模块下所有类放行com.example.dto包下所有类最大深度20层、最大引用5000个、最大字节1MB其他一切类全部拒绝。另一种方式是在代码里编程设置import java.io.ObjectInputFilter; public class SerialFilterInitializer { public static void install() { ObjectInputFilter filter info - { if (info.serialClass() ! null) { String className info.serialClass().getName(); if (className.startsWith(java.lang.) || className.startsWith(java.util.) || className.startsWith(com.example.dto.)) { return ObjectInputFilter.Status.ALLOWED; } return ObjectInputFilter.Status.REJECTED; } // serialClass() 为 null 时代表当前校验的是深度/引用数/字节数等指标 return ObjectInputFilter.Status.UNDECIDED; }; ObjectInputFilter.Config.setSerialFilter(filter); } }注意lambda里的细节先判断serialClass()是不是null。如果不为null说明正在解析一个具体的类这时执行类名白名单如果为null说明是在检查深度或资源指标这种情况下一般返回UNDECIDED让内置规则继续判断。关于maxdepth、maxrefs、maxbytes这几个参数我简单解释一下为什么值得设maxdepth限制对象图的嵌套深度防止攻击者构造超深对象树导致栈溢出。maxrefs限制反序列化过程中可出现的引用数量防止构建大量相互引用对象耗尽JVM。maxbytes限制输入流的字节数防止在入口处就发生超大流量消耗。maxarray如果支持限制数组长度防止new int[Integer.MAX_VALUE]这类瞬间申请巨量内存的玩法。这些参数本质是给反序列化过程设置一个“资源预算”即使攻击类被放行他也造不成实质性破坏因为数据还没解析完就已经超预算了。4.4 实战过滤器规则一份可直接抄的模板如果你用的是JDK 17或更高版本我建议不要只依赖JVM参数而是在代码里统一维护一份过滤器配置方便动态调整。下面是一个偏稳妥的过滤器模板public class SerialFilterFactory { /** * 返回一个通用的全局过滤器。 * 规则放行JDK基础类型 本业务DTO其他全部拒绝。 */ public static ObjectInputFilter createDefaultFilter() { return ObjectInputFilter.Config.createFilter( java.lang.*;java.util.*;java.time.*; com.example.dto.*; maxdepth20;maxrefs5000;maxbytes1048576; !* ); } }createFilter能直接把pattern字符串编译成过滤器对象比自己写lambda省事很多。如果你的业务相对简单这份模板可以直接推到测试环境验证。我特别提醒一个容易踩的坑过滤器pattern的执行顺序是“第一个匹配生效”不是“最后一个匹配生效”。所以一定要把!*放在最后作为兜底拒绝项。否则如果把!*放在前面后面所有放行规则都不会生效。4.5 过滤器设计别只看RCE还得防“内存DoS”很多团队做完类名白名单就觉得万事大吉了这是不完整的。反序列化攻击不只有远程代码执行这一种形态还有一种很常见的后果是内存耗尽。攻击者不需要执行任何命令他只需要构造一个深度巨大、引用巨多的对象图让JVM在反序列化时疯狂申请内存几次请求就能把服务拖垮。这种情况靠类名白名单拦不住因为攻击者可能只用了你的DTO类但把集合嵌套了成千上万层。所以前面说的maxdepth、maxrefs、maxbytes绝对不是可选项而是必选项。我经历过一个线上事故就是有人拿着一个只有几百字节的序列化数据外层是合法DTO内部却挂了极深的ArrayList嵌套直接把老年代撑到触发GC停顿。从那以后我对过滤器的资源限制参数特别敏感。5. 安全运维排查、止血与常态防御5.1 怎么第一时间确认自己是否暴露如果你接手的是一个老系统想知道自己有没有反序列化风险最快的办法是直接搜代码搜ObjectInputStream、readObject()、readUnshared()。搜ObjectOutputStream、writeObject()看看写出的数据流向哪里。搜Serializable的类重点盯那些会在网络传输或存储边界出现的。搜RMI相关配置比如Registry、Remote等。搜索时不仅要看“谁调用了readObject”还要反推输入的字节流是从哪来的。如果来源是用户上传的文件、外部请求体、消息队列的消息体那这个入口就疑似暴露。如果是线上系统我建议在网关或服务入口先做流量观察看看反序列化相关的请求特征。不过别等攻击发生了才做这步平时就要把入口梳理出来。5.2 排查与加固常见问题速查表问题现象可能原因处理方式上线后直接报ClassNotFoundException白名单漏掉了业务依赖的类在本地用同样的过滤器跑一遍回归测试把缺失类加入白名单过滤器设置了但不生效JDK版本不支持或系统属性被覆盖确认JDK 8u121/9检查代码里是否有重复设置反序列化中途卡死、内存飙高缺少深度/字节数限制补充maxdepth、maxrefs、maxbytes老客户端连不上新服务新过滤器把老客户端字节流里的类拦了增加过渡白名单或改为双通道兼容线上只日志报错影响范围难确认缺少反序列化来源信息在ObjectInputStream外层记录来源IP、类名、时间5.3 应急止血五步处理法如果真的收到反序列化相关告警或者安全扫出问题我建议按顺序执行以下动作切流量/断入口第一时间在网络层切断疑似入口的访问别让攻击者继续投递payload。启用全局拒绝用ObjectInputFilter.Config.setSerialFilter或JVM参数先“只允许白名单、默认拒绝”哪怕误伤业务也先保住可用性再逐步恢复。升级依赖与JDK把可被利用的依赖升到已修复版本JDK升到有过滤机制支持的版本。拉取日志核对调用链查反序列化耗时异常、异常堆栈里出现的类名确认攻击到底打没打通以及有没有横向移动痕迹。架构整改把边界上的原生序列化格式替换成安全格式给入口加上签名校验或身份认证。这五步里第一步和第二步是止血第三步到第五步才是根治。别在止血阶段就开始“优化过滤器策略”那是应急结束后做的事。6. 个人经验与几条实在建议6.1 过滤器设计怎么兼顾可用性从一次误伤说起有一回我给某个内部服务配置了白名单过滤器自测时一切正常结果一上线就跑出来一堆InvalidClassException。排查了半天发现是业务代码里一个老接口用了java.util.BitSet而它不在我的白名单里。那东西本身没风险但因为它不在白名单里整个反序列化就断了。这个案例给我的教训是白名单过滤上线前必须做一次覆盖核心链路的全量回归把真实反序列化会用到的类全部跑一遍别只测几个主流程。另外可以在过滤器的拒绝分支里把被拒类名打印成WARN日志上线初期观察一段时间确认没有“误杀”后再收紧日志级别。6.2 别把“内网”当安全区我见过太多团队拿“我们服务只在内网部署”当理由拒绝做反序列化加固。但真实的攻防情况是内网的边界远没有你想的那么结实。一个钓鱼邮件、一个开发机跳板攻击者就能摸进内网然后横向扫描各种RMI端口、消息队列端口、自定义RPC端口反序列化漏洞是他们最喜欢碰的“内网横向移动”入口。所以就算你百分百确定服务不对公网开放也建议加上过滤器。成本很低收益却很高。6.3 最后一条小习惯新接口一律禁止直接收ObjectInputStream我在团队里定过一条编码规范凡是新增接口一律不允许直接接收ObjectInputStream或byte[]形式的Java序列化数据必须使用JSON或结构化二进制协议。这条规范执行几年下来新系统几乎没再出现过反序列化类漏洞。老系统的历史包袱可以慢慢还但新债不能再添——这个原则很朴素却比任何一次“事后加固”都管用。每次看到有人问“为什么我的系统被扫出反序列化漏洞”我心里都在想同一个问题你确定要收一个来路不明的字节流吗如果答案是“我没办法历史包袱太重”那就按第4节、第5节的方法先兜底再逐步往第3节的方向迁移。Java序列化本身不是原罪真正危险的是我们在不该信任数据的地方选择了信任。先把信任边界划清楚安全这件事就成了一半。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑