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,厂商不发补丁,你也换不掉,只能先在网络层限制其出站。盘点时把这类设备单列一行,别和业务应用混在一起统计。
检测:先回答有没有,再回答哪一版#
# 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 的地方改成参数化写法,避免上线后日志内容变形、告警规则失效。
# 临时缓解:移除 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 的校验和记进变更单。
验证:三条都要过#
- 版本核对。
mvn dependency:tree -Dincludes=org.apache.logging.log4j:log4j-core与应用实际运行的 jar 一致,fat jar 里的 MANIFEST 也要看。 - 行为核对。在测试环境让应用打印一条含
${jndi:ldap://127.0.0.1:1/x}的字符串,2.17.1 下应原样输出。同时在旁边用tcpdump看有没有向 127.0.0.1:1 发起的连接,没有才算真的关闭。 - 出口核对。用
ss -tnp或防火墙日志确认应用没有连向陌生的 389、636、1099 端口。
如果前面有 WAF 或网关,回放一条带 ${jndi: 的请求,确认在网关层就被拦下。多层防御的意义在于,升级总会漏掉一两台机器。
升级之后还要回归日志功能:自定义 lookup、异步 appender、JSON 布局都可能受 lookup 语义变化影响。有的团队升级后日志里出现了原样的 ${ctx:xxx},就是没验证到位。
结论#
- Log4Shell 的根因是“把不可信输入交给会求值的格式化器”,换任何日志库都要记住这条。
- 排查资产时,依赖树只是入口,fat jar 与镜像层才是常被漏掉的地方。
- 临时缓解有三种:关 lookup、删类、封出口。三种都做,别只做一种。
- 长期基线是 2.17.1 及以上,具体以官方公告为准。
- 出现过
${jndi的机器按失陷处理,升级不等于清场。
完