Java RMI學習與解讀(一)

CoLoo發表於2021-10-27

Java RMI學習與解讀(一)

寫在前面

本文記錄在心情美麗的一個晚上。

嗯。就是心情很美麗。

那為什麼晚上還要學習呢?

emm... 卷... 捲起來。

全文基本都是根據su18師傅和其他師傅的文章學習的,本文也只是做一個學習的記錄,建議大家最好也是去學習這些師傅們的文章,寫的真的很棒。

About RMI

首先,關於RMI介紹,建議看這篇文章,裡面對不少RMI相關概念解釋的都很清晰。

RMI(Remote Method Invocation) 遠端方法呼叫協議,實現了Java程式之間跨JVM通訊,可以遠端呼叫其他虛擬機器中的物件來執行方法。也就是獲取遠端物件的引用,通過遠端物件的引用呼叫遠端物件的某個方法。

它讓我們獲得對遠端主機上物件的引用,並像在我們自己的虛擬機器中一樣使用它。RMI 允許我們呼叫遠端物件上的方法,將真實的 Java 物件作為引數傳遞並獲取真實的 Java 物件作為返回值。

無論在何處使用引用,方法呼叫都發生在原始物件上,該物件仍位於其原始主機上。如果遠端主機向您返回對其物件之一的引用,您可以呼叫該物件的方法;實際的方法呼叫將發生在物件所在的遠端主機上。

關於遠端呼叫(Remote Invocation)在C語言中的RPC(Remote Procedure Calls遠端過程呼叫)就已經實現了可以在遠端主機上執行C語言函式並返回結果。而C中的RPC與Java中的RMI最大的區別在於,C中主要關注的是資料結構,在進行RPC遠端過程呼叫的時候打包傳輸的資料時相對簡單,而Java中,例如序列化通常是將一整個類直接序列化之後進行傳輸,而類中就需要包含該類的屬性以及方法。那麼RMI相較於RPC而言就不僅僅是傳輸資料結構了,Java需要將整個類(屬性、方法)進行傳輸並且在落地後是要可以呼叫該類中的方法的。

RMI進行傳輸時使用了序列化與反序列化機制,必要時會利用動態類載入和安全管理機制(CC5提到的概念)來安全的傳輸Java類,個人感覺RMI相較於RPC真正的突破在於可以在網路上傳輸資料(物件的屬性)和行為(物件的方法)。

It should be no surprise that RMI uses object serialization, which allows us to send graphs of objects (objects and all of the connected objects that they reference). When necessary, RMI can also use dynamic class loading and the security manager to transport Java classes safely. Thus, the real breakthrough of RMI is that it’s possible to ship both data and behavior (code) around the Net.

遠端與非遠端物件

遠端物件:RMI中的遠端物件首先需要可以序列化;並且需要實現特殊遠端介面的物件,該介面指定可以遠端呼叫物件的哪些方法(這個後面會詳細提到);其次該物件是通過一種可以通過網路傳遞的特殊物件引用來使用的。和普通的 Java 物件一樣,遠端物件是通過引用傳遞。也就是在呼叫遠端物件的方法時是通過該物件的引用完成的。

非遠端物件:非遠端物件與遠端物件相比只是可被序列化而已,並不會像遠端物件那樣通過呼叫遠端物件的引用來完成呼叫方法的操作,而是將非遠端物件做一個簡單地拷貝(simply copied),也就是說非遠端物件是通過拷貝進行傳遞。

Stubs and skeletons

RMI的實現用到了存根Stubs(client端)和骨架Skeletons(server端)

存根Stubs: 什麼是Stubs?之前也說到了:當客戶端在呼叫遠端物件上的方法時,是通過遠端物件的引用呼叫遠端物件的方法,而這個所謂的"遠端物件的引用"實際上是充當該物件代理的原生程式碼,這段程式碼就是存根Stub。

