Log4Shell 复现实录:我读 OpenJDK 源码,发现两道防线的默认值竟然相反
关于 Log4Shell(CVE-2021-44228)的文章已经汗牛充栋。这篇不打算再复述一遍 "改 User-Agent 就能 RCE",而是记录我真的把它跑通、并把它打不通的原因 追到 JDK 源码的过程:四组对照实验、两个让我复现失败的真实 bug、 以及一条可以在生产环境落地的检测规则。
全部实验在本机完成,不依赖 Docker。所有结论都有原始证据。
一句话结论(先给答案)
| 问题 | 答案(全部实测,不是推测) |
|---|---|
| 根因 | log4j 渲染日志消息时对 ${jndi:...} 无条件发起 JNDI 查询 |
| 现代 JVM 能 RCE 吗 | 远程代码基加载默认被拒 ;但 javaSerializedData 反序列化默认放行 |
| 两道防线的默认值 | trustURLCodebase=false(8u191 起)、trustSerialData=true(JDK 17.0.12 实测)------相反 |
| 补丁有效吗 | 2.15.0 / 2.16.0 / 2.17.1 三版在最宽松 JVM 配置 下攻击遥测零连接 |
| 怎么检测 | 不依赖"利用成功":载荷明文 + JNDI 查找残留两类痕迹,写成 YAML 签名规则 |
一、环境:为什么我没用 Docker
复现 Log4Shell 的常规做法是拉 vulhub 镜像。我没这么干,原因只有一个词:classpath 可控。
受害应用的 classpath 上只有 log4j-api 和 log4j-core 两个 jar ,这是刻意的。 真实的 Log4Shell 利用依赖受害者 classpath 上的反序列化 gadget 库 (Commons Collections、Spring、Tomcat......),把 classpath 压到最小, 才能干净地分离两个变量: "log4j 的漏洞" 和 "classpath 的 gadget" 。
漏洞应用的写法极其平常------这也是它影响巨大的原因:
这不是"错误写法",恰恰是日常开发最自然的写法。
二、原理:三个设计缺陷的叠加

