ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

深入FilteredObjectInputStream:Log4j2反序列化防线与绕过实战

2026/9/16 6:36:58 拓冰建站 浏览量
深入FilteredObjectInputStream:Log4j2反序列化防线与绕过实战 Log4ShellCVE-2021-44228爆发那会儿几乎所有用 Java 做后端服务的团队都在熬夜升级 Log4j2。当时很多人以为把版本升到 2.15.0 就万事大吉结果紧接着 Log4j2 又爆出了 2.16.0 的 DoS 漏洞、2.17.0 的 JNDI 注入变种。在这些补丁背后有一个类被反复提起却很少有人真正讲透它就是FilteredObjectInputStream。这个类本质上就是 Log4j2 在反序列化入口加的一道安检门专门用来拦截恶意构造的 Java 对象字节流。如果你还在用 Log4j2 做 Socket 日志采集、日志服务器或者任何涉及反序列化的功能那你必须先搞懂这扇安检门是怎么工作的、它有哪些防守盲区、以及在实战里怎么验证它真的起了作用。这篇文章我从漏洞背景讲到类的实现机制再顺着攻击者的视角推演绕过思路最后结合我在生产环境里的排查经验给出可落地的自查和加固方案。不管你是安全工程师还是 Java 开发看完应该都能对自己的 Log4j2 版本心里有数。1. 漏洞背景与 FilteredObjectInputStream 的诞生原因1.1 为什么 Log4j2 会有反序列化攻击面很多人对 Log4j2 的认知只停留在“打日志的库”一听到 Log4j2 反序列化漏洞就觉得不可思议。实际上Log4j2 并不只是一个往控制台和文件里写字的工具。它早期版本里带了一个SocketServer组件这个组件可以启动一个 TCP 或 UDP 端口专门用来接收远程传过来的日志事件对象。接收端拿到的是序列化好的LogEvent对象所以必然涉及一个动作调用ObjectInputStream.readObject()把字节流还原成 Java 对象。这个动作一旦发生问题就大了。readObject()在还原对象的过程中会触发目标类里定义好的readObject()、readResolve()等方法。如果攻击者精心构造了一份字节流让还原过程触发一个危险类里的危险方法就可以在服务器上执行任意命令。这就是经典的 Java 反序列化 RCE 攻击链。Apache Commons Collections、Spring 等库的 gadget 链在当年已经被安全研究者玩出了花Log4j2 的SocketServer等于给攻击者白送了一个入口。我之前帮一个客户排查问题他们的内网服务为了方便日志归集在每台应用服务器上配了SocketAppender把日志实时转发到一台 Log4j2SocketServer上。幸好他们的网络隔离做得比较死攻击者摸不到那个端口不然后果真的不敢想。这里也提醒所有人不要以为内网服务就不用做安全加固内网横向移动的跳板往往就是这么打出来的。1.2 Log4Shell 之后 Apache 的修复思路演变Log4Shell 漏洞的核心是 JNDI Lookup 功能允许从外部 LDAP/RMI 服务拉取恶意对象导致攻击者可以在日志消息里塞一段${jndi:ldap://...}就完成 RCE。Apache 在 2.15.0 里默认禁用了 JNDI 的远程查找2.16.0 里直接移除了 JNDI 支持2.17.0 又针对 2.16.0 的 DoS 问题补了漏。但仅仅堵住 Lookup 还不够。别忘了 Log4j2 自己的SocketServer还在那摆着它照样接收字节流、照样反序列化。于是 Apache 在 Log4j2 2.17.0 版本里正式引入了FilteredObjectInputStream。这个类的目标很明确在反序列化发生之前对即将加载的类做一次过滤。能过安检的才放行进不了白名单的直接拒绝从源头掐断反序列化攻击链的触发条件。所以你要理解一个关键点FilteredObjectInputStream 不是用来防 Log4Shell 那种 JNDI 注入的而是专门防反序列化 RCE 的。两者虽然都叫 RCE攻击路径完全不同。前者靠字符串模板注入后者靠恶意对象字节流。搞混了这个区别你在排查问题时就会走错方向。2. FilteredObjectInputStream 的核心机制拆解2.1 ObjectInputFilter 与 FilteredObjectInputStream 的关系Java 在 9 版本正式引入了java.io.ObjectInputFilter接口这个接口可以在反序列化过程中对类的描述信息做校验。Java 8 则通过 JEP 290 以 backport 的形式把它带到了 8u121 之后的版本里。ObjectInputFilter最常用的作用就是设置一个过滤规则规则可以是允许列表、拒绝列表也可以两者结合。FilteredObjectInputStream是 Log4j2 自己写的一个类它继承了java.io.ObjectInputStream然后在关键的resolveClass()方法上动了手脚。我们来看一下这个类在 Log4j2 源码里的核心实现逻辑不同版本实现略有差异但思路一致public class FilteredObjectInputStream extends ObjectInputStream { private final CollectionString allowedExtraClasses; Override protected Class? resolveClass(final ObjectStreamClass desc) throws IOException, ClassNotFoundException { final String name desc.getName(); // 先查白名单白名单里的类直接放行 if (isAllowed(name)) { return super.resolveClass(desc); } // 不在白名单里尝试用父类加载如果加载到了说明存在风险 try { return Class.forName(name); } catch (final Throwable ex) { throw new InvalidClassException(Class name not allowed); } } }这段代码很多人看过但没看懂。它做的事情简单说就是检查当前要还原的对象的类名如果类名在白名单里就交给父类正常加载如果不在白名单里尝试让 JVM 自己加载这个类加载失败就直接抛异常终止反序列化。但如果类能加载成功呢Log4j2 某些版本里这个逻辑存在一个要命的隐患它没有直接拒绝而是继续走正常反序列化流程了。这就是后来一些绕过研究和补丁反复修改resolveClass实现的原因。2.2 黑名单过滤与白名单过滤的本质区别早期的 Log4j2 版本里FilteredObjectInputStream用的是黑名单思路维护一份“危险类名列表”凡是命中列表的直接拒绝。黑名单的好处是对正常业务零影响因为绝大多数场景下你不会去反序列化org.apache.commons.collections.functors.InvokerTransformer这种类。坏处也显而易见黑名单永远是不完整的。攻击者可以把攻击 gadget 里的类换成不在黑名单里的等价替代品可以通过嵌套数组、内部类等写法绕过简单的字符串匹配甚至可以利用你黑名单之外的第三方库类完成攻击。这就是为什么安全社区一直强调黑名单只能作为临时缓解措施不能作为最终防线。后来 Log4j2 在 2.17.0 之后把核心过滤逻辑切到了白名单模式只有java.util.*、java.lang.*、org.apache.logging.log4j.*等可信包里的类才允许反序列化其他类一律拒绝。这个思路跟应用隔离里“非白即黑”的策略一致。对于正常业务来说日志服务器需要反序列化的类本来就很有限白名单完全够用还能顺带挡掉绝大多数未知 gadget。我自己的经验是如果你要做二次开发把你的自定义事件类加进允许列表一定要确保这个类的反序列化逻辑是安全的不要让它调外部资源也不要让它执行命令。白名单不是免死金牌它只是缩小了攻击面你放进白名单里的每一个类都等于给攻击者开了一扇窗。2.3 FilteredObjectInputStream 不会接管所有反序列化场景这里有个非常容易踩的坑。FilteredObjectInputStream只是SocketServer接收日志事件时使用的一个ObjectInputStream子类它管不到你应用里其他的反序列化操作。你在项目里自己写了个ObjectInputStream.readObject()去读数据Log4j2 的这个过滤类不会自动帮你加防护。我当时审计一个项目时发现开发团队把 Log4j2 升到了 2.17.2觉得安全加固做完了。结果一查代码业务系统里有一个老旧的 Redis 缓存工具类自己实现了ObjectInputStream读取缓存对象完全没有过滤。这种情况就好比给大门换了把锁却忘了侧门还是敞开的。所以看到FilteredObjectInputStream这个类时你要问自己一个问题我的项目里还有哪些地方在直接调readObject()那些地方才是真正需要你重点排查的对象。3. 攻击面分析与绕过思路推演3.1 攻击者视角哪些入口值得打先梳理一下 Log4j2 生态里所有可能触发反序列化的入口。除了前面提到的SocketServer还有几个点容易被忽视。log4j-slf4j-impl、log4j-jul这些桥接模块本身不涉及反序列化但如果你用了log4j-core里和 JMX 相关的配置JmxServer或者JmxConfigurator之类的组件也可能成为攻击目标。再有就是log4j2.xml里的RewritePolicy、自定义Plugin等高级特性如果允许外部传入序列化配置也可能被攻击者利用。最经典的攻击场景是这样的攻击者扫描到了 Log4j2SocketServer监听的端口用 ysoserial 一类的工具生成一个针对目标环境的 gadget 链比如 CommonsCollections6、CommonsCollections7然后通过 TCP 连接把这个序列化字节流发给目标端口。如果 Log4j2 版本小于 2.17.0SocketServer会直接调用ObjectInputStream.readObject()攻击者的 gadget 链就会被触发服务器进程执行攻击者植入的命令。这个场景我在攻防演练里复现过整个过程快得惊人。从端口扫描到命令回显熟练的攻击者只用几分钟。所以千万不要觉得反序列化漏洞是利用门槛很高的东西ysoserial这类工具已经把门槛降得非常低了。3.2 绕过 FilteredObjectInputStream 的几种思路既然FilteredObjectInputStream会检查类名攻击者会想什么办法绕过我梳理了几个典型思路。第一种是类名变形。如果过滤规则只做简单的字符串包含判断攻击者可以用Ljava.lang.Runtime;这种 JVM 描述符形式的类名或者用数组包装的形式[Ljava.lang.Runtime;在某些实现里原始类名提取不完整过滤就失效了。第二种是寻找白名单内的可用 sink。白名单不会把所有类都拒之门外它保留了一些正常的 Java 基础类。攻击者会在白名单允许的java.io、java.util、javax.management等包里面找能够作为 gadget 链组件的类。比如javax.management.BadAttributeValueExpException这个类在历史上被多次用作反序列化攻击的触发点因为它会在 readObject 时调用 toString 方法而 toString 方法可以联动到其他危险操作。如果你在白名单里放了一个这种类那么白名单反而变成了攻击者的帮凶。第三种是绕过过滤器本身。如果 Log4j2 版本较低FilteredObjectInputStream的resolveClass实现存在前面提到的那种“加载成功就放行”的问题攻击者可以让恶意类出现在 classpath 里比如把 gadget 类打进依赖 jar 里。这个前提相对苛刻但并不是不可能。第四种思路是从 JNDI 转向其他入口。Log4j2 2.17.0 之后 JNDI 远程加载被禁了但攻击者可以找其他能触发命令执行的 JDBC 连接、RMI 调用甚至通过TemplatesImpl这种方式把字节码动态加载进来。TemplatesImpl 是反序列化绕不过去的一个经典类它内部保存了 Transformer 的字节码反序列化时可以通过调用 newTransformer 动态生成任意 Java 类字节码。这也是为什么 Fastjson、Jackson 等库都在封禁TemplatesImpl的原因。3.3 为什么白名单模式能挡住绝大部分绕过尝试理解了上面这些绕过思路你就能明白为什么 Log4j2 后来坚持走白名单路线。白名单模式下攻击者能用的类被限制在一个极小的集合里大部分 ysoserial gadget 链的第一步就需要用到org.apache.commons.collections等第三方库的类而这些类根本不在白名单里直接在第一关就被拦下。剩下的少量基础类可以被利用的组合非常有限攻击者想找出一个能打通整条链的可行方案难度大得多。但白名单也不是万能药。java.beans.EventHandler这个类曾经被安全社区讨论过多次它可以在反序列化时通过内部机制动态调用任意方法而且它也属于 JDK 自带类。如果你的白名单配置恰好包含了它它就可能被用作一条“万能跳板”。所以白名单的粒度也很重要不能一眼扫过去都是java.*就以为高枕无忧。提示在做白名单配置时建议先看日志里实际会出现哪些类的反序列化请求只把有业务需求的类加进去。追求“够用”而不是“齐全”是安全配置的一个基本原则。4. 生产环境的自查方法与加固实践4.1 快速判断你的依赖里有没有这块雷区第一步查 Log4j2 版本。用mvn dependency:tree或者gradle dependencies看你项目里实际解析到的log4j-core版本是多少别只看自己声明的版本。我之前踩过坑项目里声明了log4j-core:2.17.2结果一个第三方 SDK 内部传递依赖了一个旧的log4j-core:2.14.1Maven 的依赖仲裁机制让旧版本胜出了。检查命令如下mvn dependency:tree -Dincludesorg.apache.logging.log4jFilteredObjectInputStream在 2.17.0 才引入所以凡是低于这个版本的项目都还暴露在反序列化攻击面之下。2.17.0 到 2.17.1 之间的一些实现也有被讨论过的防守盲区建议直接升到 2.17.2 及以上。第二步排查是否有SocketServer或相关配置。在log4j2.xml里搜Socket、SocketServer等关键字看应用是不是开启了外部日志接收通道。如果确实开了确认端口访问控制策略最好只允许内网日志采集器访问。第三步检查是否有自定义的FilteredObjectInputStream子类或自己实现的反序列化过滤器。很多人会在框架基础上做二次封装如果你自己也改了过滤逻辑务必逐行 review看resolveClass里是否真的拒绝掉了不在白名单里的类。4.2 升级到安全版本的正确姿势升级本身不难难的是升级后别把业务搞挂。Log4j2 的log4j-api和log4j-core必须保持版本一致否则运行时会出现各种诡异问题。通过 Maven 的dependencyManagement统一锁定版本是最省心的方式dependencyManagement dependencies dependency groupIdorg.apache.logging.log4j/groupId artifactIdlog4j-bom/artifactId version2.17.2/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement升级完成后做一次回归验证。重点验证日志异步输出是否正常、线程名和 MDC 信息是否正确传递、以及如果有使用SocketAppender远程日志中心能否正常收到日志。另外跑一下你项目里所有涉及序列化的单元测试确认自定义的LogEvent、Message类能被正常反序列化。我在一次升级中遇到过性能下降的问题后来定位到是日志上下文里某个对象的toString方法变重了跟 Log4j2 本身关系不大。所以升级出了性能问题别第一时间怪框架先看调用链。4.3 如何验证过滤规则真的在生效只看版本号不保险最好在测试环境里做一次攻击模拟。最简单的验证方法从 ysoserial 项目里生成一条针对本机环境的 gadget 链比如CommonsCollections6用 nc 之类的工具把字节流发送到 Log4j2SocketServer监听的端口然后观察目标进程有没有执行命令的痕迹。如果你不方便用 ysoserial也可以用更简单的方式验证过滤逻辑写一个小 Java 程序尝试反序列化一个不在白名单里的类比如org.apache.commons.collections4.bidimap.TreeBidiMap看FilteredObjectInputStream是不是真的抛出了InvalidClassException。以下代码是常见的验证逻辑import org.apache.logging.log4j.core.util.FilteredObjectInputStream; public class FilterCheck { public static void main(String[] args) throws Exception { ObjectStreamClass osc ObjectStreamClass.lookup( Class.forName(org.apache.commons.collections4.bidimap.TreeBidiMap)); javax.swing.table.DefaultTableModel model new javax.swing.table.DefaultTableModel(); java.io.ByteArrayOutputStream bos new java.io.ByteArrayOutputStream(); try (java.io.ObjectOutputStream oos new java.io.ObjectOutputStream(bos)) { oos.writeObject(model); } byte[] payload bos.toByteArray(); try (FilteredObjectInputStream in new FilteredObjectInputStream(new java.io.ByteArrayInputStream(payload))) { in.readObject(); System.out.println(反序列化成功说明过滤规则有问题); } catch (java.io.InvalidClassException e) { System.out.println(已拦截异常信息: e.getMessage()); } } }如果你的测试结果是“已拦截”说明过滤规则在正常工作。如果测试结果是“反序列化成功”那你要检查一下用的FilteredObjectInputStream是不是被 maven shade 插件重写了或者你直接 new 的类并不是 Log4j2 默认使用的那个。4.4 纵深防御一根柱子撑不起安全再强调一下永远不要把希望全都押在一个FilteredObjectInputStream上。安全是分层的事。除了升级版本下面几件事同等重要一是关闭或限制 JNDI Lookup。虽然 2.17.0 之后默认禁用但保险起见在启动参数里显式加上-Dlog4j2.enableJndiLookupfalse和-Dlog4j2.formatMsgNoLookupstrue这样就算配置错误也有兜底。二是网络层做最小化暴露。Log4j2SocketServer所在的端口只允许可信 IP 访问在防火墙/安全组层面做好白名单。不要把一个日志接收端口公开到不可信网络。三是运行进程降权。让 Java 进程以最小权限的专用账号运行不分配 shell 权限不装载不必要的文件系统挂载。即使 RCE 发生攻击者的权限也非常有限。这一点很多团队觉得麻烦但真正出事时能救命。四是监控审计。在日志采集服务器上记录FilteredObjectInputStream的拒绝日志比如Class xxx not allowed这类关键词设置告警。正常业务的反序列化请求模式比较固定一旦出现大量拒绝日志说明有探测行为正在发生。5. 常见问题速查与实践经验5.1 高频问题排查表我刚接触这个类时也踩了不少坑把几个高频问题整理成表格方便你直接对照排查。问题现象可能原因处理方式升级 2.17.2 后 SocketAppender 无法接收日志自定义 LogEvent 类不在白名单在过滤规则里显式添加自定义类允许项反序列化时抛 ClassNotFoundException过滤逻辑先校验类名类在 but 不在白名单检查白名单配置确认该类是否有必要放行日志中出现大量拒绝记录但业务正常有扫描器在探测内网端口检查访问来源 IP确认是否存在扫描行为反序列化成功但命令没执行gadget 版本与目标环境不匹配确认目标 classpath 里的库版本换用匹配的 gadgetLog4j2 升级后某个依赖不可用老版 API 方法被移除或行为变更查阅官方 release notes适配新 API5.2 我在实战中积累的几个经验先说一个最容易忽略的坑日志里的类名不一定是完整类名。我在一个实际案例里过滤规则写了org.example.MyEvent但运行日志里频繁报Class org.example.MyEvent$1 not allowed。原因是我的自定义事件类内部有个匿名内部类反序列化时会触发内部类MyEvent$1的加载而这个$1内部类自然不在白名单里。后来我统一改成前缀匹配判断逻辑变成“类名以允许列表中的某个前缀开头”这个问题才解决。再一个是关于黑名单改白名单的历史遗留问题。旧项目如果之前用了黑名单模式FilteredObjectInputStream的配置里可能塞了一长串要禁用的类名。升级之后要记得把这段配置清掉换成白名单。我见过有团队把黑名单配置直接搬进新版本导致该拦的没拦住不该挡的正常类倒是误杀了一片。黑名单里的类名在升级后大多已经没有意义保留它们反而给后续维护增加负担。还有一个运维侧的经验过滤规则要确保在 JDK 服务启动早期就生效。有些项目用 Spring Boot 的ApplicationContextInitializer或者Log4j2Plugins做了动态配置注入但配置加载时机不对前几个日志事件已经用默认宽松规则反序列化完成了之后才应用严格规则。这就出现了“前 10 分钟裸奔之后才穿上防弹衣”的情况。建议过滤器初始化放在正式的日志初始化流程里并用单元测试覆盖启动阶段的对象接收场景。5.3 对 FilteredObjectInputStream 的最终建议如果你问我个人最推荐的处理方式我会说在满足业务需求的前提下能不用 SocketServer 接收序列化日志事件就尽量不用。换用 JSON 格式的日志传输或者用类似 Kafka、Syslog 这类成熟协议把反序列化这层风险直接消除掉。FilteredObjectInputStream 是 Apache 给的补救措施但它治标不治本真正安全的架构是从设计上绕开“信任字节流”这个模型。如果业务实在绕不开反序列化那就要把它当成一个核心风险点来管理。白名单要定期 review新增依赖库时要评估它是否包含可被利用的 gadget 类。你可以用 SCA 工具比如 OWASP Dependency-Check扫依赖风险但别迷信扫描结果工具只能告诉你已知漏洞看不到未知攻击面。写在最后的一点体会日志库做安全加固听起来像是个“边缘话题”但 Log4j2 这几次漏洞给我们最大的教训是任何看似不起眼的组件只要它涉及网络输入和对象还原就可能变成攻击者的突破口。以前大家写日志只关心能不能打出来、打得多不多现在还得关心日志管道的入站方向是不是也在给别人递刀。我自己做完一轮 Log4j2 排查之后最大的感悟是安全加固这件事不会因为换到 2.17.2 就宣告结束。依赖库里总会冒出新的问题与其每次跟着漏洞公告走不如在项目里把依赖管理和反序列化入口的梳理变成常态化工作。这样下次再出类似的漏洞你至少能比大多数人快一步响应。