骨架Skeletons:而在呼叫遠端(Server)的目標類之前,也會經過一個對應的遠端代理類,就是骨架 Skeleton,它從 Stubs 中接收遠端方法呼叫並傳遞給真實的目標類。

Stubs 以及 Skeletons 的呼叫對於 RMI 服務的使用者來講是隱藏的,我們無需主動的去呼叫相關的方法。但實際的客戶端和服務端的網路通訊時通過 Stub 和 Skeleton 來實現的。

那麼現在可以小結通過RMI進行遠端方法呼叫時有如下這麼一個簡單的流程:

Client端 ==> 存根Stubs ==> 骨架Skeletons ==> Server端

Remote Interface

上面我們提到了遠端物件需要實現特殊的遠端介面,下面會涉及三個概念:

  1. 遠端物件
  2. 遠端物件所實現的特殊的遠端介面
  3. java.rmi.Remote介面

在使用RMI進行遠端方法呼叫時,首先需要定義這個特殊的遠端介面;而在java.rmi包中有一個介面Remote,實際中遠端物件實現的遠端介面需要extend這個Remote介面,後續遠端物件的建立就實現我們定義的特殊的遠端介面即可。且同時生成的存根Stubs也是如此。

大概是這樣的流程:

java.rmi.Remote ==> 特殊的遠端介面 extends Remote ==> 遠端物件類 implements 特殊的遠端介面

並且在這個特殊的介面中宣告的方法都需要丟擲java.rmi.RemoteException 異常,例如:

import java.rmi.*;

public interface RemoteObject extends Remote{

    String doSomething(String thing) throws RemoteException;

    String say() throws RemoteException;

    String sayGoodbye() throws RemoteException;
}

Remote Object

而遠端物件類通常還需要繼承 java.rmi.server.UnicastRemoteObject 類,在RMI中 UnicastRemoteObject類是與Object超類等效的,該類提供了equals( ) , hashcode( ), toString( )方法;並且在RMI執行時,繼承UnicastRemoteObject類的子類會被exports 出去,繫結隨機埠,開始監聽來自客戶端(Stubs)的請求。

About Export of Remote Object

在 export 時,會隨機繫結一個埠,監聽客戶端的請求,所以即使不註冊,直接請求這個埠也可以通訊,這部分在後面學習與解讀RMI攻擊時會詳細展開。如果不想讓遠端物件成為 UnicastRemoteObject 的子類,後面就需要主動的使用其靜態方法 exportObject 來手動 export 物件。

同時建立遠端物件類需要顯示定義構造方法並丟擲RemoteException,即使是個無參構造也需要如此,不然會報錯。

import java.rmi.*;
import java.rmi.server.UnicastRemoteObject;

public class RemoteObjectImpl extends UnicastRemoteObject implements RemoteObject {

    protected RemoteObjectImpl() throws RemoteException {
    }

    @Override
    public String doSomething(String thing) throws RemoteException {
        return String.format("Doing ", thing);
    }

    @Override
    public String say() throws RemoteException {
        return "This is the say Method";
    }

    @Override
    public String sayGoodbye() throws RemoteException {
        return "GoodBye RMI";
    }
}

關於遠端物件以及遠端物件所需要implements的'特殊的遠端介面'的編寫就大致如上所述。

下面學習一下如何通過RMI進行對遠端物件上某個方法的呼叫,在此之前還需要了解一個概念 RMI registry。

RMI registry

About registry

這個概念很好理解,它類似一個電話薄或者路由表,可以通過登錄檔(RMI registry)來查詢對另一臺主機上已註冊遠端物件的引用

好比通過電話薄根據姓名查詢到某人電話號碼然後通話或者說查詢路由表中某ip的路由,通過那個gateway傳送就能找到該ip的主機。

而在RMI中的登錄檔(registry)就是類似於這種機制,當我們想要呼叫某個遠端物件的方法時,通過該遠端物件在註冊時提供在登錄檔(registry)中的別名(Name),來讓登錄檔(registry)返回該遠端物件的引用,後續通過該引用實現遠端方法呼叫。

