
Log4J2 从 2.15.0 版本开始引入FilteredObjectInputStream的那段时间应该是做安全的人集体熬夜最多的时候。Log4Shell 的补丁刚发出来大家除了盯着JndiLookup怎么被禁另一个焦点就是这个新出现的反序列化过滤器。它直接重写了java.io.ObjectInputStream.resolveClass用类名白名单的方式去限制能反序列化的对象思路本身不新鲜但放在日志库这种到处接收外部输入的地方就非常有看头了。后来不少研究者也在持续追这个类有没有绕过所以我想把它的前因后果、内部实现和后续影响系统地拆一遍给正在做漏洞分析或想要补强日志组件安全性的朋友一份参考。这篇文章适合三类人读一是负责排查 Log4J2 漏洞的运维和开发需要搞清楚补丁到底补了什么、还有哪些坑二是做安全研究、写扫描规则或做 Java 反序列化分析的人可以看看类名过滤机制在真实组件里的落地形态三是对 Java 反序列化防御思路感兴趣的读者FilteredObjectInputStream算是一个很典型的案例理解了它你对 resolveClass 白名单的优缺点基本就掌握了。1. 从 Log4Shell 到 FilteredObjectInputStream它到底在防什么1.1 Log4Shell 修复中容易被忽略的一条分支CVE-2021-44228 的核心大家都很熟悉了是日志消息里的${jndi:ldap://...}触发了JndiLookup导致攻击者可以从远程加载恶意对象。当时最直接的修复动作是把JndiLookup关掉以及默认设置log4j2.formatMsgNoLookupstrue。但如果只把目光放在 JNDI 上会漏掉另一个同样危险的能力——Log4J2 本身就支持接收和处理 Java 序列化对象而这不一定要经过 JNDI lookup。举个例子log4j-core里有一个SocketServer以及后来的ServerSocket相关封装用途是开启一个 TCP/UDP 端口接收远程传过来的日志事件。早期版本里这些日志事件就是标准的 Java 序列化对象服务端拿到字节流之后直接调用ObjectInputStream.readObject()恢复成Log4jLogEvent。熟悉反序列化利用的朋友到这里应该已经意识到问题了只要这个端口对外开放攻击者就能直接往端口上扔一个恶意序列化流让服务端执行任意对象这跟 JNDI lookup 完全无关。所以 2.15.0 的修复其实分了两条线一条是缓解 JNDI lookup另一条就是给这一整类反序列化入口加上类过滤器。FilteredObjectInputStream就是在第二条线上被设计出来的。1.2 日志组件的反序列化入口不止一处如果你觉得反序列化入口只有 SocketServer那又是一个常见的误判。Log4J2 里至少还有几个地方会碰到 Java 序列化数据ObjectMessage日志对象本身可以被序列化当消息内容是一个ObjectMessage时日志事件在传输和持久化过程中会经历序列化和反序列化。JMS Appender配置了 JMS 作为日志输出时日志对象会被打包发送到消息队列再从队列被消费方接收。部分自定义 Layout 和转换器如果使用插件机制加载了外部配置配置内容里也可能包含需要动态反序列化的对象。这些入口的攻击面大小不同但都有一个共同点它们最终都会走到ObjectInputStream.readObject()而 readObject 对类名基本是“来者不拒”的。要控制风险最直接的办法就是在类解析阶段做手脚这就是FilteredObjectInputStream存在的价值。2. FilteredObjectInputStream 的实现与接入点2.1 resolveClass反序列化过滤的兵家必争之地Java 反序列化时ObjectInputStream会从字节流里读取一个类描述符对象ObjectStreamClass然后调用resolveClass把它转成真实的Class对象。整条反序列化链路的行动顺序大致是先解析类再递归创建对象最后调用readObject或相关回调方法。这意味着resolveClass是一个天然的检查点因为它是所有类进入对象图的必经之路。不管是org.example.Exp还是java.util.HashMap只要出现在序列化流里都会先经过resolveClass。JEP 290 里那个ObjectInputFilter的filterInputArgs机制本质上也是在这一层附近做拦截只是 API 形式不同。FilteredObjectInputStream的做法就是继承ObjectInputStream把resolveClass重写加入白名单校验。校验不通过就抛InvalidClassException并且明确写了Unauthorized deserialization attempt这种提示直接告诉调用方“这个类不允许被反序列化”。2.2 白名单过滤的核心逻辑我要先说明一下这里不是完整官方源码是还原其核心逻辑后的简化版但关键分支和官方版本是保持一致的。public class FilteredObjectInputStream extends ObjectInputStream { private final String[] allowedClasses; public FilteredObjectInputStream(InputStream in, String[] allowedClasses) throws IOException { super(in); this.allowedClasses allowedClasses null ? new String[0] : allowedClasses; } Override protected Class? resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className desc.getName(); for (String allowedClass : allowedClasses) { if (className.startsWith(allowedClass)) { return super.resolveClass(desc); } } throw new InvalidClassException(Unauthorized deserialization attempt, className); } }这段代码的关键点有两个第一个是使用startsWith做前缀匹配。这种方式方便了白名单配置只需要传一个包名或类名前缀就能覆盖一大批类。比如允许org.apache.logging.log4j前缀那么整个 log4j 内部的类都能被反序列化。第二个是只做了一个简单的字符串比较没有考虑继承关系没有考虑代理类也没有处理自定义类加载器的情况。这让它的实现非常轻量但也给后续绕过方向留下了空间。2.3 两条主要接入路径SocketServer 与 ObjectMessageFilteredObjectInputStream在 2.15.0 里主要接入了两处。第一处是SocketServer相关的 TCP/UDP 接收逻辑服务端在读取远程日志数据时用FilteredObjectInputStream替代了普通的ObjectInputStream此时允许列表通常是 log4j 自己的包名前缀。第二处是ObjectMessage在序列化消息时的处理。当一个日志应用通过 Socket 或 JMS 发送对象消息对端在重新构造日志事件时也需要用同样的过滤机制确认消息里的类是不是可信的。这一处的意义在于即使攻击者不能直接连 SocketServer只要能控制日志消息内容里的对象类型过滤器也要在链路上拦住它。接入点越多防御覆盖面就越广但也意味着过滤器的判断逻辑必须足够严格。如果白名单只按包名前缀来那么所有位于该包名下、且有可利用readObject或readResolve方法的类都会成为攻击者的候选目标。3. 看似加锁的类名过滤器为什么会被绕过3.1 前缀白名单本质上是“大号黑名单”这个说法可能有点反直觉但仔细想想就明白了。真正的白名单应该只允许固定的、经过审计的少数类比如只允许org.apache.logging.log4j.core.impl.Log4jLogEvent和java.lang.String等。但实际实现里如果用org.apache.logging.log4j这种包名前缀做匹配那等于在整个包内引入了“免检通道”。Log4J2 核心包非常庞大里面有很多类带了可序列化能力也有不少实现了复杂的readObject、readResolve、toString方法。只要攻击者能找到一个类 A类 A 的名字以白名单前缀开头且类 A 在反序列化过程中会调用成员对象上的危险方法那么攻击者就可以把真正有风险的 payload 塞到这些成员对象里让“合法类”去触发并不合法的逻辑。用生活化的例子来解释白名单本意是只让公司员工进门但你把门禁卡设置成“所有名字以 J 开头的人都能进”结果一个叫 Jack 的快递员也进来了。他不是员工但因为他名字前缀匹配他就拿到了通行权。org.apache.logging.log4j这个前缀就是那个过于宽松的 “J”。3.2 外层类放行、内部类拦截的递归检查有一种常见的疑问是既然 resolveClass 会在反序列化每个类的时候都被调用那么即使外层类通过了白名单它内部嵌套的恶意类不也会被拦截吗这个理解是对的但只对了一半。resolveClass检查确实是递归的所以如果Log4jLogEvent里嵌套了一个com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl那么这个TemplatesImpl的类名不会以org.apache.logging.log4j开头理论上也会被拦截。这里的关键在于攻击者要找的不是“白名单外的类直接出现”而是“白名单内的某个类自己就能当作 gadget 初段来用”。举个例子如果有个 log4j 内部的类它的字段里声明的是一个Object类型并且这个类在readObject方法里对该字段做了toString()或hashCode()调用那么攻击者传一个javax.management.BadAttributeValueExpException作为该字段时检查点就等于被绕过了。因为真正进入字节流并使用白名单名称的类是外层那个 log4j 类而BadAttributeValueExpException是在外层类内部被“实例化”的如果过滤器没有识别到这个内部实例化过程它就不会触发拦截。所以类名过滤不能只考虑“哪个类出现了”还要考虑“类与类之间的关系”。一个对象在反序列化时触发了另一个对象的toString或readObject这两个类在字节流里不一定都要显式出现。3.3 对攻击链的分析需要关注的几个方向如果要做深入分析大概可以从下面几个方向去排查白名单包内有没有实现了Externalizable的类。Externalizable会绕过正常readObject约束直接把类内字段控制权交给readExternal方法如果该方法里有反射或动态加载风险会很高。白名单包内有没有继承自常见 gadget 类、且重写了readResolve的类。readResolve会在对象反序列化完成后被调用返回值可以直接替换当前对象如果返回值可以自定义整个对象图就可能被“替换”成恶意对象。允许列表是否还包含了除 log4j 外的其他依赖包。某些部署环境里管理员为了业务需要可能会手动扩展白名单加入org.apache.commons或com.fasterxml等前缀。这类操作会显著扩大攻击面等于把一个黑名单系统升级成了黑名单拼图。代理类的处理是否到位。ObjectInputStream除了resolveClass还有一个resolveProxyClass方法专门处理动态代理接口。如果攻击者用代理类包装恶意接口而该接口又实现了白名单内的某个接口过滤器对这个路径的检查是否严格也是需要单独测的。我不能在这里给出具体的某个绕过链和 payload因为那对防御者没有直接意义反而可能被滥用。但你可以沿着这几个方向去审计你负责的应用如果你的白名单过于宽松真正需要担心的是“合法前缀 危险行为日志”叠加在一起的组合而不只是某个明晃晃的恶意类。4. 现实场景里的排查与修复4.1 先确认你的应用到底有没有触碰到这个过滤类很多团队在升级到 2.15.0 之后就以为万事大吉了实际上FilteredObjectInputStream只有在特定入口被调用时才会生效。如果你的应用根本没有启用 SocketServer也没有通过 ObjectMessage 传输日志那么这个类对你来说可能只是个“存在但未启用”的防御。换句话说升级到包含过滤类的版本是必要条件但不是充分条件。排查时可以先检查代码和配置项目里有没有SocketServer、JMSAppender、ObjectMessage的使用依赖树里 log4j-core 版本是不是 2.15.0 及以上log4j2.xml里有没有显式声明允许列表。简单的检查命令可以这样mvn dependency:tree | grep log4jgrep -r SocketServer src/如果找到了SocketServer再看它有没有指定FilteredObjectInputStream或者对应版本号是否已经包含该类。如果只有 2.14.x 及更早版本那么即使代码里写的再好看实际执行时用的还是不带过滤的原生ObjectInputStream。4.2 修复版本对照与升级路径Log4J2 的修复版本前后经历了多次调整这里我做一个快速对照版本安全状态说明2.14.x 及更早存在 Log4Shell 及反序列化风险缺少JndiLookup缓解SocketServer 入口无过滤2.15.0首次缓解默认关闭 lookup引入FilteredObjectInputStream但过滤逻辑仍偏宽松2.16.0进一步收紧移除了部分 lookup 支持反序列化面收窄2.17.0补丁修复更多 CVE处理了包括拒绝服务在内的多个问题2.17.1建议的最低安全版本对绝大多数情况是相对可靠的选择如果你的服务还在 2.15.0 或 2.16.0建议至少升到 2.17.1。如果你的环境有特殊限制不能升级至少要确认你没有对外开放反序列化端口并且JndiLookup相关功能已经通过配置关闭。另外需要注意Maven 依赖传递也可能引入老版本 log4j-core所以版本排查不能只看根 pom还要看整个依赖树。4.3 纵深防御别把过滤器当唯一防线写到这里我想强调一个观念任何类名过滤器都会有过时的一天。真正的安全要靠入口收敛 运行时监测 最小权限一起兜底。对于 Log4J2我的建议是这样一套组合拳第一默认关掉用不到的反序列化能力。没有 JMS 需求就别开 JMSAppender没有远程日志接收需求就别监听 Socket。第二如果必须开 SocketServer尽量放在内网并用防火墙限制来源 IP而不是暴露到公网。第三升级 Log4J2 到 2.17.1 以上同时检查其他依赖有没有把 log4j-api 单独升级到很老版本保持 API 和 core 版本一致。第四在 JVM 层面使用-Djava.security.manager如果应用能接受或者配置 JEP 290 的全局过滤器作为兜底。JEP 290 是对ObjectInputStream做全局限制的标准方案虽然它对部分绕过也有自己的问题但总比没有强。第五监控日志和异常信息。FilteredObjectInputStream抛出InvalidClassException: Unauthorized deserialization attempt时一定要重视这往往意味着有探测流量已经打到了你的反序列化入口。我个人的体会是FilteredObjectInputStream这类补丁确实是当时最合理的取舍它在不破坏日志库正常功能的前提下把最明显的反序列化攻击路径堵住了。但它也给我们提了个醒Java 生态里只要还存在 ObjectInputStream 和大量自带动态行为的基础类反序列化加固就是一场持续的攻防对抗不能指望一次补丁解决所有问题。现在再看这个类的代码最该记住的其实不是某个具体逻辑而是它背后那条原则类名过滤的粒度越粗实际的安全收益越有限。要想让日志组件真正抗住反序列化攻击最好的办法还是减少入口、升级版本、配合运行时监控把防线做成一套体系而不是寄托于某一个startsWith。最后分享一个我在项目里积累的小习惯每季度会重新检查一遍日志组件的依赖版本和配置把用不到的消息传输功能全部关掉。以前总觉得“反序列化漏洞离业务很远”但经历过 Log4Shell 时代之后我更愿意把这类检查当作日常巡检的一部分。