ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java反序列化漏洞原理与防御:从readObject到RCE的完整拆解

Java反序列化漏洞原理与防御:从readObject到RCE的完整拆解 先别急着往下看我先问你一句你上次在代码里写ObjectInputStream ois new ObjectInputStream(input); ois.readObject();的时候有没有想过这个“读数据”的动作到底会读出来什么我见过不少刚接触反序列化安全的开发第一反应都是“这不就是把字节流恢复成对象嘛和 JSON.parse 差不多吧”。等自己跑通一次利用链看到弹出的计算器、看到的命令执行回显才意识到问题有多严重。这篇内容不端着讲理论就围绕 readObject() 这条线把它背后真正会发生的事、攻击者怎么利用它、我们又该怎么防一层层拆开说明白。我尽量用做项目、做安全测试时踩过坑的经验来写适合正在写 Java 接口的研发、维护过中间件的运维以及刚开始学安全的测试同学。先把一个观念立住反序列化不是“读取数据”它是在“执行模型”。字节流里藏着的不仅是字段值还有类名、方法调用的触发点。你允许一次 readObject()等于把一台机器的门打开让一段你完全不知道会干什么的逻辑跑起来。1. 先看清楚readObject() 并不只是“读数据”1.1 Java 原生的序列化机制长什么样Java 对象能够被序列化核心靠两件事一个是对象需要实现Serializable接口另一个是使用ObjectOutputStream.writeObject()把一个对象的状态转换成字节流。反过来ObjectInputStream.readObject()从字节流里恢复对象。这个过程看起来是对称的但很多人忽略了一个关键点序列化字节流里不仅保存对象的字段值还保存对象的类描述信息。也就是说当我有一个User对象里面有个name字段为“张三”序列化后那个文件里包含“类名com.example.model.User”以及字段名、字段值。反序列化时JVM 会先读取类描述再尝试加载这个类然后通过反射创建实例、给字段赋值。这段描述看起来没毛病但问题恰恰出在一个细节上真正开发中没人会只序列化简单的 POJO。像HashMap、ArrayList、PriorityQueue这些常用集合类它们全部都实现了Serializable而且里面装的元素也可以是任意可序列化对象。这套组合拳下来反序列化的实际工作量和风险边界早就超出“读几个字段”了。1.2 readObject() 是一个能“搞事情”的钩子先看一个最基本的常识一个类如果不写readObject()反序列化时执行的是默认逻辑但如果类里定义了readObject()方法反序列化时就会调用它。这不是 Java 魔法而是刻意设计成这样的一个扩展点目的原本是让开发者能在恢复对象时做额外校验或状态修复。麻烦的是JDK 自带的很多核心类也重写了readObject()。它们重写的理由都很正当HashMap需要重新计算 hash 桶、PriorityQueue需要恢复堆结构、TreeMap需要重新构建红黑树。可问题来了这些恢复内部结构的过程往往需要调用对象的方法比如hashCode()、equals()、compareTo()。我打个比方你就懂了。你以为你在读一本字典翻到哪一页就记哪一页的内容但实际上的动作是每翻一页字典都会根据你翻到的那一页自动去执行旁边贴着的一张小纸条上的指令。如果一个攻击者能往字节流里塞进精心构造的“小纸条”那么一次再看普通的 readObject()就会沿着类的内部逻辑调用链一路执行到危险操作。2. 把“读数据”变成“跑命令”反序列化攻击的原理拆解2.1 先记住这几个黑话gadget、gadget chain、sink搞反序列化利用绕不开这几个词gadget指某个类里可以被利用的方法片段比如一个transform()、一个setValue()、一个hashCode()它不是专门为了攻击而存在的而是正常业务里就有但被恶意编排后能一步步靠近危险动作。gadget chain利用链把多个 gadget 串起来从readObject()的入口一路调用到最终的危险方法。这个过程很像搭积木每个类的方法都只是做了很小的一步但串着串着就从“读字段”走到了“执行命令”。sink链条的终点通常是类似Runtime.exec()、ProcessBuilder.start()、Method.invoke()这种能够真正执行系统命令或反射调用的方法。理解这几个词之后你再去看网上各种“反序列化漏洞分析”就不会一头雾水了。说的无非就是入口点在哪、中间用哪些类把调用接起来、最后让哪个方法兜底执行命令。2.2 经典 CommonsCollections 链路是怎么串起来的在 Java 反序列化漏洞历史里Apache Commons Collections的出镜率相当高。原因也很简单它是一套用途极广的集合工具库很多 Java Web 项目直接或间接依赖它。这库里又有几个类天生串起来就适合当“积木块”。核心是Transformer接口它有个代表性实现叫InvokerTransformer功能是通过反射调用方法。你可以把它理解成“万能代理”给它一个对象、一个方法名、一组参数它就帮你反射调用。这本身是设计出来的功能合法业务里确实有场景需要动态调方法但攻击者看它眼里的东西就不一样了。另一个关键实现是ChainedTransformer它会把一组Transformer按顺序全部执行一遍前一个的输出作为后一个的输入。当攻击者把手里的 Transformer 串成下面这样的顺序先返回Runtime.class反射调用getRuntime()拿到Runtime实例反射调用exec()方法传入系统命令。这三个Transformer用ChainedTransformer包起来之后只要让链条被触发一次命令就直接执行了。那怎么让它触发这就需要找“入口”。CommonsCollections 系列有多个入口变种比如经典的AnnotationInvocationHandler入口JDK 内部类曾经在readObject()里会遍历 Map 的 entry 并调用setValue()比如TiedMapEntryLazyMap的入口在反序列化时触发hashCode()进而触地 Map 的get()方法比如PriorityQueueTransformingComparator的入口。我用 CC6 这条链给你描述一下完整的调用路径方便你理解为啥一次读操作会变成命令执行HashMap.readObject()恢复键值对时会对每个 key 调hashCode()key 是一个TiedMapEntry对象它的hashCode()会调用内部Map的get()内部的LazyMap在get()找不到缓存时会调factory.transform()factory是一个ChainedTransformer于是上面的三步 Transformer 依次执行最后Runtime.exec()被调用。看到没整条链的每一步都是正常方法调用没有任何一个类叫“黑客工具类”。这就是反序列化利用的可怕之处全是正常组件组合起来却不干正事。2.3 不止 CommonsCollections真实世界里的重灾区有人可能觉得那我不用 commons-collections 就行了没那么简单。这些年 Java 生态里被反序列化问题折腾过的场景相当多Apache Shiro它的“记住我”功能会把用户身份序列化后加密放进 Cookie。如果密钥泄露攻击者就能构造恶意序列化数据服务端反序列化后直接 RCE。这里的问题甚至不需要额外引入什么库因为 Shiro 自身为了“记住我”功能就内置了序列化逻辑。WebLogic它的 T3 协议和 IIOP 协议都涉及反序列化历史上出过大量绕过补丁的案例。有些漏洞利用最关键的点就是“找到新的 gadget 绕过黑名单”。Fastjson虽然它主打 JSON但JSON.parseObject()在开启autoType的情况下可以指定反序列化类的type字段。攻击者利用这个特性配合特定类库里的 getter/setter 逻辑也能走到命令执行。它不完全等同于原生 Java 反序列化但风险一样真实。Spring早期某些版本在处理 HTTP Invoker 等远程调用协议时同样存在反序列化风险。所以你会发现反序列化漏洞从来不只是某个库的 bug而是“入口 可用的 gadget 类”两个条件同时满足时产生的结果。很多系统入口没办法完全消灭那就只能在打底层做拦截和隔离。3. 亲手看一次“爆”出来的过程靶场与本地复现3.1 用 Pikachu 靶场直观感受漏洞现象不少安全教学靶场里都内置了反序列化漏洞模块其中 Pikachu 靶场一个开源的教学环境算很好上手的。它的反序列化模块会提供一个带漏洞的接口你把一个经过 Base64 编码的序列化对象提交给服务端服务端直接拿ObjectInputStream去读它然后把反序列化出来的对象属性展示在页面上。操作方式一般是这样的先用工具比如 ysoserial生成一个执行指定命令的序列化 payload然后用 Base64 编码作为参数提交到靶场接口。如果命令是whoami回到页面上就能看到系统用户名如果是ipconfig或ifconfig就能看到服务器网络信息。我第一次在靶场里跑通时印象最深的就是那种“页面看上去一切正常但我传过去的东西真的让它执行了系统命令”的割裂感。靶场的设计目的就是让你了解漏洞的触发机制和现象我只建议在本地或隔离网络里玩这个环境不要把它部署到公网或者拿它去打别人。3.2 在本地写一个最小实验看 readObject 如何成为入口如果你想自己做一个最小实验不需要跑到靶场里本地写几行代码就够了。思路很简单把利用链生成的.ser文件放在本地然后用一个普通的ObjectInputStream.readObject()去读它。第一步准备 ysoserial 工具生成 payload。比如我这里以CommonsCollections6链为例让它执行 Mac 上弹出计算器的命令java -jar ysoserial-0.0.6-SNAPSHOT-all.jar CommonsCollections6 open /System/Applications/Calculator.app calc.ser第二步写一个看起来毫无攻击性的 Java 程序它就干一件事读取并反序列化这个文件。import java.io.FileInputStream; import java.io.ObjectInputStream; public class ReadObjectDemo { public static void main(String[] args) throws Exception { try (ObjectInputStream ois new ObjectInputStream( new FileInputStream(calc.ser))) { Object obj ois.readObject(); System.out.println(反序列化完成对象类型: obj.getClass()); } } }第三步运行它你就会看到/System/Applications/Calculator.app被启动了。而你的代码里从头到尾没有任何调用Runtime.exec()的地方只是“读了一下文件”。这种体验远比看一百篇分析文章都直观。需要注意这个实验依赖commons-collections3.1 或 3.2.1 版本。如果你的项目已经升级到 3.2.2这条常见的链可能失效因为 3.2.2 对InvokerTransformer等危险类做了修复不再允许它们反序列化。这也是为什么网上同一个 payload 有的环境能打、有的环境打不了的根本原因。3.3 为什么 payload 看起来像“乱码”对反序列化 payload 有印象的人都知道它看起来不是可读的文本而是一串奇怪的二进制。你应该也见过某些 WAF 规则里会出现rO0AB开头的字符串那就是 Java 序列化对象经 Base64 编码后的样子。Java 序列化文件的头部固定是魔法数字AC ED 00 05转成 Base64 以后通常以rO0AB开头。所以安全设备要拦截 Java 反序列化攻击最简单粗暴的方式之一就是在报文里找这段特征。攻击者为了绕过这个特征也会有各种对抗方式比如分段提交、篡改头部、格式变种等等。但从防御角度你至少要知道如果你的接口需要接收这种特征的数据且又直接调用了 readObject()那基本等于把攻击者的路铺好了。4. 防御落地方案五条能直接执行的硬规则4.1 规则一能不用原生反序列化就别用这条规则听起来最简单但做起来最难因为很多历史遗留代码就是靠 Java 序列化在走数据交换。比如早期有些系统会把对象直接写进 Redis、Memcached或者服务之间通信直接走 Java 序列化。我的建议是如果是新项目数据交换格式一律优先走 JSON、Protobuf、Avro 这类结构化格式谁也别折腾 Java 序列化。其中 JSON 方案里还要注意不要轻易开启autoType和enableDefaultTyping否则等于又开了一个类似的洞。如果是老项目面临迁移成本那至少要把有反序列化入口的位置盘点出来单独做过滤和监控不要让它们成为被绕过后的中招点。4.2 规则二必须反序列化时做类白名单过滤现实是有些场景真的绕不开反序列化比如某些中间件协议本身就这么设计的。这时最有效的基础防线就是在resolveClass()阶段检查类名只有出现在白名单里的类才允许被加载。写一个自定义的ObjectInputStream是业内成熟方案业内一般叫 lookahead ObjectInputStreamimport java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectStreamClass; import java.util.Arrays; import java.util.HashSet; import java.util.Set; public class WhitelistObjectInputStream extends ObjectInputStream { private final SetString whitelist new HashSet(Arrays.asList( com.example.model.UserInfo, com.example.model.Product, java.lang.String, java.util.ArrayList, java.util.HashMap, java.util.Date )); public WhitelistObjectInputStream(InputStream in) throws IOException { super(in); } Override protected Class? resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className desc.getName(); if (!whitelist.contains(className)) { throw new InvalidClassException(不在白名单中的类, className); } return super.resolveClass(desc); } }可以看到整个判断逻辑就是一张表的事。但操作上有一个很实际的坑白名单不能随便拍脑袋列。你需要先梳理业务里的序列化对象到底有哪些列全了再固化。我在实际项目中给一个老系统配白名单时就出现过因为漏了某个业务对象类导致线上接口大面积报错的情况。建议先开启一段时间的日志记录模式把真实出现的类名都收集起来再收敛成白名单。4.3 规则三用 JEP 290 / ObjectInputFilter 做底层拦截Java 9 开始官方提供了ObjectInputFilter机制可以直接给 ObjectInputStream 设置过滤规则。Java 8 的高版本也通过 JEP 290 回移提供了支持所以现在大多数线上 JDK 8 都可以直接用。配置方式有两种。一种是在代码里设置ObjectInputStream ois new ObjectInputStream(input); ois.setObjectInputFilter(info - { if (info.serialClass() ! null !info.serialClass().getName().startsWith(com.example.model.)) { return ObjectInputFilter.Status.REJECTED; } return ObjectInputFilter.Status.ALLOWED; });另一种是全局 JVM 参数-Djdk.serialFiltercom.example.model.*;java.lang.*;java.util.*;!*全局参数的意思就是只允许com.example.model.*、java.lang.*、java.util.*这些前缀的类反序列化其他一律拒绝。这个方案的优点是生产环境不用改代码出问题回滚也容易可以把参数先加上观察一段时间。不过要注意一个版本细节jdk.serialFilter是 JDK 8u121 才开始引入的如果你的线上 JDK 比这个还老需要先升级。另外JDK 9 以后filterFactory的实现也有了变化有条件建议直接用较新版本做统一收敛。4.4 规则四从依赖侧排查并升级风险组件反序列化利用的前提之一是“目标环境里有对应的 gadget 类”。所以依赖管理是你最可控的一环做法也直接升级commons-collections到 3.2.2 或 4.4这两个版本对多个存在反序列化隐患的类增加了防御逻辑全面排查项目里是否出现过InvokerTransformer、InstantiateTransformer、TransformingComparator等危险类用 Maven 或 Gradle 的依赖树命令一眼就能看出来mvn dependency:tree | grep commons-collections # 或者 gradle dependencies --configuration compileClasspath | grep commons-collections但这里有个非常隐蔽的坑不少“安全修复”根本轮不到你项目的 pom.xml。我接手过一个系统应用自己的依赖检查过全是安全的但用的中间件包里内置了一个旧版 commons-collections且它暴露的 RMI 接口允许反序列化最终漏洞评估结果还是没救回来。所以在做审计时不光看应用依赖还要把中间件安装目录下的 lib 包也翻一遍。比如 WebLogic 的wlserver目录里就可能有大量存在历史漏洞的类库。4.5 规则五纵深防御别把宝押在一层防线上就算前面的措施都做了还是应该保持“假设会被绕过”的心态来兜底防御。我在安全治理上比较偏好的做法是分层叠加入口层在 HTTP 层拦截包含rO0AB开头即 Base64 后的 Java 序列化特征的请求参数对内部接口、后台接口尤其严格运行时层在应用部署时挂 RASPRuntime Application Self-Protection它能在 JVM 底层检测到例如Runtime.exec()被非预期链路调用的情况并直接阻断这类产品对反序列化链路的检测覆盖率远高于传统 WAF系统层不要用 root 权限运行中间件和 Java 进程单独建低权限账号限制进程能访问的文件目录在安全组和防火墙层面限制服务器主动外联因为反序列化攻击执行命令后常需要“回连”攻击者的机器来上下载工具断了外联能极大提高攻击者拿第二阶段的成本监控层对ObjectInputStream的调用、Runtime.exec的发起者、进程的网络连接记录日志做到出了问题能看清整个链路。这几层单独拆开看都不算难组合在一起之后攻击者要过五关斩六将才能把一次反序列化攻击完整跑通。安全本来就是成本与风险之间的权衡没必要一步到位上超重方案但底线上要有个“即使第一道被绕后面还有关卡”的格局。5. 自查清单与踩坑经验5.1 三分钟快速自查清单我把日常排查反序列化风险的点整理成一张表你在项目上线前或者做代码审计时直接照着过一遍检查项怎么查判断标准是否存在 ObjectInputStream 反序列化入口全局搜索new ObjectInputStream有且能接外部输入立即标记高危入口是否接收用户可控数据查看请求参数、文件上传、消息队列等来源不可信输入直达 readObject 为高危是否启用 JEP 290 过滤查看 JVM 参数或代码里ObjectInputFilter未配置则建议优先补齐依赖里是否有危险 gadget 类mvn dependency:tree搜索 commons-collections 等版本低于安全线则升级中间件自带依赖是否安全查看中间件 lib 目录版本存在旧组件要及时升级或换版是否用 JSON/Protobuf 替代序列化数据交换查看接口协议定义新系统应直接规避原生序列化是否监控反序列化触发点看日志或 RASP 策略无监控意味着盲区注意这只是一份快速自查的清单不是完整安全评估。真正要确认漏洞是否可利用还是要在测试环境执行实际的攻击链路验证。5.2 我在实际项目中踩过的几个坑先说第一个坑升级了 pom但打出来的 jar 里还是旧版依赖。有一次我给一个服务升级 commons-collections明明 pom 里版本号已经改成 3.2.2但用工具扫描最终构建出来的 fat jar 时还是发现旧的类文件。最后查下来是打包插件把某个上层依赖解压后再把旧 jar 塞了回去。这种问题只有以最终产物为准去查不要只看 pom 声明。第二个坑ObjectInputFilter 上线即事故。我在前面讲了白名单过滤的方针但实践中最容易翻车的就是过滤规则配置过严。有一次我把一个老接口加上jdk.serialFilter全局参数白名单只写了模型类结果接口直接大面积报错因为某个服务间调用会在序列化数据里带上自定义异常类型那个类没进白名单所有请求直接凉了。后来学乖了凡是新加过滤策略先在预发环境跑两周记录被拦截的类名再决定是加白名单还是淘汰相关功能。第三个坑漏掉了“二次反序列化”场景。有些系统虽然对外接口是 JSON但内部为了性能可能把某些对象序列化后存进 Redis取出时再反序列化。这类入口隐蔽不做全链路梳理很难发现。我的建议是把“凡是能传入字节流的地方”都当成攻击面而不是只看用户请求直接打到的那个方法。第四个坑过于依赖黑名单。有段时间很多方案热衷于维护一套“危险类黑名单”把已知的利用链类都禁掉。问题是攻击者可以找新的 gadget或者用 JDK 内部类构造新链条黑名单永远慢半拍。相比之下白名单模式天然能拦住未知链条对不在体系内的类一概拒绝体验上虽然前期痛苦但远更可靠。5.3 真遇到攻击时应急可以从哪里入手万一系统还是被打了别慌也别直接重启机器把所有证据丢了。先做几个基础动作从应用日志里找反序列化入口的请求记录尤其是带有异常InvalidClassException、ClassNotFoundException且伴随着异常类名的记录从系统进程看有没有异常的java子进程、反弹 Shell 或者下载器进程查看服务器网络连接找是否有到陌生 IP 的外联关注临时目录里新增的可执行文件、jar 包、脚本文件找到利用链中涉及的类名后反查是哪个接口把这条数据放进来封堵入口。这一套动作做完基本上能把攻击路径摸清楚。核心原则是先切断攻击者的后续动作外联、命令执行再保留现场最后重建环境时带着“为什么会被打穿”的答案去补方案。反序列化这个问题我自己从最开始“居然还有这种事”到后面能在一堆业务代码里快速定位风险入口靠的就是亲手复现、亲手看链、亲手配置防御。说实话等你自己在本地把那条弹计算器的 payload 跑起来之后你再看到代码里的readObject()心里那根弦自然就绷紧了。
返回列表