登錄檔(registry)由java.rmi.Naming java.rmi.registry.Registry實現。

Naming類提供了進行儲存及獲取遠端物件等操作登錄檔(registry)的相關方法,如bind()實現遠端物件別名與遠端物件之間的繫結。其他的還有如:

查詢(lookup)、重新繫結(rebind)、接觸繫結(unbind)、list(列表)

而這些方法的具體實現,其實是呼叫 LocateRegistry.getRegistry 方法獲取了 Registry 介面的實現類,並呼叫其相關方法進行實現的

比如bind方法的原始碼

    /**
     * Binds the specified <code>name</code> to a remote object.
     *
     * @param name a name in URL format (without the scheme component)
     * @param obj a reference for the remote object (usually a stub)
     * @exception AlreadyBoundException if name is already bound
     * @exception MalformedURLException if the name is not an appropriately
     *  formatted URL
     * @exception RemoteException if registry could not be contacted
     * @exception AccessException if this operation is not permitted (if
     * originating from a non-local host, for example)
     * @since JDK1.1
     */
    public static void bind(String name, Remote obj)
        throws AlreadyBoundException,
            java.net.MalformedURLException,
            RemoteException
    {
        ParsedNamingURL parsed = parseURL(name);
        Registry registry = getRegistry(parsed);

        if (obj == null)
            throw new NullPointerException("cannot bind to null");

        registry.bind(parsed.name, obj);
    }

這個類提供的每個方法都有一個 URL 格式的引數,格式如下://host:port/name

  • host 表示登錄檔所在的主機
  • port 表示登錄檔接受呼叫的埠號,預設為 1099
  • name 表示一個註冊 Remote Object 的引用的名稱,不能是登錄檔中的一些關鍵字

java.rmi.registry.Registry介面在RMI中有兩個實現類RegistryImpl 以及 RegistryImpl_Stub

建立註冊中心(registry)

一般通過LocateRegistry#createRegistry()方法建立

import java.rmi.*;
import java.rmi.registry.LocateRegistry;


public class Registry {
    public static void main(String[] args) {
        try {
          //預設繫結1099埠
            LocateRegistry.createRegistry(1099);
        } catch (RemoteException e) {
            e.printStackTrace();
        }
    }
}

建立Server端

通過Server端將需要呼叫的類(遠端物件類)進行別名與遠端物件的繫結

import java.net.MalformedURLException;
import java.rmi.*;


public class RemoteServer {
    public static void main(String[] args) throws RemoteException, AlreadyBoundException, MalformedURLException {
        //例項化遠端物件類,建立遠端物件
        RemoteObject remoteObject = new RemoteObject();
        //通過Naming.bind()方法繫結別名與 RemoteObject
        Naming.bind("rmi://127.0.0.1:1099/Zh1z3ven", remoteObject);
    }
}

Client端呼叫

建立Client端,通過遠端物件引用實現對遠端方法的呼叫

import java.rmi.*;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.Arrays;

public class RMIClient {
    public static void main(String[] args) throws RemoteException, NotBoundException {
        //建立註冊中心物件
        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        //列印註冊中心中的遠端物件別名list
        System.out.println(Arrays.toString(registry.list()));

        //通過別名獲取遠端物件存根stub並呼叫遠端物件的方法
        RemoteInterface stub = (RemoteInterface) registry.lookup("Zh1z3ven");
        System.out.println(stub.say());
        System.out.println(stub.doSomething("Sing Song"));
        System.out.println(stub.sayGoodbye());

    }
}

這裡用一張Longofo師傅的圖加深下理解

RMI Demo

上面大概將RMI整個過程中的三個角色Client、RMI Registry、Server端簡單的程式碼demo放了出來,下面我們把它揉到一起實現一次簡單的RMI過程。

