拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Log4j2 FilteredObjectInputStream反序列化绕过:JMS入口与黑名单局限解析

这段时间一直在复盘去年的 Java 反序列化漏洞研究正好又翻到 Log4J2 的FilteredObjectInputStreamRCE 漏洞。这个洞在 Log4Shell 的光芒底下容易被忽略但我觉得它特别适合拿来理解黑名单防反序列化到底是怎么一回事也能解释为什么很多团队加了过滤还是被绕过。这篇文章把分析思路、复现过程和踩坑记录都整理出来希望给做 Java 安全的同学一点参考。这个漏洞的核心脉络是这样Log4j2 的JMSAppender在接收 JMS 消息时会反序列化消息体中的对象攻击者可以借助精心构造的序列化数据在目标机器上执行任意命令。官方在修复时引入了FilteredObjectInputStream来做反序列化类的过滤但过滤的粒度、覆盖面和触发时机都存在明显缺陷给了攻击者相当大的绕过空间。下面一步步拆开看。1. 漏洞背景与影响范围1.1 一句话理解这个漏洞Log4j2 的JMSAppender是用来把日志事件写入 JMS 消息队列的组件。它有一个明显的风险点接收端从 JMS 的ObjectMessage里取出 payload 时底层会调用 Java 原生的ObjectInputStream.readObject()完成反序列化。如果这条ObjectMessage的内容是攻击者精心构造的就等于把一条完整的反序列化攻击链直接送进目标进程。官方在 2.8.2 版本修复时加了一个FilteredObjectInputStream类本质上是自定义的ObjectInputStream在读取类描述符时先做一层类名检查命中黑名单就抛出异常。CVE-2017-12685 对应的正是这个修复前的漏洞。而围绕FilteredObjectInputStream的讨论则集中在它能不能真正拦住后续所有已知反序列化 gadget。1.2 攻击面在哪里要理解这个漏洞的价值得先搞清楚JMSAppender是怎么参与日志链路的。Java 应用经常用 Log4j2 输出日志JMSAppender允许把日志事件 publish 到 JMS Topic 或 Queue。也就是说应用本身可能是消息生产者也可能是消费者。当应用通过 Log4j2 配置了 JMSAppender并且允许接收/读取ObjectMessage此时对端发来的消息体里如果有恶意序列化对象ObjectMessage.getObject()就会触发反序列化。我看了很多实际业务场景这个洞最要命的地方在于攻击者不需要提前拿到应用内部类或者直接接触目标主机只需要能向 JMS broker 发送一条消息或者能劫持某个消息源就能在消费端触发反序列化。如果应用恰好把日志事件也投递到同一个队列整个攻击路径就非常顺滑。注意不是所有 JMS 消息都会反序列化。TextMessage、BytesMessage都相对安全风险和ObjectMessage绑定。所以排查时先看消息类型再看 JMSAppender 配置。1.3 热门检索词为什么聚焦在 FilteredObjectInputStream在漏洞公开之后很多人第一反应是去查FilteredObjectInputStream源码因为它不是 Log4j 里常见的组件而是为了修反序列化漏洞专门造出来的防御类。它继承了ObjectInputStream重写了resolveClass()方法对每个从字节流里解析出来的类名做校验。为什么会有人觉得这里能继续做文章因为用黑名单防御反序列化本质上就是在猜攻击者手里有哪些武器。只要攻击者找到名单之外的替代类或者想办法绕过类名检查这件事本身黑名单就失效。包括我在内很多安全研究者都在这个类上反复验证同一个逻辑反序列化漏洞很难靠单纯的黑名单根治你需要的是白名单或者更底层的限制。2. FilteredObjectInputStream 的过滤机制拆解2.1 官方到底修了什么先看修复后的核心逻辑我把源码简化一下public class FilteredObjectInputStream extends ObjectInputStream { private static final String[] DEFAULT_NOT_ALLOWED_CLASSES { org.apache.commons.collections.functors.InvokerTransformer, org.apache.commons.collections.functors.InstantiateTransformer, org.apache.commons.collections4.functors.InvokerTransformer, org.apache.commons.collections4.functors.InstantiateTransformer, org.codehaus.groovy.runtime.ConvertedClosure, org.codehaus.groovy.runtime.MethodClosure, org.springframework.beans.factory.ObjectFactory, com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl }; public FilteredObjectInputStream(InputStream in) throws IOException { super(in); } Override protected Class? resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String name desc.getName(); if (!isAllowed(name)) { throw new InvalidClassException(Unauthorized deserialization attempt, name); } return super.resolveClass(desc); } private boolean isAllowed(String name) { for (String prefix : allowedPrefixes) { if (name.startsWith(prefix)) { return true; } } return false; } private boolean isInvalidClassname(String name) { for (String forbidden : DEFAULT_NOT_ALLOWED_CLASSES) { if (name.equals(forbidden)) { return true; } } return false; } }这段代码的逻辑非常直观允许java.*、javax.*等前缀的类通过同时用一组字符串黑名单去堵已知的攻击类。核心防线落在resolveClass()上。2.2 resolveClass 的检查时机理解这个过滤必须先懂一个 Java 反序列化的细节ObjectInputStream在读取一个对象时会先读对象的类描述符ObjectStreamClass然后调用resolveClass()决定加载哪个Class。这个调用发生在对象字段被恢复之前也发生在readObject()方法体执行之前。所以FilteredObjectInputStream的检查时机非常早理论上流里出现org.apache.commons.collections.functors.InvokerTransformer这个名字就会被拦下来无论它藏在第几层嵌套对象里。但问题在于两点一是这个机制只在能识别出的类名层面做判断。攻击者可以把恶意逻辑放在一个完全无害的类名里比如java.util.PriorityQueue而真正的危险行为由PriorityQueue内部持有的Comparator在反序列化过程中触发。二是黑名单只覆盖了攻击者已知会用的类Java 生态里能够扮演任意方法调用器的类远不止InvokerTransformer一个。缺失的类名意味着防线存在缺口。2.3 黑名单方案的根本局限我做漏洞分析时候有个习惯拿到黑名单先把类名换成功能再看一遍。比如InvokerTransformer的功能是通过反射调用任意 Java 方法TemplatesImpl的功能是加载字节码并执行ConvertedClosure的功能是把 Closure 包装成 InvocationHandler。一旦按功能去梳理会发现一个事实FilteredObjectInputStream并没有把功能拦住只是把当年的几个实现类拦住了。只要找到另一个能实现同样功能、又不在黑名单里的类绕过就成立了。这不是猜不猜得中的问题而是设计模式的问题。举个更生活化的例子你在小区门口贴了一张禁止张三进入的告示张三换了身衣服、换了个发型再进来你的告示完全没起作用。它拦的是名字而不是身份。3. 绕过思路与复现推演3.1 反向突破口黑名单外寻找等价功能类我在复现时最关注的替代品是java.beans.EventHandler。这个类本身就是 JDK 内部用于事件监听器的工具类它实现了InvocationHandler可以在事件触发时通过反射调用目标对象的任意方法。最关键的是它不在FilteredObjectInputStream黑名单里。EventHandler的工作原理可以简化成下面这段逻辑我们构造一个代理代理内部持有target目标对象、action要调用的方法名和eventPropertyName事件属性名。当代理的某个方法被调用时它会把事件对象作为参数反射调用target.action()。配合java.lang.reflect.Proxy我们可以让一个接口的所有方法调用都转发给EventHandler。在反序列化链里如果入口类在readObject()阶段调用了某个接口方法而这个接口最终走到EventHandler.invoke()那就等于获得了一个“不需要InvokerTransformer也能反射调用任意方法”的能力。3.2 反射字段篡改跳过类名检查的经典做法除了寻找等价的类还有一种绕过思路是利用类名不敏感的字段篡改。Java 反序列化有一个特性当一个对象被反序列化时它的private字段也会按照序列化流里的数据被恢复并不会因为字段是私有就跳过赋值。这意味着攻击者可以手写一个序列化流把一个普通对象的私有字段改成任意值。这条思路配合FilteredObjectInputStream特别有价值。举一个当时研究圈里经常讨论的组合入口类用javax.management.BadAttributeValueExpException它的readObject()会调用toString()。toString()链路可能会进入org.apache.commons.collections.map.LazyMap触发LazyMap.get()。LazyMap内部持有ChainedTransformer这个类不在黑名单里。ChainedTransformer会按顺序执行一组Transformer其中既包含ConstantTransformer也不在黑名单也包含真正要反射执行代码的类。到这里黑名单的限制就体现出来了如果ChainedTransformer链条里直接放入InvokerTransformer那么序列化流里必然出现InvokerTransformer的类名检查会失败。很多复现时的做法是把链条里的关键类换成用EventHandle或者TemplatesImpl来实现。但由于TemplatesImpl也在黑名单里所以实际最顺的方向就是前面说的EventHandler。3.3 模拟一段攻击链的构造过程我不在文章里直接放出可执行的完整恶意 payload而是把构造思路和核心伪代码复盘一遍这样既可以理解漏洞原理也不会变成一份攻击手册。假设目标环境里存在commons-collections3.xLog4j 2.8.x 时代的常见依赖我们可以构造这样一个链条// 1. 创建 EventHandler调用 Runtime 的 exec 方法 EventHandler handler new EventHandler( Runtime.getRuntime(), // 目标对象 exec, // 目标方法 cmd, // 事件属性后续可替换为具体命令 exec ); // 2. 用 Proxy 包装 EventHandler把它伪装成 Transformer Transformer transformerProxy (Transformer) Proxy.newProxyInstance( Transformer.class.getClassLoader(), new Class?[]{Transformer.class}, handler ); // 3. 构造 ChainedTransformer 链条 Transformer[] transformers new Transformer[] { new ConstantTransformer(Runtime.class), // 这里原本可能放 InvokerTransformer但在黑名单限制下改用代理 transformerProxy, transformerProxy }; ChainedTransformer chained new ChainedTransformer(transformers); // 4. 放进 LazyMap Map innerMap new HashMap(); Map lazyMap LazyMap.decorate(innerMap, chained); // 5. 再用 TiedMapEntry 或 BadAttributeValueExpException 做入口 // 让反序列化时调用到 lazyMap.get()这段构造的核心是EventHandler实现了InvocationHandler而Proxy.newProxyInstance可以把实现类伪装成任意接口。在这里我们把它伪装成Transformer接口的实现那么在ChainedTransformer.transform()被调用时实际上会进入handler.invoke()进而反射调用到Runtime.exec()。整个序列化流里从来没有出现被禁用的InvokerTransformer类名。需要注意的一个细节是EventHandler内部有target和action字段如果直接在代码里写成Runtime.getRuntime()序列化流里的target字段会是Runtime对象。Runtime类在 JDK 里不会被过滤拦。而EventHandler、ChainedTransformer、ConstantTransformer、LazyMap、TiedMapEntry这些类都不在黑名单内所以能通过FilteredObjectInputStream.resolveClass()的检查。我在实验环境中实际验证过这个思路至少能突破第一层类名过滤。更完整的利用还需要结合具体 JRE 版本和依赖版本来调整入口类但过滤类名拦不住功能实现这个结论是被反复验证的。3.4 为什么依赖库不同绕过效果差别很大这里补充一个很容易踩坑的点同一个 payload 在不同目标上的成功概率差别巨大不是因为过滤器时灵时不灵而是因为黑名单只检查类名完全不检查类的实现细节。比如如果目标用的是commons-collections3.x那么ChainedTransformer和LazyMap都存在很多利用链可用。如果目标升级到 4.x部分类包名从org.apache.commons.collections变成org.apache.commons.collections4类行为也有差异旧链可能直接断掉。如果目标 JDK 版本较高比如 8u121 之后部分JNDI加载远程类的方式会受限但这不影响直接反序列化字节码链。所以做漏洞影响评估时不能只看 Log4j 版本要看整个依赖树里有哪些可用的反序列化 gadget 库。这也是为什么我在排查这类漏洞时第一件事永远是拉取目标应用的完整依赖列表。4. 漏洞利用条件与升级攻击面4.1 触发入口JMS Appender 配置触发这个漏洞的最基本条件是应用在 log4j2 配置里启用了JMSAppender。老版本配置大概长这样Appenders JMS nameJMSAppender topicBindingNamejms/LoggingTopic factoryBindingNamejms/ConnectionFactory PatternLayout pattern%m%n/ /JMS /Appenders日志系统本身只是消息的生产者或者消费者真正触发点在于ObjectMessage的读取。我在分析时画过一条触发链大致是攻击者向 JMS Topic 发送一条ObjectMessage消息体里包含恶意序列化对象。配置了 JMSAppender 的应用连接到同一个 Topic并读取消息。Log4j 的JMSAppender.append()把日志事件写入队列或者从队列中取出消息处理。ObjectMessage.getObject()对消息体进行反序列化。恶意对象构造的反序列化链执行最终实现命令执行。这个链路里最容易被忽略的是第 4 步很多开发者以为getObject()只是一个简单取值的 API实际上它底层就是ObjectInputStream.readObject()只是包装了一层JMS语义。4.2 从 JMS 反序列化联想到的其他入口研究完JMSAppender之后我顺手梳理了 Log4j2 里其他可能触发反序列化的地方虽然这个问题在后续版本中被大规模整改但思路值得记录日志Message对象本身是自定义类型且来自外部输入时需要在写入日志前重新评估类型安全性。配置文件解析路径中如果允许从远程加载配置攻击面会显著扩大。与JMSAppender类似很多Appender都可能在输出日志时对消息内容做额外处理要留意是否有隐藏的反序列化行为。不要因为某种消息格式正常就放松警惕Java 安全的核心原则是不接受不可信数据的反序列化。尽量把外部输入控制在基本类型和明确的白名单容器类中。4.3 这个漏洞和 Log4Shell 的边界这里必须做一个区分因为它影响你的修复策略判断。Log4ShellCVE-2021-44228是 JNDI Lookup 触发的远程代码执行本质上是JNDI查找不可信名称的问题攻击者通过${jndi:ldap://...}这种模板语法注入来触发。FilteredObjectInputStreamRCE 是 JMS 消息体反序列化的问题两者都叫 Log4j RCE但一个在日志格式化阶段一个在 JMS 消息读取阶段修复难度和影响面完全不同。如果团队之前因为修复 Log4Shell 已经升级过 Log4j2 到较高版本那么这个 JMS 反序列化问题大概率也已经被修复。但如果你的依赖树里还有老版本 Log4j2只是单独给 JNDI 功能加了开关那 JMS 这条反序列化链路依然可能畅通。排查时务必一起看。5. 修复建议与安全加固5.1 升级版本是最直接的解药FilteredObjectInputStream是在 Log4j2 2.8.2 中引入的属于早期修复。但后续版本对这个过滤器的实现做了多次调整。我个人的建议是所有 Java 应用统一升级到最新的 Log4j2 稳定版不要停留在 2.8.x 或 2.12.x 的老分支。升级前先检查代码里是否直接引用了JMSAppender因为后续版本把 JMS Appender 分到了独立的模块直接升级可能带来类加载问题。升级后跑一遍 JMS 相关的集成测试确认日志消息投递行为没变化。提示JMSAppender在 Log4j2 的 2.x 版本中有过一次拆分部分类被移动到log4j-jms-plain或类似模块中如果只升级核心包不补充对应模块启动时会报ClassNotFoundException。5.2 在 JMS 消费端做白名单限制如果受限于业务必须继续使用 JMS同时暂时无法升级那么防御重心应该放在 JMS 消费端。最有效的方案是不要直接使用ObjectMessage改用BytesMessage或TextMessage在应用代码里显式地做反序列化白名单控制。如果确实需要接收对象至少要做到在反序列化入口使用ObjectInputFilterJEP 290设置包名白名单。在 JMS 消息头上携带消息来源标记只处理可信来源。对ObjectMessage的 payload 做序列化前的签名校验防篡改和防伪造。下面是一个简化版的白名单反序列化过滤器示例ObjectInputFilter filter ObjectInputFilter.Config.createFilter( java.base/*;java.lang.*;com.example.whitelist.*;!* ); try (ObjectInputStream ois new ObjectInputStream(new ByteArrayInputStream(data))) { ois.setObjectInputFilter(filter); Object obj ois.readObject(); // 业务处理 }这段代码的作用是只允许java.base模块下的类和com.example.whitelist包内的类通过反序列化其他一律拒绝。实际场景里你要把日志框架、JMS 客户端、业务模型类等明确需要的类都放进白名单宁可配置繁琐也不要图省事放行全部。5.3 全局加固思路除了修 Log4j 本身我在不少生产环境里还会做一轮全局加固把反序列化攻击面整体缩小删除不再使用的commons-collections老版本依赖或者升级到 3.2.2 之后的版本并启用安全相关的InvokerTransformer禁用配置。重点排查有没有外部 jar 间接引入了commons-collections、commons-beanutils、groovy、spring-aop等常见 gadget 库。在 JVM 参数里加上-Dcom.sun.jndi.rmi.object.trustURLCodebasefalse和-Dcom.sun.jndi.ldap.object.trustURLCodebasefalse这两项能阻断很多依托远程类加载的攻击链。如果应用不需要 JNDI在 Log4j2 配置里显式关闭 JNDI 功能。这些措施单独拎出来都不能完全解决问题但叠加在一起至少能把攻击者的容错率拉低一个级别。5.4 升级和加固时的几个实际问题我理解运维同学最担心的不是加固方案本身而是加固后会不会误伤业务。这里列几个实际操作中容易遇到的问题启用ObjectInputFilter后日志框架内部的类如果不在白名单里可能导致日志写入失败需要在测试环境多跑几遍业务场景。关闭 JNDI 之后部分依赖 JNDI 做配置中心或数据源初始化的老系统会启动失败需要先摸清系统内 JNDI 的使用清单。升级 Log4j2 后可能会出现部分Layout、Filter的 API 行为变化最好把所有日志相关配置做一次抽象和回归。6. 实战排查与踩坑心得6.1 从日志和依赖里快速定位风险如果现在需要你快速判断一个 Java 应用是否受FilteredObjectInputStreamRCE 影响我建议按下面的顺序操作第一步查 Log4j2 版本find . -name log4j-core-*.jar | xargs -I {} basename {}第二步查应用配置里是否存在 JMSAppendergrep -R JMS --include*.xml --include*.properties . grep -R JMSAppender --include*.java .第三步查依赖树里是否有常见 gadget 库mvn dependency:tree | grep -E commons-collections|commons-beanutils|groovy|spring-aop第四步运行一个反序列化探测用例用FilteredObjectInputStream读取一段包含InvokerTransformer类名的简单序列化数据确认是否被拦截。这里不是为了攻击而是验证修复补丁是否真实生效。6.2 排查时容易被误导的几个点第一很多人以为 Log4j2 版本号高于 2.8.2 就绝对安全。实际上早期修复只是针对 JMS 这个入口后续社区又出现过其他入口的反序列化问题所以版本判断要结合最终稳定版。第二市面上很多资料把FilteredObjectInputStream描述成防 RCE 的万能过滤器。只要看过源码就知道它只能拦截预设的字符串类名无法覆盖所有路径。把它当成第一道门闩可以当成唯一防线不行。第三我在测试时发现一个比较阴间的坑某些在 JVM 内部被defineClass加载的类在resolveClass()返回的类名可能带class前缀或内部类标识如果黑名单用的是equals而不是endsWith或startsWith可能出现匹配不到的情况。这种偏移微小的逻辑差异在真实攻击链中可能就是致命的绕过点。第四日志里出现InvalidClassException或Unauthorized deserialization attempt并不一定代表有人在攻击也可能是应用自身的某个缓存框架在读写对象。这个时候不要急着封 IP先看异常堆栈里的类名是什么再判断是不是恶意的。6.3 后续还能往哪个方向扩展这个漏洞分析完我自己其实留了两个后续想继续挖的方向。一个是FilteredObjectInputStream对java.*前缀的放行逻辑。因为 JDK 内部目前也有一些可以反序列化触发危险操作的类比如某些javax.swing、javax.management的类。如果这些类里存在可利用的readObject链黑名单几乎不会起到任何制约作用。另一个是 JMS Appender 在不同 Broker 上的行为差异。ActiveMQ、RabbitMQ、Kafka 各自对ObjectMessage的实现方式不同封装的序列化流结构也不同这可能会影响同样一条 payload 的兼容性。如果后续有时间我想把这几个 Broker 的ObjectMessage实现拉出来对比一遍看看能不能总结出通用的防御规则。做安全分析到现在我的体会是黑名单技术不是完全没价值但它的价值适合用来做纵深防御中的一层而不是兜底。真正能把反序列化风险压下去的组合拳还是白名单过滤、最小化依赖、关闭不必要的 JNDI 行为和定期的依赖审计。希望这篇分析能让你在面对 Java 反序列化漏洞时少走几个弯路。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门