Java原生序列化漏洞之反序列化输入点

Published: 2021年10月10日

In Vuln.

上一篇记录了反序列化的一些利用链,而反序列化需要两个点,可控的反序列化输入点与存在利用链,本篇关注Java自带协议里的反序列化点...

RMI协议攻击

RMI基础

RPC远程过程调用在大型复杂应用是少不了的,Java实现的叫RMI(Remote Method Invocation),其底层可食用多种协议,本文只关注默认的JRMP协议实现。RMI由三部分组成,Server实现功能并把对象注册到Registry,Client从Registry查询到Server注册对象的位置与其交互,如下:

img

图里除了三个角色还有三种类,对于开发只需要定义类的接口,该接口需继承自Remote接口,它也是空接口标记用,有了接口还需实现该接口的功能classImpl,实现时为了能注册为远程对象以接收调用,它需要继承UnicastRemoteObject类或者在注册前调用UnicastRemoteObject.exportObject()封装它:

public interface HelloInterface extends java.rmi.Remote;
public class HelloImpl extends UnicastRemoteObject implements HelloInterface;

classStubclassSkel是由RMIC自动生成的,分别用于客户端和服务端作为代理去分发调用,其实在1.2后就不再生成Skel了,再之后Stub也不静态生成了,而都是使用动态代理生成,但是Registry和DGC还会使用旧的方式,之后会看到在服务分发时会有两条路径,只有这两个是走旧路径...

现在先来康康一些类或接口,它们大都在rt.jar!sun.rmi.*下:

// 公共类
public interface Remote {};  // 空接口,标记用
public abstract class RemoteObject implements Remote, Serializable;  // Object的Remote版
public abstract class RemoteServer extends RemoteObject; // 
public class UnicastRemoteObject extends RemoteServer; // 用于导出一个使用JRMP的远程对象和与远程对象通信的Stub
abstract public class RemoteStub extends RemoteObject;  // 静态生成的Stub的父类,用于提供远端通信
public interface Skeleton;  // 静态生成的Skel的父类

// 引用,表示远端对象的句柄
public interface RemoteRef extends java.io.Externalizable;  // 引用的通用接口,调用stub会作用到它的invoke方法上
public class UnicastRef implements RemoteRef;  // 引用的一个具体实现,它内部使用了LiveRef
public class LiveRef implements Cloneable;  // 它存储具体的引用,含Endpoint表示具体的地址(TCP的IP:PORT),ObjID表示一个远程对象的ID(可直接指或JVM分配)等信息
public interface ServerRef extends RemoteRef;  // 服务端句柄
public class UnicastServerRef extends UnicastRef implements ServerRef, Dispatcher; // 服务端远程句柄实现

// 注册中心相关的类
public interface Registry extends Remote; // 注册中心的接口,注册中心是特殊的远程对象,可远程调用,因此也要像其他对象一样继承Remote
public class RegistryImpl extends RemoteServer implements Registry;  // 注册中心的实现,这里并没有继承UnicastRemoteObject,但最终它在使用时会封装
public final class RegistryImpl_Skel implements Skeleton;  // 注册中心的Skel
public final class RegistryImpl_Stub extends RemoteStub implements Registry, Remote;  // 注册中心的Stub

RMI是从获取注册中心开始的,注册中心可能位于本地也可能为位于远端,Java提供了LocateRegistry类用于获取注册中心实例或引用,它提供两类方法:

public static Registry createRegistry(int port);  // 在本地创建/获取注册中心实例,返回为RegistryImpl对象
public static Registry getRegistry(String host, int port);  // 在远程获取注册中心引用,返回为RegistryImpl_Stub对象

由于我们使用使只用它的方法(接口对就行),所以一般不会意识到它们的不同,实际上它们返回的对象不同,因此在对其进行操作时内部过程也会有区别,createRegistry返回的会直接操作内部的map属性,而getRegistry返回的需要使用Skel代理才能作用于真正的Registry,对一个远端访问,其分发流程如下:

public class UnicastServerRef extends UnicastRef implements ServerRef, Dispatcher{
    public void dispatch(Remote obj, RemoteCall call) throws IOException {
        try {
            // read remote call header
            ObjectInput in = call.getInputStream();
            int num = in.readInt();
            if (num >= 0) {  
                oldDispatch(obj, call, num);  // 1.1(兼容模式)之前走的流程
                return;
            }
            // 1.2之后走的流程
            long op = in.readLong();
            MarshalInputStream marshalStream = (MarshalInputStream) in;
            marshalStream.skipDefaultResolveClass();
            Method method = hashToMethod_Map.get(op);       // 获取方法
            Class[] types = method.getParameterTypes();     // 获取方法的参数类型
            Object[] params = new Object[types.length];
            unmarshalCustomCallData(in);
            for (int i = 0; i < types.length; i++) {
                params[i] = unmarshalValue(types[i], in);   // 反序列化参数
            }
            Object result = method.invoke(obj, params);     // 调用方法
            ObjectOutput out = call.getResultStream(true);  // 获取返回值
            Class<?> rtype = method.getReturnType();
            if (rtype != void.class) {
                marshalValue(rtype, result, out);           // 序列化返回值
            }
        } catch (Throwable e) {
            ObjectOutput out = call.getResultStream(false);
            out.writeObject(e);                             // 若出现一场,将异常返回给调用者,这里可以回显结果,若能自定义异常内容的话,如能自定义类...
    }

攻击注册中心

上面可见有两个流程,注册中心和分布式垃圾回收使用的是以前的方法,于是看oldDispatch,如下:

    public void oldDispatch(Remote obj, RemoteCall call, int op) throws IOException{
        try {
            ObjectInput in = call.getInputStream();
            try {
                Class<?> clazz = Class.forName("sun.rmi.transport.DGCImpl_Skel");
                if (clazz.isAssignableFrom(skel.getClass())) {
                    ((MarshalInputStream)in).useCodebaseOnly();
                }
            } catch (ClassNotFoundException ignore) { }
            long hash = in.readLong();
            unmarshalCustomCallData(in);
            skel.dispatch(obj, call, op, hash);  // 分发
        } catch (Throwable e) {
            ObjectOutput out = call.getResultStream(false);
            out.writeObject(e);
    }
}

接着还是要跟入具体的Skel,这里是RegistryImpl_Skel,没有对应的源码,它是由rmic生成的,直接看反编译代码即可:

// 操作如下,
new Operation("void bind(java.lang.String, java.rmi.Remote)")  // 0
new Operation("java.lang.String list()[]")  // 1
new Operation("java.rmi.Remote lookup(java.lang.String)")  // 2
new Operation("void rebind(java.lang.String, java.rmi.Remote)")  // 3
new Operation("void unbind(java.lang.String)")  // 4
public final class RegistryImpl_Skel implements Skeleton {
    public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception {
        RegistryImpl var6 = (RegistryImpl)var1;
        ObjectInput var8;
        switch(var3) {
            case 0:
                RegistryImpl.checkAccess("Registry.bind");  // 是否有权访问,默认只能本地访问,之前没有或使用不正确

                try {
                    ObjectInput var9 = var2.getInputStream();
                    String var7 = (String)var9.readObject();
                    Remote var80 = (Remote)var9.readObject();
                } catch (ClassNotFoundException | IOException var77) {
                    throw new UnmarshalException("error unmarshalling arguments", var77);
                } finally {
                    var2.releaseInputStream();
                }
                var6.bind(var7, var80);                     // 注册
    ...

到这里看到了,注册中心提供很多功能,其中list无参数只是列出也算有用,可以看看注册了哪些对象,没准可利用,其他的方法都是有参数的,而且它还挺懒的,像Name既然规定了是字符串这里就用字符串读嘛,偏要用对象读,所以这些点就是反序列化点,当然之后有很多修复,一个一个列:

访问权限检测

bind/unbind/rebind会检查权限,默认只能本地访问,最初是反序列化后再检查,这样虽然不能操作成功但是能反序列化成功,后来在JDK8u141彻底修复了,但是lookup可以传参但没检查,因此它也可以搞,见RMIRegistryExploit...

反序列化过滤器

这是JEP290引入的(JDK8u121/JDK7u131/JDK6u141),如下:

public class RegistryImpl extends RemoteServer implements Registry {
    public RegistryImpl(int var1, RMIClientSocketFactory var2, RMIServerSocketFactory var3) throws RemoteException {
        this(var1, var2, var3, RegistryImpl::registryFilter);  // 实例化时注册了过滤器
    }

    private static Status registryFilter(FilterInfo var0) { // 过滤器如下,若未在参数或配置文件中指定,则使用默认的
        if (registryFilter != null) {
            Status var1 = registryFilter.checkInput(var0);
            if (var1 != Status.UNDECIDED) {
                return var1;
            }
        }
        if (var0.depth() > 20L) {
            return Status.REJECTED;
        } else {
            Class var2 = var0.serialClass();
            if (var2 != null) {
                if (!var2.isArray()) {
                    // 默认支持如下类的子类...
                    return (String.class != var2 && 
                            !Number.class.isAssignableFrom(var2) && 
                            !Remote.class.isAssignableFrom(var2) && 
                            !Proxy.class.isAssignableFrom(var2) && 
                            !UnicastRef.class.isAssignableFrom(var2) && 
                            !RMIClientSocketFactory.class.isAssignableFrom(var2) && 
                            !RMIServerSocketFactory.class.isAssignableFrom(var2) && 
                            !ActivationID.class.isAssignableFrom(var2) && 
                            !UID.class.isAssignableFrom(var2) 
                            ? Status.REJECTED : Status.ALLOWED;
                } else {
                    return var0.arrayLength() >= 0L && var0.arrayLength() > 1000000L ? Status.REJECTED : Status.UNDECIDED;
                }
            } else {
                return Status.UNDECIDED;
            }
        }
    }
}
// 它会传给UnicastServerRef,而在之前的dispatch过程中,
public class UnicastServerRef extends UnicastRef implements ServerRef, Dispatcher{
    public void dispatch(Remote obj, RemoteCall call) throws IOException {
            unmarshalCustomCallData(in);  // 它会在调用readObject前被调用
    }
    protected void unmarshalCustomCallData(ObjectInput var1) throws IOException, ClassNotFoundException {
        if (this.filter != null && var1 instanceof ObjectInputStream) {
            final ObjectInputStream var2 = (ObjectInputStream)var1;
            Config.setObjectInputFilter(var2, UnicastServerRef.this.filter);  // 设置了过滤器,拜拜...
        }
    }}

这时注册中心就没法反序列化之前如CC中的恶意类了,但是可以从白名单中找一些类,如LiveRef,它会在反序列化时注册到DGC里,DGC之后会调用makeDirtyCall,于是可以在这里返回恶意的序列化数据(见JRMPClient和JRMPClient),如下:

public class UnicastRef implements RemoteRef {
    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
        ref = LiveRef.read(in, false);  // UnicastRef反序列化会构造LiveRef
        ...
public class LiveRef implements Cloneable {
    public static LiveRef read(ObjectInput in, boolean useNewFormat) throws IOException, ClassNotFoundException {
        Endpoint ep = TCPEndpoint.read(in);
        ...
        LiveRef ref = new LiveRef(id, ep, false);
        if (in instanceof ConnectionInputStream) {
            ConnectionInputStream stream = (ConnectionInputStream)in;
            stream.saveRef(ref);  // 这里会把引用保存到stream的incomingRefTable
            ...
class ConnectionInputStream extends MarshalInputStream {
    void saveRef(LiveRef ref) {  // 保存引用,在所有参数/返回值被反序列完后,发送dirty call
        Endpoint ep = ref.getEndpoint();
        List<LiveRef> refList = incomingRefTable.get(ep);  // 保存在了incomingRefTable里
        if (refList == null) {
            refList = new ArrayList<LiveRef>();
            incomingRefTable.put(ep, refList);
        }
        refList.add(ref);
    }
    void registerRefs() throws IOException {  // 将incomingRefTable里的所有数据添加到DGCC
        if (!incomingRefTable.isEmpty()) {
            for (Map.Entry<Endpoint, List<LiveRef>> entry :
                     incomingRefTable.entrySet()) {
                DGCClient.registerRefs(entry.getKey(), entry.getValue());
                ...

public class StreamRemoteCall implements RemoteCall {
    public void releaseInputStream() throws IOException {
        in.registerRefs();  // 添加到DGC...
        in.done(conn);
        conn.releaseInputStream();
    }
    public void done() throws IOException {
        releaseInputStream();
    }
}

final class DGCClient {
    static void registerRefs(Endpoint ep, List<LiveRef> refs) {
        do {
            EndpointEntry epEntry = EndpointEntry.lookup(ep);
        } while (!epEntry.registerRefs(refs));
    }
    private static class EndpointEntry {
        public boolean registerRefs(List<LiveRef> refs) {
            // 要素过多,就是把EndPoint取出来放refsToDirty里,再调用makeDirtyCall
            makeDirtyCall(refsToDirty, sequenceNum);
            return true;
        }
        private void makeDirtyCall(Set<RefEntry> refEntries, long sequenceNum) {
            Lease lease = dgc.dirty(ids, sequenceNum, new Lease(vmid, leaseValue));  // 这是个远程调用...
    ...

不过JDK8u231在dirty函数也加了过滤器,而且在出异常时直接把列表清空了不让调用dirty...

注:(1). 除了用本地利用链,RMI还支持指定codebase,就是客户端可为服务端指定,反之亦然,于是在反序列化本地不存在的类时将从codebase加载类,不过限制很多(JDK 6u45/7u21之后加的)基本不会出现不多说...

(2). 上面用到了DGC反连相当于攻击客户端,除此之外本来客户端和服务端可以相互攻击,一般都是打服务所以不说客户端了,思路一样...

攻击注册的对象

先用list看看有哪些,若对象的参数是非原始类型的,就会调用readObject,显然这个点没法用默认的过滤器防所以没有防,另外没准里面有些方法本来就是危险方法直接拿来用也很香啊(想🍑...

JNDI

JNDI基础

JNDI即Java命名和目录接口(Java Naming and Directory Interface),分开看就是名称服务和目录服务的接口,它对外提供了标准的API,而底层支持多种具体协议,用户也可使用SPI自己实现,架构图如下:

img

本文只关注RMI/LDAP,其实CORBA也出过,见Java CORBA隐秘的角落 -- JDK CORBA 安全性研究(下),关于JNDI细节的可以看看JNDI Guide不多说。JNDI维护着上下文与绑定对象,绑定属于某个上下文,除初始上下文外其他上下文属于它的父级上下文,一般使用如下代码查询对象:

Context context = new InitialContext();
context.lookup(jndiName);

这里面的绑定的对象有两种存储形式:

  1. 序列化:按某种方式将其序列化为数据存储,也可使用Java原生的序列化实现,缺点是占空间,有些对象不能完全序列化
  2. 引用:存储对象的工厂类与属性信息,在查询时重新有工厂类根据属性信息生成对象实例

于是攻击面有两个,序列化与工厂类,下面分别描述。

利用引用

这是JNDI最有名的利用方式,也直接把它叫JNDI注入,简单的说能控制lookup的参数就能(远程)加载任意类,这是由它支持存引用决定的,引用形式需要类有匹配的工厂类,它在存储时调用getStateToBind生成引用形式(可以不实现),在获取时调用getObjectInstance生成对象,其类关系图如下:

img

当获取的引用时,需要根据引用里的工厂类和额外的信息构造处期望的对象,引入类定义如下:

public class Reference implements Cloneable, java.io.Serializable {
    protected String className;  // 对象的类全名
    protected Vector<RefAddr> addrs = null;  // 其他数据,存储实例的信息,用于工厂类重建对象实例
    protected String classFactory = null;  // 工厂类的名称,工厂根据引用数据创建对象
    protected String classFactoryLocation = null;  // 工厂类的位置
}

可以看到它竟然可以指定工厂类的位置,因此我们写个恶意的工厂类,在它类初始化/实例初始化/getObjectInstance等位置插入恶意代码即可让受害者加载并执行它...

然而,这个点被修啦:

对象 版本 说明
RMI/CORBA JDK 6u141/7u131/8u121 增加了com.sun.jndi.rmi.object.trustURLCodebase选项,默认为false,禁止RMI/CORBA协议使用远程codebase的选项
LDAP JDK 6u211/7u201/8u191 增加了com.sun.jndi.ldap.object.trustURLCodebase选项,默认为false,禁止LDAP协议使用远程codebase的选项

虽然禁止加载远程的,但是本地的还是不限,最有名的就是Tomcat的BeanFactory,它可以构造并调用[12]:

  • 有public修饰的无参构造方法
  • public修饰的只有一个String.class类型参数的方法,且该方法可以造成漏洞

这里面最出名的有Tomcat8内置(SpringBoot1.2也行,它内置的Tomcat也是8以上)的javax.el.ELProcessor#evalGroovygroovy.lang.GroovyShell#evaluate,以前者为例它能利用引用里面的信息执行任意表达式:

ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true, "org.apache.naming.factory.BeanFactory", null);
ref.add(new StringRefAddr("forceString", "x=eval"));
ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['/bin/sh','-c','open /Applications/Calculator.app']).start()\")"));

存在BeanFactory时还能利用SnakeYaml/XStream反序列化漏洞,MVEL执行表达式,NativeLibLoader加载动态库。

而其他工厂类,如数据库连接的,可利用h2执行Java代码实现RCE,这些详见[12]。

利用本地利用链

它底层本身也有反序列化,若还存在利用链即可...

JMX

Java管理扩展用于管理/监视应用,它管理的是MBean对象,这类对象除了符合Bean(属性私有用getter/setter获取,存在无参构造器)外,实现自定义的接口,该接口名必须是类名+MBean,JMX可远端访问,默认用的是RMI,攻击手段就多种多样了

img

利用存在的MBean

MLet

这是个特殊类,可用于从远端加载类,使用时有两个限制:

1.不能有用户鉴权,有认证时默认的权限是不支持该操作的

2.不能有安全管理器,有的话默认的策略是不支持该操作的

在没有这两个限制时,则可以编写一个恶意类与mlet文件将其放入http目录下,创建javax.management.loading.MLet实例并调用getMBeansFromURL加载恶意类,详见[6]/[10]

其他类

其他类的属性可能会泄露一些信息,如会话ID,或提供一些敏感操作,如修改日志的路径,或者方法参数是非原始类型,这时有了个反序列化输入点...

利用RMI

JMX会启动Registry并把自己注册为jmxrmi,因此之前攻击RMI的那套可以弄上来...

攻击鉴权过程

之前的鉴权是用HashMap传递鉴权信息,显然在鉴权前就要发生反序列化,这个已经修了(CVE-2016-3427)...

参考

[1] JAVA RMI 反序列化知识详解 -- 天融信阿尔法实验室

[2] 关于 Java 中的 RMI-IIOP -- Longofo@知道创宇404实验室

[3] Java 中 RMI、JNDI、LDAP、JRMP、JMX、JMS那些事儿(上) -- Longofo@知道创宇404实验室

[4] Java 安全-RMI-学习总结 -- p1g3@D0g3

[5] Java 反序列化过程中 RMI JRMP 以及 JNDI 多种利用方式详解 -- Alpha@天融信阿尔法实验室

[6] attacking-rmi-based-jmx-services -- Hans-Martin Münch@h0ng10

[7] JNDI with RMI -- wh1t3p1g

[8] JAVA JNDI 注入知识详解 -- 天融信阿尔法实验室

[9] 浅谈Java RMI Registry安全问题 -- wh1t3p1g

[10] 攻击Java JMX-RMI -- wh1t3p1g

[11] JNDI with LDAP -- wh1t3p1g

[12] 探索高版本 JDK 下 JNDI 漏洞的利用方法 -- 浅蓝