那麼一般RMI Registry和Server端是在同一端的,我們就把它們放在同一個類中

RMI Registry&Server

import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.Arrays;

public class RegistryServer {
    public static void main(String[] args) {

        try {
            //建立Registry
            Registry registry = LocateRegistry.createRegistry(1099);

            //例項化遠端物件類,建立遠端物件
            RemoteObject remoteObject = new RemoteObject();
            //通過Naming類繫結別名與 RemoteObject
            Naming.bind("rmi://127.0.0.1:1099/Zh1z3ven", remoteObject);
            System.out.println("Registry&Server Start");
            //列印別名
            System.out.println("Registry List: " + Arrays.toString(registry.list()));

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

RMI Client

package Rmi;

import java.rmi.*;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.Arrays;

public class RMIClient {
    public static void main(String[] args) throws RemoteException, NotBoundException {
        //獲取註冊中心物件
        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        //列印註冊中心中的遠端物件別名list
        System.out.println(Arrays.toString(registry.list()));

        //通過別名獲取遠端物件存根stub並呼叫遠端物件的方法
        RemoteInterface stub = (RemoteInterface) registry.lookup("Zh1z3ven");
        System.out.println(stub.say());
        System.out.println(stub.doSomething("Sing Song"));
        System.out.println(stub.sayGoodbye());

    }
}

先執行RMI Registry&Server端

之後啟動RMI Client端

成功呼叫了遠端物件的方法。

如果執行過程丟擲瞭如下圖的異常,一般是埠占用的問題。

建議:

  1. 排查是否1099埠起了別的服務

  2. 因為RemoteInterface是存在於RegistryServer和Client兩端的專案中,那麼這個介面程式碼是需要一致的;且在例項化RemoteObject時,遠端物件的型別應為RemoteInterface。例如:

    RegistryServer:

    RemoteInterface remoteObject = new RemoteObject();

    Client:

    RemoteInterface stub = (RemoteInterface) registry.lookup("Zh1z3ven");

還有一個點就是關於RemoteInterface介面應該在Registry/Server/Client端都存在,否則在registry.lookup之後拿到stub但是無法通過 . 呼叫遠端物件的相關方法。

那麼接下來是當傳遞的引數不是String而是一個物件時需要注意的點,涉及到兩個概念

  1. RMI的動態載入類,java.rmi.server.codebase
  2. Java SecurityManager安全管理機制

RMI 流程

小結一下如何從0實現1次RMI:

  1. 建立遠端物件介面(RemoteInterface)

  2. 建立遠端物件類(RemoteObject)實現遠端物件介面(RemoteInterface)並繼承UnicastRemoteObject類

  3. 建立Registry&Server端,一般Registry和Server都在同一端。

    • 建立註冊中心(Registry)LocateRegistry.getRegistry("ip", port);
    • 建立Server端:主要是例項化遠端物件
    • 註冊遠端物件:通過Naming.bind(rmi://ip:port/name ,RemoteObject) 將name與遠端物件(RemoteObject)進行繫結
  4. 遠端物件介面(RemoteInterface)應在Client/Registry/Server三個角色中都存在

  5. 建立Client端

    • 獲取註冊中心LocateRegistry.getRegistry('ip', prot)
    • 通過registry.lookup(name) 方法,依據別名查詢遠端物件的引用並返回存根(Stub)
  6. 通過存根(Stub)實現RMI(Remote Method Invocation)

RMI 動態載入類

在RMI過程中Client端和Server端的資料傳輸有如下特點:

RMI的Client和Server&Registry進行通訊時是將資料進行序列化傳輸的,所以當我們傳遞一個可序列化的物件作為引數進行傳輸時,在Server端肯定會對其進行反序列化。

關於RMI的動態載入類機制:

如果RMI需要用到某個類但當前JVM中沒有這個類,它可以通過遠端URL去下載這個類。那麼這個URL可以是http、ftp協議,載入時可以載入某個第三方類庫jar包下的類,或者在指定URL時在最後以\結束來指定目錄,從而通過類名載入該目錄下的指定類。

動態載入時用到的是java.rmi.server.codebase屬性,需要將URL賦值給該屬性

一般是通過System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:8080/");設定

或以java -Djava.rmi.server.codebase="http://myserver/foo/"的方式指定URL

還有就是Java SecurityManager機制,這個在CC5中也提到了:

當執行未知的Java程式的時候,該程式可能有惡意程式碼(刪除系統檔案、重啟系統等),為了防止執行惡意程式碼對系統產生影響,需要對執行的程式碼的許可權進行控制,這時候就要啟用Java安全管理器。該管理器預設是關閉的。

而在RMI中進行動態載入類時有一個限制[1]為:

需要設定RMISecurityManager作為安全管理器(SecurityManager),這樣RMI時才會動態載入類。

System.setSecurityManager(new RMISecurityManager());

同時需要給定一個管理策略檔案,該檔案以.policy結尾,內容如下可以給定全部許可權

grant {
    permission java.security.AllPermission;
};

之後可通過讀取靜態資原始檔的方式載入該管理策略

System.setProperty("java.security.policy", RemoteServer.class.getClassLoader().getResource("rmi.policy").toString());

那麼還有一個限制[2]為:

屬性 java.rmi.server.useCodebaseOnly 的值必需為false。但是從JDK 6u45、7u21開始,java.rmi.server.useCodebaseOnly 的預設值就是true。當該值為true時,將禁用自動載入遠端類檔案,僅從CLASSPATH和當前虛擬機器的java.rmi.server.codebase 指定路徑載入類檔案。使用這個屬性來防止虛擬機器從其他Codebase地址上動態載入類,增加了RMI ClassLoader的安全性。

動態載入類主要是分為兩個場景,角色分別為Client和Server

  1. Client端接受通過RMI遠端呼叫Server端某個方法產生的返回值,但是該返回值是個物件且Client端並沒有該物件的類,那麼就可以通過Server端提供的URL去動態載入類。
  2. Server端在RMI過程中收到Client端傳來的引數,該引數可能是個物件,如果該物件對應的類在Server端並不存在,那麼就可以通過Client端提供的URL去動態載入類

場景1:Client端動態載入Server端

測試環境均為JDK7u17

RemoteInterface

import java.rmi.*;

public interface RemoteInterface extends Remote{

    String doSomething(String thing) throws RemoteException;

    String say() throws RemoteException;

    String sayGoodbye() throws RemoteException;

    String sayServerLoadClient(Object name) throws RemoteException;

    Object sayClientLoadServer() throws RemoteException;
}

RegistryServer端的RemoteObject(implements RemoteInterface)

import java.rmi.*;
import java.rmi.server.UnicastRemoteObject;

public class RemoteObject extends UnicastRemoteObject implements RemoteInterface {

    protected RemoteObject() throws RemoteException {
    }

    @Override
    public String doSomething(String thing) throws RemoteException {
        return String.format("Doing ", thing);
    }

    @Override
    public String say() throws RemoteException {
        return "This is the say Method";
    }

    @Override
    public String sayGoodbye() throws RemoteException {
        return "GoodBye RMI";
    }

    @Override
    public String sayServerLoadClient(Object name) throws RemoteException {
        return name.getClass().getName();
    }

    @Override
    public Object sayClientLoadServer() throws RemoteException {
        return new ServerObject();
    }
}

Server端待動態載入的類

import java.io.Serializable;

public class ServerObject implements Serializable {
    private static final long serialVersionUID = 3274289574195395731L;
}

RegistryServer2

import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.Arrays;

public class RegistryServer2 {
    public static void main(String[] args) {

        try {
            System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:8080/");
            //建立Registry
            Registry registry = LocateRegistry.createRegistry(1099);

            //例項化遠端物件類,建立遠端物件
            RemoteInterface remoteObject = new RemoteObject();
            //通過Naming類繫結別名與 RemoteObject
            Naming.bind("rmi://127.0.0.1:1099/Zh1z3ven2", remoteObject);
            System.out.println("Registry&Server Start");
            //列印別名
            System.out.println("Registry List: " + Arrays.toString(registry.list()));

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

RMIClient2

import java.rmi.NotBoundException;
import java.rmi.RMISecurityManager;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.Arrays;

public class RMIClient2 {
    public static void main(String[] args) throws RemoteException, NotBoundException {
        //設定java.security.policy屬性值與RMISecurityManager
        System.setProperty("java.security.policy", RMIClient2.class.getClassLoader().getResource("rmi.policy").getFile());
        System.setSecurityManager(new RMISecurityManager());
        //獲取註冊中心物件
        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        //列印註冊中心中的遠端物件別名list
        System.out.println(Arrays.toString(registry.list()));

        //通過別名獲取遠端物件存根stub並呼叫遠端物件的方法
        RemoteInterface stub = (RemoteInterface) registry.lookup("Zh1z3ven2");
        System.out.println(stub);
        System.out.println(stub.say());
        System.out.println(stub.doSomething("Sing Song"));
        System.out.println(stub.sayGoodbye());

        System.out.println("The Class Name: " + stub.sayClientLoadServer().getClass().getName());

    }
}

測試結果

場景2:Server端動態載入Client端

RMIClient

import java.rmi.*;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.Arrays;

public class RMIClient {
    public static void main(String[] args) throws RemoteException, NotBoundException {
        //將指定URL賦值給codebase
        System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:8080/");
        //建立註冊中心物件
        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        //列印註冊中心中的遠端物件別名list
        System.out.println(Arrays.toString(registry.list()));

        //通過別名獲取遠端物件存根stub並呼叫遠端物件的方法
        RemoteInterface stub = (RemoteInterface) registry.lookup("Zh1z3ven");

        System.out.println(stub.say());
        System.out.println(stub.doSomething("Sing Song"));
        System.out.println(stub.sayGoodbye());
        System.out.println("The Class Name: " + stub.sayServerLoadClient(new ClientObject()));

    }
}

Client端待動態載入的類

這個類限制不多,主要是注意serialVersionUID需要設定一下,以免反序列化時出問題。

import java.io.Serializable;


public class ClientObject implements Serializable {
      private static final long serialVersionUID = 3274289574195395731L;

}

Registry&Server端

import java.rmi.Naming;
import java.rmi.RMISecurityManager;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.Arrays;

public class RegistryServer {
    public static void main(String[] args) {

        try {
            System.setProperty("java.security.policy", RegistryServer.class.getClassLoader().getResource("rmi.policy").getFile());
            System.setSecurityManager(new RMISecurityManager());

            //建立Registry
            Registry registry = LocateRegistry.createRegistry(1099);

            //例項化遠端物件類,建立遠端物件
            RemoteObject remoteObject = new RemoteObject();
            //通過Naming類繫結別名與 RemoteObject
            Naming.bind("rmi://127.0.0.1:1099/Zh1z3ven", remoteObject);
            System.out.println("Registry&Server Start");
            //列印別名
            System.out.println("Registry List: " + Arrays.toString(registry.list()));

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

rmi.policy

grant {
    permission java.security.AllPermission;
};

測試結果

END

本來記錄的時候心情很美麗,結果學起來真的很吃力。

最近有點忙,後續關於RMI攻擊的深入解讀還不知道何時能搞定。

測試程式碼後續會貼到Github上(學的時候沒有新建專案,有點亂需要重新弄一下)

如有錯誤還煩請各位師傅不吝賜教。

Reference

https://www.oreilly.com/library/view/learning-java/1565927184/ch11s04.html

https://su18.org/post/rmi-attack/

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

https://github.com/longofo/rmi-jndi-ldap-jrmp-jmx-jms

相關文章