安全修复 · 2026 年 11 月 11 日

Log4Shell 复盘:一行 JNDI 查询如何击穿 Java 生态

一个被写进日志的 User-Agent,就能让服务器主动连上攻击者的 LDAP 服务。这篇复盘讲清 Log4Shell 的成因与传播路径、怎么在资产里找出 log4j-core 及其间接依赖,以及升级之外还能做哪些兜底动作。

2021 年 12 月 9 日深夜,Java 团队的工作群基本都在问同一句话:我们有没有直接或间接依赖 log4j-core。Log4Shell(CVE-2021-44228)的杀伤力不来自多精巧的利用链,而来自触发条件低得离谱——只要一段用户能控制的字符串被写进日志。

这次事件后来成了供应链安全的经典案例:漏洞在日志框架里,影响却落在电商、游戏、政务系统、IoT 固件,几乎所有跑 Java 的地方。

背景:一条日志怎么会走到外网#

Log4j2 提供 lookup 语法,用来在日志消息里拼接运行时信息,比如 ${java:version}、${env:HOME}。这套语法本意是方便排查,2013 年加入 JNDI 支持之后,消息里可以出现 ${jndi:ldap://...},由日志框架代替你去发起一次远程查询。

日志的输入几乎从来不可信。User-Agent、Referer、用户名、搜索关键词、表单字段,任何一个进了日志,就等于把一段外部字符串送进了格式化器。

漏洞原理:格式化器与目录服务被接在一起#

点到为止地讲:Log4j2 在格式化消息时会解析 ${} 表达式,识别到 jndi 前缀就调用 JNDI lookup。JNDI 是 Java 访问命名与目录服务的标准接口,LDAP、RMI、DNS 都是它的 provider。当 lookup 指向外部 LDAP、且 JDK 允许从远端加载类时,“写一行日志”就等价于“加载并执行远端代码”。

链路可以拆成三段:不可信输入进入日志 → 消息 lookup 解析出 jndi 地址 → 进程主动连接外部服务。防守方在任意一段设卡都有效,这也是当时各种临时方案能奏效的原因。

当时还有一种误判:以为不打日志就安全。实际上访问日志、错误页、审计组件都可能把请求头落盘,链路上任何一环写入文件都算触发。判断风险要看“外部输入最终去了哪里”,而不是看业务代码里有没有 log.info。

两个细节常被忽略。其一,DNS provider 不能直接执行代码,但能当外带通道,用来判断哪些机器中招。其二,变形非常多——大小写混写、嵌套表达式、编码绕过,靠关键字黑名单过滤 ${jndi 基本拦不住,后来业界才统一转向“关掉 lookup 加升级”。

影响范围:直接依赖只是冰山一角#

场景 风险 怎么确认
应用直接依赖 log4j-core 2.x 高 mvn dependency:tree 看传递依赖
中间件、脚手架自带旧版 core 高 翻 fat jar、容器镜像、lib 目录
只引了 log4j-to-slf4j 较低 桥接包不含 core,但要确认没被传递引入
日志里完全不记录外部输入 较低 仍需升级,别把安全寄托在“没人打印”上

具体的受影响版本与修复版本,请以 Apache Logging 官方安全公告和发行版公告为准。需要强调的是:2.15.0 只是第一步,2.16.0 移除了消息 lookup,2.17.x 才补齐后续问题,长期基线应落在 2.17.1 及以上。

老系统尤其容易漏。很多 2015 年前后搭的脚手架把 core 固定在 2.7、2.8,又通过 shade 插件打进 fat jar,依赖树里看不出来,只有解开 jar 才能看到类。

还有一类资产不在应用清单里:VPN 网关、邮件网关、监控平台、老式 OA 设备。它们往往自带 Java 运行时和旧版 log4j,厂商不发补丁,你也换不掉,只能先在网络层限制其出站。盘点时把这类设备单列一行,别和业务应用混在一起统计。

检测:先回答有没有,再回答哪一版#

bash
# 1. 找磁盘上的 log4j-core
find / -name 'log4j-core-*.jar' 2>/dev/null

# 2. fat jar 里是否还带着 JndiLookup(依赖树看不到这一层)
for j in $(find /opt /srv -name '*.jar' 2>/dev/null); do
  unzip -l "$j" 2>/dev/null | grep -q 'lookup/JndiLookup.class' && echo "$j"
done

# 3. 历史日志里是否出现过 lookup 尝试
grep -aE '\$\{[^}]*jndi' /var/log/app/*.log | head

第二条最容易被跳过。已经打好的包里,依赖树是构建期的事,运行期只有字节码说了算。

第三条是失陷排查的起点。如果日志里出现过 ${jndi,说明那台机器被人试过,要按“可能已失陷”的流程走:查异常出站连接、查新增进程、查计划任务,而不只是升级了事。

扫描器能给出一份清单,但它只能回答“文件在不在”,判断不了“类有没有被加载”。对核心系统,建议补一份 SBOM:用 cyclonedx 插件或 syft 生成依赖清单,把 log4j-core 版本写进发布记录。下次再出同类事件,五分钟就能回答哪些系统受影响。

修复与缓解:先止血,再升级,最后复查#

顺序很重要。业务不能立刻重启时,先做出口封禁和开关;能排期时,统一升级并回归。

  • 启动参数加 -Dlog4j2.formatMsgNoLookups=true,对 2.10 及以上有效,属于过渡手段。
  • 从 jar 中删除 JndiLookup.class,能立竿见影,但下次构建会被覆盖,必须写进构建流程。
  • 出口侧禁止应用服务器主动访问互联网的 LDAP、RMI 端口,这条对未知的同类漏洞也有效。

2.17.1 与 2.14 的差异不止安全修复。消息 lookup 被移除后,一些把变量直接写在日志文案里的老代码会输出原样字符串。升级前先在代码里搜 ${,把依赖消息 lookup 的地方改成参数化写法,避免上线后日志内容变形、告警规则失效。

bash
# 临时缓解:移除 lookup 类(先备份,确认应用可重启)
cp log4j-core-2.14.1.jar log4j-core-2.14.1.jar.bak
zip -q -d log4j-core-2.14.1.jar \
  org/apache/logging/log4j/core/lookup/JndiLookup.class
unzip -l log4j-core-2.14.1.jar | grep -c JndiLookup   # 期望输出 0
注意

不要在生产环境热替换正在被类加载器持有的 jar。已经加载的类不会因为文件被改而卸载,要么滚动重启,要么等发布窗口。改动前把 jar 的校验和记进变更单。

验证:三条都要过#

  1. 版本核对。mvn dependency:tree -Dincludes=org.apache.logging.log4j:log4j-core 与应用实际运行的 jar 一致,fat jar 里的 MANIFEST 也要看。
  2. 行为核对。在测试环境让应用打印一条含 ${jndi:ldap://127.0.0.1:1/x} 的字符串,2.17.1 下应原样输出。同时在旁边用 tcpdump 看有没有向 127.0.0.1:1 发起的连接,没有才算真的关闭。
  3. 出口核对。用 ss -tnp 或防火墙日志确认应用没有连向陌生的 389、636、1099 端口。

如果前面有 WAF 或网关,回放一条带 ${jndi: 的请求,确认在网关层就被拦下。多层防御的意义在于,升级总会漏掉一两台机器。

升级之后还要回归日志功能:自定义 lookup、异步 appender、JSON 布局都可能受 lookup 语义变化影响。有的团队升级后日志里出现了原样的 ${ctx:xxx},就是没验证到位。

结论#

  • Log4Shell 的根因是“把不可信输入交给会求值的格式化器”,换任何日志库都要记住这条。
  • 排查资产时,依赖树只是入口,fat jar 与镜像层才是常被漏掉的地方。
  • 临时缓解有三种:关 lookup、删类、封出口。三种都做,别只做一种。
  • 长期基线是 2.17.1 及以上,具体以官方公告为准。
  • 出现过 ${jndi 的机器按失陷处理,升级不等于清场。

完

#Log4j#JNDI#供应链
回到顶部 ↑