Log4Shell 复现实录:我读 OpenJDK 源码,发现两道防线的默认值竟然相反

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" 。

漏洞应用的写法极其平常------这也是它影响巨大的原因:

这不是"错误写法",恰恰是日常开发最自然的写法。


二、原理:三个设计缺陷的叠加

三个缺陷叠加才构成这个漏洞,缺一个都不行:

  1. 消息查找(Message Lookup) :StrSubstitutor 在渲染时递归解析 ${...}, 注册的查找器里有 jndi(JndiLookup)。这个机制本为配置插值 设计 (如 ${sys:user.dir}),但实现上对消息内容同样生效。
  1. JndiLookup 不限制协议与地址 :ldap://、rmi://、dns:// 都行, 且地址完全由输入决定------日志里出现什么,就查什么。
  1. 业务把用户输入写进日志 :如上,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

  1. 漏洞的普适性来自"正常代码" ------log.info("GET {} UA={}", path, ua) 不是错误写法,这才是 Log4Shell 影响巨大的原因。
  1. 攻击面 ≠ 可利用性------最小复现能干净地分离这两个因素,别把"没打通"包装成"打通了"。
  1. 默认值会骗人------两道 JVM 防线默认值相反,且随版本变化,加固时显式设置。
  1. 遥测成功 ≠ 链路工作------BER 编码、objectClass 两个坑的共同教训。
  1. 检测不依赖利用成功------载荷明文和查找残留是最稳的特征。
  1. 纵深防御------升级 ≥2.17.1 > JVM 三属性 > 出网控制 > 日志检测 > 痕迹排查。

合规声明

所有实验均在 localhost 完成,载荷只创建本地标记文件,不指向任何真实目标。 内容仅供学习研究与授权环境下的防御规则开发。


完整代码、四组实验的原始证据文件、检测规则与测试用例均已开源:

每一条结论都能在 evidence/ 目录里找到原始记录。

相关推荐
维拉德1 小时前
Java新人第一次接打标需求:从抓包找接口到客户端展示
java
luobobo8961 小时前
支付回调验签连续失败三天,最后发现是网关的时间戳单位问题
java
BBmmo1 小时前
Java InputStream.read() 为什么返回 int?
java
Nuanyt1 小时前
LeetCode Hot100 刷题笔记 · 普通数组专题
java·笔记·算法·leetcode
cfm_29143 小时前
Leaf-segment号段模式
java
Wang's Blog3 小时前
Java 项目实战: 交友项目 - 发布圈子动态:数据库表和接口文档介绍
java·数据库·交友
paopaokaka_luck3 小时前
智慧电玩店管理系统(空闲机台随机分配、游玩时长概率模拟、积分自动结算、积分兑换闭环、 ECharts经营分析、 腾讯地图选址)
java·人工智能·mysql·echarts·推荐算法
LMT流氓兔3 小时前
第五单元——第9课:防止同一次操作重复执行
java·服务器·数据库·算法·ai·langchain
OxYGC3 小时前
Spring 面经系列 · 第 6 篇:Spring MVC 请求全流程、父子容器与零配置原理
java·spring·mvc