
做了这么多年Java安全反序列化这块确实是绕不过去的坎。很多开发同事一开始觉得无所谓“我的接口只接收JSON不走原生序列化”“内网系统外网打不进来”。但真正经历过一次RCE级利用之后才会明白这类漏洞的危险程度完全不亚于那些被新闻反复报道的高危漏洞。这个系列的第二篇就把我个人在项目里踩过的坑、复盘过的利用链、以及最终落地有效的防御手段一起聊透重点关注远程代码执行RCE的利用思路和对应的实战加固方案。适合三类人认真读负责Java应用安全的工程师想知道“为什么一个readObject就能丢一台机器”后端开发和架构师需要搞清楚在代码层到底怎么做才能拦住这类攻击以及做运维和应急响应的朋友理解如何在上线前和攻击发生后快速定位、止损。1. 从原理到危害为什么一次readObject就可能导致主机失守1.1 序列化到底做了什么Java的序列化机制本质上是把内存中的对象变成一段字节流方便存储或者网络传输。反序列化就是从字节流里把对象再还原出来。这个过程对应两个关键方法writeObject负责把对象“拆开”成字节readObject负责把字节“拼回”成对象。听起来很简单。但问题在于“拼回”的过程远比我们想象的要复杂。Java的反序列化并不会只做简单的字段赋值——它在还原对象时会执行目标类里定义的相关方法。也就是说只要攻击者能控制那一段字节流就可以借助目标类自身的方法逻辑来达到一些危险的效果。类比一下。你让快递员把一个打包好的箱子送进家门你以为里面只是一件家具。但快递员进门之后按照箱子上贴的说明书开始在客厅里帮你组装家具。如果说明书被人换过快递员可能拆出来一把刀——问题不在快递员而在于你允许他按照外部提供的说明书执行操作。反序列化就是那个快递员字节流就是说明书而readObject就是那个不加思考执行说明书的动作。1.2 漏洞根源readObject内部藏着危险逻辑ObjectInputStream.readObject()从流中读出类名然后加载类、创建实例、递归还原字段。在这一长串动作里某些类自身的readObject方法里还埋着额外的调用逻辑。举一个非常经典的例子。某个集合类库中Map类型在反序列化时会重新计算每个键值对的哈希值计算过程中就会调用键对象的hashCode()方法。如果键对象的hashCode()方法里被设计成会调用外部可控的方法那么攻击者只需要精心构造一个Map对象让它被反序列化恶意代码就会沿着这条调用链执行下来。这就是反序列化漏洞的核心不是某个方法写错了而是多个类的方法叠加之后形成了一条可以被外部数据牵引到危险方法的执行路径。攻击者输入一份序列化数据目标系统却在执行攻击者想要的逻辑命令。1.3 危害边界RCE意味着什么反序列化漏洞的危害是可以分级的。最轻微的情况是应用崩溃、拒绝服务中等危害是可以越过权限检查读取数据最严重的就是RCE——远程代码执行。一旦达到RCE级别攻击者就能在目标机器上执行系统命令。这意味着什么读文件、写文件、反弹Shell、在服务器上留下后门、横向移动……它和常见的SQL注入、命令注入不同利用反序列化漏洞攻击时数据流是“干干净净”的——就是一份看起来像普通业务对象的字节流却暗藏着系统性风险。这也是为什么我认为RCE场景下的反序列化问题应该排在Java应用安全清单里相当靠前的位置。别人拿到的是你一个接口的权限而反序列化一旦被利用拿到的是整个Java进程的权限。如果应用还以高权限运行那和直接拿到服务器几乎没区别。2. 利用链解剖Gadget链是怎么把普通方法串成攻击武器的2.1 先理解Gadget链许多人对反序列化利用有一个误解以为攻击者需要自己去写代码把恶意类塞进序列化数据里让目标加载。实际上并非完全如此。更多时候攻击者利用的是目标系统里已经存在的类和方法——只要这些类的调用序列可以被外部数据触发就能构造出所谓“Gadget链”。Gadget这个词可以理解为“零件”或者“小工具”。单独看任何一个类它的任何方法都是合法的、正常的。但把这些类的方法按照特定顺序拼起来就能实现远程代码执行。就像日常生活中的很多东西单独看都是普通物品但被组合起来就可能产生意外的风险。这也是为什么反序列化漏洞的修复往往让人头疼应用本身一行代码都没写错问题出在依赖的第三方类库组合上。2.2 一条经典RCE链路的逻辑拆解为了让不太熟悉这块的读者有一个直观理解我用一个简化的调用链来展示RCE是怎么一步步串起来的。不涉及具体工具参数只讲逻辑结构。假设目标应用引入了一个使用广泛的集合类库其中有一个工具类它的某个方法支持通过反射调用任意Java方法。我们的简化链路大概长这样// 伪代码仅展示调用链路的逻辑结构 public class 入口类 implements Serializable { private void readObject(ObjectInputStream in) { in.defaultReadObject(); // 反序列化过程中会自动触发某个Map相关的计算 集合类.get(任意键); } } public class 集合类 { public Object get(Object key) { // 计算key的哈希触发key.hashCode() return entries[key.hashCode()]; } } public class 攻击载荷类 { private String 命令; public int hashCode() { // 这里实际会调用危险工具类的transform方法 return 工具类.transform(命令).hashCode(); } } public class 工具类 { public static Object transform(Object input) { // 通过反射执行任意方法比如Runtime.exec() return 反射执行(input); } }实际链路的细节比这个复杂许多中间可能还会用到动态代理、异常处理、类加载器等手段来做转折和增强。但你抓住主干就行入口方法在反序列化时调用了某个集合操作集合操作触发了自定义对象的hashCodehashCode里又通过反射机制走向了命令执行。它最巧妙的地方在于攻击者只需要构造一整套嵌套的对象结构而目标系统上的JDK和依赖库本身就提供了后续所有节点。整个利用过程不需要在目标机器上新建文件也不需要上传额外的类因此很多传统的文件监控方案对它无效。2.3 为什么简单黑名单拦不住很多团队第一反应是既然攻击者会用某些危险类那我在反序列化时检查一下类名把疑似恶意的类拉黑不就行了思路是对的但实际操作中会有几个问题。第一Java类库的生态太庞大了。同一类功能可能在不同库里有不同实现类名变体、别名、代理类、内部类都可能是替代品。你封了一个名攻击者换一个库、换一条链照样能打通。第二很多Gadget链根本不需要一个明显“恶意”的类名作为起点。入口类、中间类都是正常的业务类或工具类到执行命令的最后一步之前整条链上的类看起来都人畜无害。你没法把这些类全部拦掉。第三黑名单的维护速度通常跟不上新链的发现速度。今天你拦住的只是目前已知的某一条路明天别人发一条新链你的黑名单就失效了。所以我不建议把黑名单当成主要防御手段。它可以作为辅助过滤但绝不能是唯一防线。3. 防御实战五个能立刻落地的加固动作3.1 最彻底的防御关掉不需要的反序列化入口这个优先级最高也最容易被忽略。很多时候应用跑得好好的根本没有必要接收Java原生的序列化字节流。接口设计初期用了某种RPC框架默认走的就是Java序列化或者会话信息放在了分布式缓存里为了省事直接把对象塞进去了。这些入口一旦暴露就相当于把大门钥匙挂在了门口。我接手过的一个业务系统就是典型例子。表面上对外提供的是HTTP JSON接口团队也觉得“我们不走原生序列化”。结果排查时发现系统内部用的消息队列客户端在消费消息时走的正是原生Java反序列化。队列的访问控制又不够严格攻击者只要能把消息投递进去就能触发RCE链路。防御动作有三个层次替换协议能用JSON、Protobuf等跨语言、结构化的格式就不要用Java原生序列化。封闭内网入口无法替换的模块至少把端口、队列、RPC服务限制在可信网络范围内同时做好账号粒度的访问控制。过时接口清理把没人维护的老接口、兼容接口梳理一遍不需要的直接下线。很多团队总想着在漏洞层面做文章但实际上从架构上减少使用面的收益要高得多。3.2 核心手段反序列化白名单校验如果确实有场景必须使用Java反序列化那白名单校验是我强烈推荐的做法。它的逻辑很简单反序列化时检查目标类不在白名单列表里的类一律不允许加载并还原。实现方式是在自定义的ObjectInputStream中重写resolveClass方法public class SafeObjectInputStream extends ObjectInputStream { private static final SetString ALLOWED_CLASSES new HashSet(Arrays.asList( com.example.model.UserInfo, com.example.model.OrderVO, java.util.ArrayList, java.util.HashMap )); public SafeObjectInputStream(InputStream in) throws IOException { super(in); } Override protected Class? resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className desc.getName(); // 基础类型、数组、以及业务白名单内的类才允许加载 if (!ALLOWED_CLASSES.contains(className) !className.startsWith(java.lang.) !className.startsWith(java.util.)) { throw new InvalidClassException(Unexpected class deserialized: className); } return super.resolveClass(desc); } }这里有个细节白名单范围一定要收敛。java.lang和java.util看起来无害但某些利用链也会用这些基础库里的类做跳板。白名单的精髓是“只放行你真的用到的那几个类”越多越不安全。另外白名单上线前一定要做好压测。有些业务场景里反序列化的类数量比预期多很多如果漏了几个合法类没加进去上线后就会出现大面积报错。建议先以日志模式观察一段时间确认没有合法流量被拦截后再切换为强制拦截模式。3.3 组件级拦截利用过滤器限制反序列化行为从JDK 9开始官方提供了一套全局过滤器机制可以按类名模式、数组长度、流深度、引用数等维度对反序列化过程进行限制。这算是在代码改不动的情况下一个性价比比较高的兜底方案。ObjectInputFilter filter ObjectInputFilter.Config.createFilter( maxdepth10;maxrefs1000;maxbytes1024*1024; com.example.model.*;java.util.*;java.lang.*; ! ); ObjectInputStream ois new ObjectInputStream(inputStream); ois.setObjectInputFilter(filter);需要注意的几点maxdepth建议根据实际业务对象嵌套层级来定设得太小会误伤正常数据。maxrefs要防止攻击者利用大量对象引用消耗内存。maxbytes是针对单个反序列化流的大小限制不是整个请求体的大小。过滤器是“先黑后白”的逻辑顺序很重要。我习惯把允许的包名写前面结尾加一个!表示拒绝其他所有。这个方案更适合做纵深防御里的一个层不建议单独依赖它。毕竟它拦得住已知模式的类名拦不住一个完全未知的合法类被拿来拼新链。3.4 依赖治理从源头掐断危险Gadget前面提到反序列化漏洞很多时候不是业务代码的锅而是依赖库的问题。所以依赖治理在整个防御体系里的位置非常靠前。我建议每个Java项目建立这样的机制依赖清单盘点定期生成依赖清单重点关注那些曾经出现过公开利用链的组件例如操作集合的工具库、日志处理库、序列化框架等。版本升级策略不要求“最新”但至少要保证已经修复已知高危漏洞的版本。很多老版本和修复版本之间的API差异不大升级成本远比应急响应低。无用依赖裁剪一个项目里经常出现十几二十个间接依赖其中很多根本用不上。每裁剪一个无用包攻击面就小一圈。构建阶段自动检查在CI流水线里加入依赖安全扫描步骤一旦引入的版本触发已知高危漏洞直接构建失败。依赖治理做得好很多时候漏洞根本就不存在了。我就见过一个团队花了三天时间排查修复一个反序列化问题最后发现那个出问题的库在整个项目里只有一个地方用到甚至换个实现方案就能完全移除。3.5 运行时监测让攻击行为现形如果攻击还是发生了那我们至少希望在它发挥破坏力之前能发现异常。运行时监测就是干这个的。比较实际的做法有几种在反序列化入口埋点在ObjectInputStream解析核心路径上输出日志记录流的来源IP、目标类名、请求时间。正常情况下这个日志量不会太大但一旦有人扫描探测日志里很快会出现大量陌生类名。关注命令执行的痕迹监控进程的exec调用、动态代理生成、特殊访问行为。比如一台Java应用服务器正常情况下不会频繁执行系统命令出现这类行为就要立刻拉响警报。日志里的特征关键字很多利用链在执行时会在内存或日志里留下特征例如动态代理创建、反射调用异常等。配合统一日志检索平台做好规则告警效果更好。运行时监测不是用来“修复漏洞”的而是用来“发现攻击”的。它解决的是“你已经被人打穿了但还没意识到”的问题。这部分能力和传统的WAF、防火墙不冲突属于应用视角的纵深。4. 对抗演练搭建靶场复现一次完整攻防理论讲再多都不如自己动手做一次。下面分享一套我在内部安全测试时常用的演练思路。这个过程不需要任何外部攻击工具全部在自己可控的测试环境里完成。4.1 靶场搭建思路先准备一台隔离的虚拟机或者容器网络环境最好和办公网、生产网完全隔离。里面跑一个最简单的Java Web应用模拟一个接收对象数据的HTTP接口接口实现里用ObjectInputStream直接解析请求体。我不会在这里放出完整的利用Payload代码因为这部分如果真的需要实践应该有清晰的目的和安全边界。我建议演练时把重点放在观察现象和验证防御效果上。整个靶场的访问链路是这样的攻击机模拟发起请求 ↓ HTTP请求 Java靶场应用存在反序列化入口 ↓ 解析请求体 反序列化模块 ↓ 触发链 命令执行 / 外部域名请求为了观察触发效果可以用一个自己可控的域名配合DNS解析日志服务作为“心跳探针”。如果反序列化链被执行靶场会主动发起一个域名解析请求就能在不直接执行危险命令的前提下确认漏洞存在。4.2 漏洞触发过程的行为观察整个触发过程的观察点我整理成了一张表方便对照排查观察点正常情况下漏洞被触发时接口响应时间毫秒级明显拉长可能数秒应用日志业务正常日志出现异常类加载、转换异常栈系统进程无特殊行为出现新的子进程或网络连接DNS请求只有业务域名出现指向探针域名的异常解析文件系统无新文件可能出现临时文件、脚本第一次做这个演练时我自己也吃了一惊。仅仅是一段看起来像乱码的字节流打进去之后不到两秒DNS日志里就出现了那台靶机的请求记录。如果这段链路的终点不是解析一个域名而是真正执行一条反弹命令那后果完全不同。4.3 防护方案落地后的重新验证在靶场上把防御动作逐个加上去再用同样的探测数据重放观察差别先加白名单。如果探测数据里的目标类不在白名单里接口直接返回异常DNS日志里不再出现新的记录。再加ObjectInputFilter。就算白名单校验被绕过模拟白名单配置错误深度限制和引用数限制也会在流解析阶段直接打断异常日志里能明显看到拦截记录。最后把入口协议从原生序列化改成JSON。彻底消除攻击面再发送相同数据时系统会直接报参数格式错误连反序列化环节都不会进入。这个“加一道防线重放一次”的动作我强烈建议每个团队都做一遍。它不只是验证防御方案有效更重要的是让团队里的每个人亲眼看到攻击链路被一个个环节切断的过程。比任何文档都有说服力。5. 上线前检查清单与踩坑记录5.1 我自己踩过的坑第一坑以为某个接口不接收对象就不会触发反序列化。实际上一个应用里可能存在多个隐藏入口消息队列消费者、分布式缓存客户端、RPC框架的默认调用方式、甚至日志处理框架的某些配置。排查时不能只盯着对外接口要用依赖和调用链的角度全局排查。第二坑过滤器参数设置太宽松等于没设。我第一次配置过滤器时为了怕影响业务maxbytes直接设了100MB类名规则也放得极宽。后来回头看这种配置基本上只拦了个寂寞。防御参数需要在“不误伤业务”和“有效拦截”之间反复测试宁可先收紧再观察也不要一开始就放开。第三坑加白名单时漏了合法类。有个项目上线白名单后用户反馈某些查询功能直接报错一查日志才发现漏掉了一个内部使用的VO类。后来我养成了习惯白名单上线前先用日志模式记录一周实际出现的类再对照这个清单开会确认最后才开启强制模式。5.2 常见问题速查表现象可能原因处置思路反序列化接口偶发超时有扫描器在探测利用链部分链构造耗时较长检查访问日志来源启用过滤器并观察拦截日志应用CPU飙升但业务量正常可能是攻击者反复提交大量反序列化数据抓线程栈定位反序列化热点临时限流升级依赖后出现兼容问题间接依赖版本冲突或API变更使用依赖树分析冲突逐个替换验证白名单拦截了正常业务白名单类列表不完整或过于严格先切日志模式观察再补充合法类日志里出现大量陌生类加载正在被探测或利用立即检查对外暴露的序列化入口尽量下线5.3 三层防御比单点修复更重要反序列化漏洞的防御说穿了就是一句话不要让外部数据轻易驱动目标类的方法逻辑。纵深防御的思路放到这里再合适不过。第一层是架构层尽量减少甚至消除原生反序列化入口第二层是代码层白名单加过滤器拦住绝大多数已知利用链第三层是运行层靠日志和监控保证即使攻击发生也能快速发现、定位。三层各自解决不同的问题缺了哪一层都有风险。单靠白名单拦不住新链单靠监控拦不住破坏单靠架构收敛老接口没清干净仍然是漏洞。防御不是某一个点的完美而是多个层次叠加之后让攻击者无路可走。我自己在项目里的体会是反序列化这类问题发现一次就要把整改做透。它不比一个普通的Bug改一行代码就能收工。它是需要从架构、代码、运维三个视角同时动手的体系性问题。把这篇里的方法落地一遍哪怕只做到一半你面对RCE级反序列化风险时的底气都会完全不同。