最近爆出了一个新的rce;有趣的是不是爆出如何,而是爆出之后的反复bypass;这篇文章就先来追踪一下漏洞原理;
问题出现在error处;会触发log4j2的错误处理流程打印异常日志;
来个demo;
1 | import org.apache.logging.log4j.LogManager; |
追踪个流程;
在log4j2中日志是存在相应级别;按照从低到高来分:
ALL < TRACE < DEBUG < INFO < WARN < ERROR < FATAL < OFF。
相应的定义在StandardLevel枚举class中有以下自定义级别,intLevel 值越小,级别越高:
1 | OFF(0), |
debug代码进入error方法处理;
Level.ERROR返回的是个Level obj;简单看一下相应的静态域处理;
不难理解是生成了相应的obj;然后给相应的域作值;利用最开始的日志处理选择obj,最后一起封装传入logIfEnabled方法去进行处理;这里也是一些敏感处理方法的一些处理措施;判断日志处理权限是否合适和一些日志是否输出;其内部实现机制是结合相应配置的filter进行判断;
进入相应的filter看一波;
不过这里是本地debug,没有在config中定义相应的filter;所以直接return;这里第二个条件有意思;看一下intLevel生成方式;
1 | this.intLevel = this.loggerConfigLevel.intLevel(); |
不难发现是在相应的configlevel中进行获得;
说到这里有必要去理解一下logger的生成方式;其实内部的实现机制是根据getLogger方法传入的内容去LoggerRegistery中去find;如果没有找到就会去newInstance一个新的logger;然后将new的logger用putIfAbsent方法实现放到loggerRegistry中;newInstance内部实现机制是去实现了new Logger的操作;
1 | protected Logger newInstance(final LoggerContext ctx, final String name, final MessageFactory messageFactory) { |
所以回归到intLevel的生成方式内;这里内部还有一个细节;本地测试能不能attack success;其实除了jdk的版本限制以外还有一个点;就是
1 | return level != null && this.intLevel >= level.intLevel(); |
此逻辑位于logIfEnabled接口的isEnabled处理接口内;有趣的是上面也正是在介绍这个intLevel;
看一个关键的构造器;位于Logger.PrivateConfig class;
1 | public PrivateConfig(final Configuration config, final Logger logger) { |
里面详细的讲述了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 | public LoggerConfig() { |
其中已经默认写好了相应的default level为ERROR级别;下面利用getLevel方法触发之后会return ERROR;所以回到原本话题,intLevel是固定的200;也即是Level.ERROR.getintLevel()为200;所以默认处理的日志等级应该在error之上;
上面是对isEnabled接口中的处理流程和logger生成的一些思考;继续流程:
转回原本的流程追踪;在确实可以启用之后就会进行logMessage了;去将信息写入日志;
1 | public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message, final Throwable throwable) { |
具体实现是在logMessageSafely接口中;实现原理是实现递归的操作;
1 | private void logMessageSafely(final String fqcn, final Level level, final Marker marker, final Message message, final Throwable throwable) { |
跟进tryLogMessage;
内部机制是去logger相应的config下拿到相应的打日志策略;因为最开始没有给相应类创建logger;导致newInstance创建了一个logger其相应的config也是默认的loggerconfig:“root”;所以这里的策略默认的DefaultReliabilityStrategy;跟着策略看一下其内部实现机制;
发现是调用了config下的log去打印;由config来实现;
去到config下实现的目的可以清晰的看到,先来进行判断是否允许远程lookup;当允许的时候就会直接进入else if下进行处理;利用for循环去lookup properties域内的值;如果相应的obj下设定需要lookup就会直接replace;在内部进行lookup实现;将结果放到props中;这里rootlogger默认为false;properties也默认为null;所以直接进入下一步
构造log事件;按照判断的要求去factory中create出相应的event;接着就实现过一遍filter看一下是否filter中设定了相应不允许输出的东西在此过程中;accept or deny;当没有问题的时候,调用LoggerConfig.processLogEvent函数去处理event;调用Appender;Appender负责将日志事件传递到其目标。每个Appender都必须实现Appender接口。开始进行日志信息到最后文件的传输;
常用的Appender有ConsoleAppender(输出到控制台)、FileAppender(输出到本地文件)等,通过AppenderControl获取具体的Appender,本次调试的是ConsoleAppender。
因为是ConsoleAppender,所以这里去到AppenderControl class下的callAppender方法;有一个过滤;
1 | private boolean isFilteredByAppenderControl(final LogEvent event) { |
真正的实现是在callAppender0
追踪流程到tryAppend中;判断是否直接启用格式化输出;这里启用。
看下相关代码流程:
1 | protected void directEncodeEvent(final LogEvent event) { |
首先获取Layout日志格式,通过Layout.encode()进行日志信息的格式化;格式化调用在toSerializable函数中调用formatters来进行format实现;
整个格式化过程格式化完之后将其放入buffer中;buffer生成因为默认的递归深度为1;result为null;所以最后返回new一个1024大小的buffer;
1 | protected static StringBuilder getStringBuilder() { |
传入的message经过各种Converter进行处理,最后到处理主要信息message的时候去触发MessagePatternConverter.format处理,此处也是漏洞触发的关键点;
在放入buffer的处理流程中程序如果碰到了${}就会触发相应的解析,看一下解析点;
先创建一个workingBuilder;在利用formatTo将内容append到buffer之后会向后进程;将message放到buffer里,向下进行利用formatTo将${}
append到buffer里;当noLookups为false的时候,会去触发workingBuilder.append()去调用Substitutor的replace进行替换原来的信息;
进入细致流程看一下;
因为前缀是jndi;所以调用jndiLookup;
这里进入最后的漏洞触发点;
触发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去调用其静态函数去进行logger的初始化;
LogManager启动的入口是下面的static代码块:
1 | static { |
追溯一下相关的流程;environment属性下调用get方法去获取相关的factory_name;
追踪过去看一下:
可以看到environment class下调用PropertyFilePropertySource class去把相关的配置文件加载成一个java的obj;
看如下的class就已经很清晰了;加载配置文件到java对象的详细流程;
回到原本的class中;
之后从系统中去获取相关配置文件中规定的相关属性,如果为null;就用配置文件里的来进行set;更加细致的不再追溯;知道这段逻辑中,LogManager优先通过配置文件”log4j2.component.properties”通过配置项”log4j2.loggerContextFactory”来获取LoggerContextFactory,如果用户做了对应的配置,通过newCheckedInstanceOf方法实例化LoggerContextFactory的对象,最终的实现方式为:
1 | public static <T> T newInstanceOf(final Class<T> clazz) throws InstantiationException, IllegalAccessException, InvocationTargetException { |
但是在默认情况下不存在初始的默认配置文件log4j2.component.properties,因此需要从其他途径获取LoggerContextFactory。
默认下已经调用envrionment下的get方法拿到的factoryClassName为null;接着看下其他处理策略;
当然这里代码写的很严谨,会先去判断ProviderUtil是否为null;如果是就直接进入第三种策略;相关判别代码如下:
1 | //ProviderUtil.class |
因为这里介绍第二种策略,所以默认为不为null;进入第二种策略;先看部分代码;
调用ProviderUtil.getProviders载入providers;然后调用providers的loadLoggerContextFactory方法去载入LoggerContextFactory实现类;如果依然无法get到loggerContextfactory;
则使用SimpleLoggerContextFactory作为LoggerContextFactory。
但是实际上已经配置了相关的provider;项目是直接从maven上down下来的;所以配置文件可能不同;我的版本也有点低;但是追踪下发现确实是实现了相关的配置;所以直接使用Log4jPrivider去进行loadLoggerContextFactory获得loggerContextfactory;
最后LogManagerStatus.setInitialized(true);
设置初始化完成;