三个缺陷叠加才构成这个漏洞,缺一个都不行:
- 消息查找(Message Lookup) :
StrSubstitutor在渲染时递归解析${...}, 注册的查找器里有jndi(JndiLookup)。这个机制本为配置插值 设计 (如${sys:user.dir}),但实现上对消息内容同样生效。
- JndiLookup 不限制协议与地址 :
ldap://、rmi://、dns://都行, 且地址完全由输入决定------日志里出现什么,就查什么。
- 业务把用户输入写进日志 :如上,
log.info的写法完全正常。
"正常代码也能被利用"是它成为史上影响最大漏洞之一的根本原因。
三、核心发现:JVM 两道防线,默认值居然相反
这是我在复现中觉得最有价值的部分。直接读 OpenJDK 17 源码 (com.sun.jndi.ldap.VersionHelper,jdk-17+35):
| 攻击路径 | JVM 属性 | 默认值 | 后果 |
|---|---|---|---|
远程代码基加载(javaCodeBase → 下载 .class) |
trustURLCodebase |
false(8u191 起) | 实验 A:被拒 |
javaSerializedData 反序列化 |
trustSerialData |
true(17.0.12 实测) | 实验 C:反序列化真实发生 |
两道防线的历史脉络:2018 年 10 月 8u191 把 trustURLCodebase 默认改为 false, 挡掉了最初的攻击方式;攻击者随即转向 javaSerializedData + 受害者 classpath 上的 gadget 库,成为 2021--2023 年 in-the-wild 的主流利用方式。
部署建议 :实测与部分公开资料有分歧------本机 17.0.12 上
trustSerialData默认仍为 true(源码 + JNDI 探针双重实证)。不要依赖默认值,显式设置:
四、四组对照实验
| 实验 | 配置 | 结果 |
|---|---|---|
| A | 默认 JVM | 触达但被拒:bind/search 成功、恶意 entry 已返回,但无类下载 ;日志留 LdapCtx@6f49fe6a |
| B | -DtrustURLCodebase=true |
RCE 打通 :GET /Exploit.class 200 + pwned_log4shell.txt 由受害 JVM 创建 |
| C | 默认 JVM + javaSerializedData |
反序列化放行:日志留 com.sun.rowset.JdbcRowSetImpl@8845ad5;无 gadget → 无 RCE |
| D | 最宽松 JVM + 2.15.0/2.16.0/2.17.1 | 全部零连接:JNDI 查询根本没发生 |
实验 B 的攻击侧遥测(完整链路 + 一个有意思的细节):
链跑了两遍 。因为 log4j2.xml 配了 Console 和 File 两个 appender, 各自渲染一次消息 → 各触发一次 JNDI 查找。多 appender 会放大攻击面, 这个细节在多数分析文章里没被提到。
攻击面 ≠ 可利用性(我认为最重要的一课)
实验 C 证明了反序列化默认放行,攻击者的 JdbcRowSetImpl 被成功还原------ 但 JDK 自带类里没有"readObject 时自触发任意代码执行"的 gadget,链路停在这里。
最小环境把"攻击面敞开"和"缺 gadget"分开证明了。 我的环境没有 RCE, 不代表真实世界没有------企业应用带着一整个 classpath,Log4Shell 才能大规模 RCE。 不少复现文章会把最小环境的"没打通"包装成成功案例,这里如实记录边界。
五、两个让我复现失败的真实 bug
这部分是全文我最想分享的------它们都表现为"看起来成功,实际什么都没发生" 。
坑 1:LDAP BER SET OF 编码错误
LDAP 的 PartialAttribute.vals 是一个 SET 装着所有值。我最初写成 "每个值各包一个 SET":
后果:多值属性后残留字节 → JNDI 属性解析失败 → lookup 返回 LdapCtx。 遥测上 "bind/search 成功、entry 已返回",看起来一切正常,但后续什么都没发生 。 修完补了个编码 round-trip 自测(_ber_selftest.py:构造→严格解析)。
坑 2:entry 缺 javaNamingReference / javaClassName
读 OpenJDK 17 源码才搞清楚两个前置门槛:
com.sun.jndi.ldap.Obj.decodeObject:只有 objectClass 含 javaNamingReference 时才走 decodeReference 构造 Reference;否则返回 null,lookup 结果就是 LdapCtx。
LdapCtx.c_lookup:只有 javaClassName 存在时才调用 decodeObject ------ 只给 javaSerializedData 不触发反序列化。
共同教训:每个环节都要验证到下一跳。 "页面有响应" ≠ "扫描在工作";"entry 已返回" ≠ "JNDI 会解析它"。
六、补丁链(实验 D 实证)
| 版本 | 修复内容 | 实验 D 结果 |
|---|---|---|
| 2.14.1 | 漏洞版本 | RCE |
| 2.15.0 | 消息查找默认仅限 java 协议 |
零连接,payload 明文残留日志 |
| 2.16.0 | 默认关闭消息查找;修 CVE-2021-45105(DoS) | 零连接 |
| 2.17.1 | 修 CVE-2021-44832(ThreadContext RCE) | 零连接 |
2.15.0 不是银弹:CVE-2021-45046 表明在非默认配置下仍有风险, 2.16.0 才默认关闭消息查找。本实验只验证默认配置下的代码基攻击路径(边界如实说明)。
七、检测:不依赖"利用成功"
这是防御侧最实用的一点。无论攻击成功与否,都会留下痕迹:
| 痕迹 | 场景 |
|---|---|
${jndi:...} 明文 |
补丁版本(查找禁用)/ RCE 成功但载荷返回 null |
com.sun.jndi.ldap.LdapCtx@xxxx |
JNDI 查询已发生、代码基被拒 |
com.sun.rowset.JdbcRowSetImpl@xxxx |
序列化字节流已被还原 |
我把它们写成了声明式 YAML 规则(配套项目 SentinelWatch,阈值 + 签名双模引擎):
样本日志(取自真实捕获,非编造)跑出来的结果: 2 CRITICAL + 2 HIGH,正常业务行静默 (仅这 4 条 Log4Shell 规则; 叠加 Fastjson 规则集会额外命中------样本里的 ${jndi:ldap://...} 同样触发 JNDI URL 规则,实测 4 CRITICAL + 2 HIGH)。 11 个测试用例覆盖端到端与静默语义。
WAF 侧 可以参考这条正则,并注意它挡不住的绕过变体 (${lower:j}、${::-j}、嵌套 ${${env:xxx:-j}ndi:...}):
八、Takeaways
- 漏洞的普适性来自"正常代码" ------
log.info("GET {} UA={}", path, ua)不是错误写法,这才是 Log4Shell 影响巨大的原因。
- 攻击面 ≠ 可利用性------最小复现能干净地分离这两个因素,别把"没打通"包装成"打通了"。
- 默认值会骗人------两道 JVM 防线默认值相反,且随版本变化,加固时显式设置。
- 遥测成功 ≠ 链路工作------BER 编码、objectClass 两个坑的共同教训。
- 检测不依赖利用成功------载荷明文和查找残留是最稳的特征。
- 纵深防御------升级 ≥2.17.1 > JVM 三属性 > 出网控制 > 日志检测 > 痕迹排查。
合规声明
所有实验均在 localhost 完成,载荷只创建本地标记文件,不指向任何真实目标。 内容仅供学习研究与授权环境下的防御规则开发。
完整代码、四组实验的原始证据文件、检测规则与测试用例均已开源:
- 复现代码与实验证据:**github.com/yliucherish... 目录)
- 检测规则与测试用例:**github.com/yliucherish...
每一条结论都能在
evidence/目录里找到原始记录。