0x01 写在前面
2021 年 12 月 9 号注定是一个不眠之夜,著名的Apache Log4j 项目被爆存在远程代码执行漏洞,且利用简单,影响危害巨大,光是引入了 log4j2 依赖的组件都是数不清,更别提项目本身可能存在的风险了,如下图所示,mvnrepository搜索引用了 log4j-core version 2.14.1的项目就 十几页了:
本文就来简单分析一下该漏洞的原理。
0x02 影响范围
引用了版本处于2.x < 2.15.0-rc2的 Apache log4j-core的应用项目或组件
0x03 漏洞分析
根据官方的修订信息:https://issues.apache.org/jira/projects/LOG4J2/issues/LOG4J2-3201?filter=allissues
可以明确知道,是通过 jndi 中 LDAP 注入的方式实现了 RCE,然后查看其补丁的更改记录:
可以发现对lookup
函数进行了修改判断
知道了漏洞类型,那么就好入手了,首先翻阅官方文档中关于lookup
的说明:
lookup
提供了一种在任意位置向 Log4j2 配置添加值的方法,是实现StrLookup
接口的特殊类型的插件 ,查看官方文档发现log4j2 支持的方法有很多:
总计有:base64
、data
、ctx
、main
、env
、sys
、sd
、java
、marker
、jndi
、jvmrunargs
、map
、bundle
、log4j
由于这里主要说明的是关于 JNDI lookup 的用法,其他的不再赘述。
关于 JNDI lookup官方文档有说明:
JndiLookup 允许通过 JNDI 检索变量,然后给了示例:
<File name="Application" fileName="application.log">
<PatternLayout>
<pattern>%d %p %c{1.} [%t] $${jndi:logging/context-name} %m%n</pattern>
</PatternLayout>
</File>
实际上通过 log4j2 支持的方法那张图中就可以发现log4j 中 jdni 的用法格式如下:
${jndi:JNDIContent}
既然明确了lookup
是触发漏洞的点,并且找到了可以触发 lookup
的方法 ,那么就可以找入口点,只要找到入口点,然后传入 jndi 调用 ldap 的方式,就能够实现 RCE。
那么,哪一个入口点可以传入${jndi:JNDIContent}
呢?
没错了,就是LogManager.getLogger().xxxx()
方法
在log4j2中,共有8 个日志级别,可以通过LogManager.getLogger()
调用记录日志的方法如下:
LogManager.getLogger().error()
LogManager.getLogger().fatal()
LogManager.getLogger().trace()
LogManager.getLogger().traceExit()
LogManager.getLogger().traceEntry()
LogManager.getLogger().info()
LogManager.getLogger().warn()
LogManager.getLogger().debug()
LogManager.getLogger().log()
LogManager.getLogger().printf()
上述列表中,error()
和fatal()
方法可默认触发漏洞,其余的方法需要配置日志级别才可以触发漏洞。因为在logIfEnabled
方法中,对当前日志等级进行了一次判断:
只有当当前事件的日志等级大于等于设置的日志等级时,才会符合条件,进入logMessage()
方法
知道这些基本信息后,就可以进一步了解漏洞的触发原理了。
测试 case 如下:
public class log4j {
private static final Logger logger = LogManager.getLogger();
public static void main(String[] args) {
Collection<org.apache.logging.log4j.core.Logger> current = LoggerContext.getContext(false).getLoggers();
Collection<org.apache.logging.log4j.core.Logger> notcurrent = LoggerContext.getContext().getLoggers();
Collection<org.apache.logging.log4j.core.Logger> allConfig = current;
allConfig.addAll(notcurrent);
for (org.apache.logging.log4j.core.Logger log:allConfig){
log.setLevel(Level.ALL);
}
logger.error(Level.ALL,"payload");
// logger.warn("payload");
// logger.info("payload");
// logger.debug("payload");
// logger.traceExit("payload");
// logger.trace("payload");
// logger.fatal("payload");
// logger.printf(Level.ALL,"payload");
// logger.traceEntry("payload");
// logger.log(Level.ALL,"payload");
}
}
由于这些调用方法触发漏洞的原理都是一样的,所以本文就以 error 举例说明。
查看 error 的类继承关系可以发现,实际上会调用AbstractLogger.java
中的public void error()
方法:
在该方法中会调用logIfEnabled
判断是否符合日志记录的等级要求,如果符合,那么会进行logMessage
操作:
后续不关键调用路径如下:
logMessage
----> logMessageSafely
----> logMessageTrackRecursion
----> tryLogMessage
----> log
----> DefaultReliabilityStrategy.log
----> loggerConfig.log
----> processLogEvent
----> callAppenders
----> tryCallAppender
----> append
----> tryAppend
----> directEncodeEvent
----> encode
----> toText
---->toSerializable
---->format
----> PatternFormatter.format
第一个关键点在PatternFormatter.java
中的 format
方法:
如果检测到$
字符后跟了一个{
字符,那么会对直到}
中间的内容进行解析并replace
replace
--> substitute
--> StrSubstitutor.substitute
--> resolveVariable
--> Interpolator.lookup
在Interpolator.lookup
方法中,首先会获取字符串的前缀值:
如果匹配到内置方法,那么就进入对应的处理方法,这里是 JNDI 方法,那么就会由JndiLookup
类进一步处理:
最终加载由攻击者传入的LDAP服务端地址,然后返回一个恶意的JNDI Reference对象,触发漏洞,实现 RCE。
0x04 漏洞复现
0x05 写在最后
log4j2涉及的组件之多、牵扯的范围之广,造成的结果,恐怕是漏洞发现者或者是某个公开 poc 的安全公众号都始料未及的。
其实这个漏洞带给我们的不仅仅是一个新的吃饭技能,更多的是一些思考:
第一,安全发展至今,为什么一个 java底层依赖出现漏洞,却导致国内所有大厂全军覆没?虽然个别厂商提前修复,但同样说明了一件事,供应链级别的0day攻击,依旧是无法第一时间防御的。是否存在一种新的机制或方案能够防御供应链级别的0day攻击?
第二,log4j2 项目引入JNDI lookup 已有 7 年之久,但是应用范围如此之广、利用复杂难度之低的漏洞,长达 7 年未被发现,实在有些惭愧(更惭愧的是官方文档还有 jndilookp 的使用说明)
第三,以后挖0day思路真如 skay 说的一样加一:find *.jar => add as library => shift+shift => find log4j => RCE
实际上不仅是 log4j,通常这也是一个挖掘组件依赖漏洞的一种思路
第四
参考
https://logging.apache.org/log4j/2.x/manual/lookups.html
https://github.com/apache/logging-log4j2/pull/608/commits/755e2c9d57f0517a73d16bfcaed93cc91969bdee