去年我接手一单安全排查时,第一眼看到告警日志里浮现出Log4j2的调用栈,说实话人是有点懵的。一个主要负责打日志的库,怎么会跟反序列化远程命令执行(RCE)扯上关系?等到顺着FilteredObjectInputStream的过滤逻辑一路往下挖,我才发现问题远没有表面看起来那么简单:不是Log4j2不该碰反序列化,而是它碰了之后,用来兜底的那层过滤机制本身就有先天缺陷。这篇文章就围绕Log4j2 FilteredObjectInputStream相关的RCE漏洞分析展开,把当时踩过的坑、翻过的源码、总结的排查和防御方法一次性说清楚。如果你正在做Java应用安全自查,或者想理解反序列化防护为什么总是失效,这篇文章应该能帮到你。
1. 从日志到命令执行:Log4j2为什么会被拖进反序列化泥潭
1.1 日志框架到底为什么会碰 readObject()
很多同学看到“日志框架”这四个字,第一反应就是写文本、打控制台、输出到文件,最多再做点日志采集和格式化。但Log4j2并不是一个只管字符串的库,它内部有几个功能天然会接触Java对象,其中一个就是ObjectMessage。
ObjectMessage允许你把一个Java对象直接塞进日志事件里。举个例子,你在业务代码里写logger.info(new ObjectMessage(userObj)),日志框架会把userObj转成字符串输出。问题是,如果某个日志入口直接接收外部传过来的二进制流,并且日志框架内部去反序列化这个流,那问题就来了。
还有更直观的入口:Log4j2自带的SocketAppender和对应的SocketServer。它们的设计初衷是让应用通过网络传输日志事件,让远端统一收集日志。既然是传输对象事件,那接收端就不可避免要把Received的字节流还原成Java对象。这一步还原动作,就把Java原生的ObjectInputStream.readObject()拉进了攻击面。
Java反序列化一直是个老生常谈的问题。readObject()本质上等于告诉系统:“来,把这个字节流拆开,变成一个活生生的对象。”但对象构建过程中会执行很多隐式的逻辑,比如readObject()方法、readResolve()方法、构造函数,甚至Map和集合类在元素加入时会调hashCode()、equals()。攻击者如果能控制这个字节流,就能在这些隐式逻辑里埋雷,最终诱发命令执行。
我打过一个比方:反序列化就像收快递。你在网上买了一个箱子,快递柜收到箱子后根本不验货,直接按箱子上的“安装说明”开始组装。组装过程中,说明书里夹带了一行小字“顺手运行这个命令”。快递柜照做了,于是你拿到了一个被攻击者控制的系统权限。Log4j2某些入口干的就是快递柜的活儿。
1.2 从CVE-2020-9488说起:一次典型的反序列化RCE
在Log4j2的反序列化安全事件里,最有代表性的就是CVE-2020-9488。这个漏洞的触发点落在SMTPAppender上。SMTPAppender的功能是让Log4j2在记录到特定日志级别时自动发一封报警邮件,它需要处理一批邮件会话参数,包括主机、端口、认证信息,以及跟SSL/TLS相关的一堆属性。
问题在于,某些SMTP会话属性在处理时会被塞进会话对象,其中一部分路径会走到反序列化逻辑。攻击者如果能控制这些属性值,或者控制邮件内容中的特定字段,就可以让Log4j2在解析过程中调用到ObjectInputStream.readObject(),最终把精心构造的恶意序列化数据还原成攻击对象。当时的修复版本是2.13.0,官方在后续版本里很明确地做了一个动作:默认不允许反序列化ObjectMessage,只有显式开启log4j2.enableObjectMessage才允许。
再说一个容易让人忽略的点:很多大型中间件、大数据组件、网关产品都内置了Log4j2。它们内部可能并不直接暴露日志接口,但一旦某个上游接口可以影响日志内容,攻击者就有可能借助日志链路反打到业务系统。这就是为什么Log4j2的漏洞总是被CVSS打分打得很高——不是因为日志框架本身权限大,而是因为它太普及,攻击面被指数级放大。
1.3 攻击面到底有多大:Log4j2反序列化入口清单
我当时梳理Log4j2反序列化入口时,列了这么一张表,分享出来供你对照自查:
| 入口类型 | 主要组件 | 风险说明 |
|---|---|---|
| 对象消息 | ObjectMessage | 直接把Java对象写入日志事件,若对象可控则风险极高 |
| 网络日志接收 | TcpSocketServer / UdpSocketServer | 接收远程序列化日志事件,默认监听端口暴露即危险 |
| 邮件告警 | SMTPAppender | 配置或内容中特殊字段可能触发反序列化 |
| 日志事件解析 | LogEvent / Log4j IOStream | 部分版本对事件流处理存在解析缺陷 |
这张表最大的价值在于提醒你:不要只盯着logger.info("xxx")这种常规写法。反序列化入口往往藏在你不常看的地方,尤其是跟网络、IO、配置解析相关的组件里。
2. FilteredObjectInputStream 过滤机制源码级拆解
2.1 黑名单过滤器是怎么设计出来的
Log4j2官方不是没想过防这个问题。在意识到反序列化入口危险之后,他们引入了一个自定义的ObjectInputStream子类,就是标题里提到的FilteredObjectInputStream。它做的事情说起来很简单:重写resolveClass()方法,在类解析环节做一层拦截。
正常ObjectInputStream读取对象时,字节流里记录的是类名,系统会通过resolveClass()把这个类名解析成真正的Class对象。FilteredObjectInputStream的思路就是在这一步做检查,如果发现类名命中黑名单,就直接抛异常,阻止后续反序列化动作。我按当时分析的版本把核心逻辑还原成以下伪代码(非官方原版源码,仅表达核心思想):
public class FilteredObjectInputStream extends ObjectInputStream { private static final String[] BLOCKED_PACKAGES = { "org.apache.commons.collections.functors", "com.sun.org.apache.xalan.internal", "javax.naming" }; @Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className = desc.getName(); for (String blocked : BLOCKED_PACKAGES) { if (className.startsWith(blocked)) { throw new InvalidClassException("Blocked class: " + className); } } return super.resolveClass(desc); } }思路明确,实现也不算复杂。但安全防御最怕的就是“看起来没问题”。
2.2 黑名单的匹配粒度:你挡住的只是名字里带特定字符串的类
这个过滤器用来匹配的核心手段,基本就是字符串匹配,而且很多时候是startsWith或contains这类简单逻辑。比如它看到org.apache.commons.collections.functors.InvokerTransformer,觉得这是危险类,就拦下来。
问题恰恰出在这里。反序列化攻击链的构成非常灵活,一个能触发命令执行的“危险类”并非只有一种。Commons-Collections里有InvokerTransformer,Commons-Beanutils里有BeanComparator,还有各种动态代理类、JNDI注入相关类、JDK原生类,这只是冰山一角。攻击者在构造利用链时,完全可以避开黑名单里已有的那几个类名。
这就好比小区门禁系统贴了一张通缉令,上面只写了张三和李四的名字。今天来的小偷化名王五,保安照样给人放行了。黑名单的覆盖范围永远追不上攻击者的脑洞。
还有一层更细的问题:某些过滤逻辑匹配的是“类名包含某个特征”,但序列化字节流里的类名写法可能和加载后实际类名存在偏差。比如大小写变体、内部类用$还是.分隔、数组类型的描述符等,一旦匹配逻辑没考虑到这些情况,拦截就会出现漏网之鱼。我见过有些项目自研的过滤器连数组类名都处理不好,防御效果可想而知。
2.3 三个致命盲区让过滤器变成了纸糊的
我分析完这个过滤器之后,总结了三个比较致命的盲区。
盲区一是黑名单覆盖永远滞后于新的利用链。安全社区发现一个gadget链,官方补一个类名,但攻击者手里可能有十条备选链。防护方在明处,攻击方在暗处,这种攻防不对称对黑名单方案来说是致命的。
盲区二是第三方库版本差异让类名匹配变得不靠谱。很多应用里实际加载的类来自传递依赖,不同版本之间类名、方法名、内部结构都会有差异。有的黑名单是按某个固定版本的类名写的,一旦应用因为业务需要升级了依赖库,原本被黑名单记住的危险类名变了,过滤器可能就断片了。
盲区三是resolveClass()这个校验点处于反序列化流程的中段而非源头。虽然它能拦截一部分类的解析,但反序列化过程中对象的嵌套非常深,一个对象图里可能递归构造大量子对象。过滤机制如果只在某一层做检查,攻击者可以通过套壳、嵌套的方式绕过比较靠前的拦截点,让真正的危险类在最内层才被解析,而那时过滤器可能根本来不及拦。这也是为什么很多安全专家反复强调:永远不要只靠黑名单来防反序列化。
3. 绕过原理与攻击链构建:从黑名单到RCE的距离
3.1 反序列化RCE的标准攻击模型
在聊绕过之前,先明确反序列化RCE的标准攻击模型,方便你建立整体概念。一次成功的反序列化攻击,通常由三个角色组成:
第一个叫触发器。它是readObject()或者readResolve()这类反序列化过程中必然会执行的方法。攻击者需要找一个类的readObject()能主动调用一堆“奇怪”的方法,比如hashCode()、equals()、toString()。
第二个叫转换器。它负责把目标对象的方法调用转换成一个危险动作。最典型的就是各种Transformer,比如把Runtime.class变成可调用的exec()方法。
第三个叫执行器。它是最终触发动作的落点,常见的就是Runtime.getRuntime().exec(),也可能是JNDI注入、写文件、SSRF等。
三个角色串起来,就是一条gadget chain。比如某个HashMap在反序列化时会自动调用元素的hashCode(),而某个TiedMapEntry的hashCode()又去调用了LazyMap.get(),get()再触发Transformer链,最终调到Runtime.exec()。每一环本身都是合法的类、合法的方法,组合起来却成了达成任意代码执行的一条链子。
3.2 FilteredObjectInputStream 绕过三条路线
基于上面的模型,FilteredObjectInputStream即使存在,攻击者至少有三条路线可以绕。
路线A是最直接的思路:找不在黑名单里的“同功能类”。Log4j2拦住了Commons-Collections里的某个Transformer,那我就换一个库。Commons-Beanutils里的BeanComparator、Spring里的MethodInvokeTypeProvider、Groovy里的ConvertedClosure,只要这些类不在黑名单里,整条链依然走得通。对防御方来说,真正致命的不是某一个类,而是攻击者手里有海量可替换的“零件”。
路线B是活用JDK原生类和JNDI机制。早期很多黑名单只盯着第三方工具库,却疏忽了JDK自带的类。即使后来有版本开始把javax.naming加进黑名单,也依然可能在更早或更晚的解析环节找到漏网之鱼。尤其是一些URL类、ClassLoader相关的类,一旦被恶意利用,同样能实现代码执行或者信息泄露。
路线C是利用库版本差异和依赖冲突。这一点在真实环境里非常常见。你以为应用加载的是某个安全版本的依赖,实际通过Maven依赖树一看,某个中间件又引入了一个老版本的第三方库。FilteredObjectInputStream的黑名单是按固定类路径写的,一旦实际加载的类来自不同版本,类名对不上,拦截就会失效。
必须说明的是,以上三条路线我都只做原理层面的还原,不建议在真实系统里做任何未经授权的利用测试。理解绕过的目的是为了知道黑名单方案的边界在哪里,而不是为了打点。
3.3 绕过思维训练:从CTF RCE题目看过滤绕过的共性
如果你想训练这种“过滤绕过”的思维,CTF靶场其实是个很安全的练习环境。CTFHub、pikachu这些靶场里有很多RCE题目,题目本身训练的就是命令注入和过滤绕过。
举个例子,一个典型命令注入场景:服务端把你输入的内容直接拼进ping命令,但过滤了空格。空格没了怎么办?可以用${IFS}替换空格,也可以用制表符%09。如果过滤了cat关键字,可以用c""at、tac、head等别名工具,还能用反斜杠c\at变形绕过。这些题目在技术上跟FilteredObjectInputStream没有直接关系,但背后的思维模型是完全相通的:任何关键词、字符串、格式上的过滤,本质上都是在跟攻击者比“谁更了解所有可能的变体”。
你在CTF题里练得越多,越能体会到一个道理:过滤型防御方案的上限很低,因为它的完备性完全取决于过滤列表本身。今天的名单是完整的,明天不一定还是完整的。这也是为什么行业里主流的声音一直在推动从“黑名单”走向“白名单”和“默认禁用”。
4. 实战复盘:从告警到定位的排查实录
4.1 复现环境怎么搭
如果你也想自己复现一遍这个漏洞分析过程,环境搭建可以按下面这套方案来。
先准备一个最小的Java应用,用Maven引入一个受影响版本的Log4j2依赖,比如2.12.x。然后给应用加一条暴露反序列化入口的测试代码:启动一个ServerSocket监听某个端口,收到字节流后直接用FilteredObjectInputStream读取对象。这里要注意,你的目的是理解过滤器的拦截逻辑和绕过原理,不是在生产环境做攻击演练,所以建议只在隔离的实验环境里操作。
复现过程中最值得观察的点是调用栈。当你把一段测试用的恶意序列化数据发送给服务端时,注意看堆栈里resolveClass()的调用时机,以及过滤器的异常是如何被抛出的。我当时的做法是给resolveClass()临时加一行打印,把每一次解析的类名都打出来,这样能很直观地看到哪些类被拦了、哪些类顺利通过。
4.2 拿到一次“疑似RCE”后我的排查顺序
在实际排查过程中,我总结了一套固定动作。第一步是看日志,重点是异常堆栈。如果堆栈里出现了java.io.ObjectInputStream.readObject()、resolveClass()、InvalidClassException这些字样,基本可以确定反序列化行为被触发过。
第二步是确认依赖版本。用Maven的dependency:tree或者直接看构建产物里的log4j-core-*.jar,确定实际版本号和官方修复版本之间的关系。这里有个容易踩的坑:你以为升到了2.13.0,结果某个传递依赖又把2.11.2拉回来了。版本冲突在Java生态里真的太常见了。
第三步是抓线程栈。如果应用还在运行,用jstack把Java进程的线程栈导出来,搜索FilteredObjectInputStream或者SocketServer相关栈帧,能快速定位到反序列化发生在哪个线程、哪个组件里。
第四步是反查业务代码。全局搜索ObjectMessage、TcpSocketServer、UdpSocketServer、SMTPAppender这些关键词,确认它们到底有没有被业务代码直接或间接地调用过。很多时候漏洞不是Log4j2主动产生的,而是业务方为了某个需求“不小心”打开了潘多拉魔盒。
4.3 自查清单:哪些入口最容易被忽视
再分享一张我当时用来做自查的清单表:
| 自查项 | 具体方法 | 风险等级 |
|---|---|---|
| log4j-core版本 | 检查pom或lib目录,确认是否低于2.13.0 | 高 |
| ObjectMessage使用 | 全局搜索new ObjectMessage | 中 |
| SocketServer暴露 | 检查是否开放Socket端口,端口是否对外 | 高 |
| SMTPAppender配置 | 检查log4j2.xml中是否配置邮箱、SSL属性 | 中 |
| 传递依赖覆盖 | 用dependency:tree检查是否有老版本Log4j2 | 高 |
| 第三方组件引用 | 排查集成中间件是否内置Log4j2,比如某些报表引擎和低代码平台 | 高 |
提到第三方组件,我想起近期一些暴露出来的组件RCE漏洞,比如部分报表引擎在预认证阶段就存在反序列化或表达式注入风险。这类组件的共性问题是:组件本身为了功能强大,默认开启了太多危险特性,用户拿到手后一般都保持默认配置上线,结果一个默认端口就是一条通往RCE的路。Log4j2的FilteredObjectInputStream问题也是这样,弱配置加默认开启,叠加在一起就是灾难。
5. 修复与纵深防御:别把安全押在一条黑名单上
5.1 官方修复升级指南
关于Log4j2反序列化漏洞的修复,最有效的手段永远是升级版本。对于CVE-2020-9488这类问题,升级到2.13.0以上基本上就断了主要攻击路径。后续版本里,官方给出了log4j2.enableObjectMessage这个开关,默认关闭,等于把ObjectMessage这条反序列化入口彻底封死了。
这里建议你在升级后检查两件事。第一件事是确认JVM启动参数里有没有显式加-Dlog4j2.enableObjectMessage=true,如果加了这个参数,一定要评估业务是否真的需要,不需要就删掉。第二件事是跑一遍dependency:tree,确保没有老版本依赖被传递进来。我遇到过不止一次,明明改了pom,构建出来的包里还是老版本jar,排查半天才发现是某个内部发布的common包把Log4j2从2.11.2带进来了。
5.2 平台级防护:JEP 290与ObjectInputFilter
除了升级版本,我强烈建议你利用Java平台自带的序列化过滤机制,也就是JEP 290提供的ObjectInputFilter。它的思路跟FilteredObjectInputStream正好相反,是白名单优先,默认拒绝一切不在允许范围内的类。这套机制从Java 9开始就有了,我建议在应用启动参数里加上类似这样的配置:
-Djdk.serialFilter=maxbytes=1048576;java.base.**;org.example.**;!*这段配置的含义是:限制反序列化数据最大为1MB,只允许反序列化java.base包和你自己业务包下的类,其他一切类全部拒绝。这样做的好处是把反序列化的范围缩到最小,即使攻击者构造了恶意字节流,也找不到可利用的类入口。这一层防线比FilteredObjectInputStream的黑名单靠谱得多,因为白名单的默认策略是不信任,而不是列可疑名单。
还有一个容易被忽略的点:如果你在代码里手动创建ObjectInputStream,建议都统一走一个带ObjectInputFilter的静态工厂方法,而不是到处new ObjectInputStream()。这样后续要调整过滤规则,只需要改一个地方。
5.3 五条铁律与项目实践清单
最后把项目实践层面的经验总结成几条硬性原则,希望你踩坑之前就能看到。
第一条,不可信数据永远不要反序列化。不管它是不是Log4j2,不管它外面套了哪一层过滤器,只要数据来源不可控,就不要走readObject()这条路。如果在业务上非反序列化不可,那一定要加白名单过滤和完整性校验。
第二条,最小化依赖。Java项目里很多安全风险都来自“被牵连的依赖”。你只是想用某个工具库,结果它传递依赖带来了老版本的Log4j2、老版本的Commons-Collections,这就把攻击面硬生生打开了。建议定期用SCA工具扫描依赖漏洞。
第三条,网络端口收紧。Log4j2的SocketServer如果开了TCP或UDP端口,默认就等同于对外提供了一个反序列化入口。一定要确认这些端口有没有暴露到公网、有没有防火墙限制、有没有做来源IP白名单。
第四条,做运行时检测。如果业务确实离不开反序列化接口,推荐引入RASP或IAST类工具,在运行时监控readObject()调用和命令执行行为,一旦发现异常动作直接阻断。这类工具对0day的抵抗力比任何过滤规则都强。
第五条,建立漏洞台账。每发现一个漏洞,不应该只是改个配置、升个版本就完事,要把漏洞成因、影响组件、修复方案、验证结果记录在案。这样下次遇到类似问题,你能快速知道排查路径,不用重复踩坑。
我在实际分析中还有一个体会:FilteredObjectInputStream这层黑名单过滤器,本质上是在旧架构下做的一个修补措施,它解决不了反序列化的根因问题。根因是Java反序列化机制本身对输入过于信任,而这个根因只能通过“默认禁用”“最小化允许”“运行时检测”去缓解。你可以在所有关键入口都加上黑名单,但永远要记得,黑名单只是权宜之计,不是安全终点。
这个原理不仅适用于Log4j2,也适用于所有反序列化相关组件。你在Log4j2上学到的这套分析思路——找入口、看过滤、绕过验证、升级加固——完全可以迁移到Fastjson、XStream、Jackson这些同类框架上。安全这条路,说到底就是不断从已知漏洞里提炼防御方法,再用防御方法反推潜在风险。下一次再遇到一个“看起来人畜无害”的组件,希望你能多问一句:它的入口里,有没有藏着一扇没关的门。