Skip to content

浅学RMI

前言

RMI攻击手法只对jdk8u121之前有效,在8u121之后,bind,rebind,unbind这三个方法只能对本地进行攻击。

RMI

RMI是什么

RMI全称 Remote Method Invokcation,它允许一个Java程序中的对象调用运行在另一个JVM中的正在运行的对象方法,就像在本地调用一样。两者之间通过底层Socket进行通信。

RMI使用的通信协议是JRMP(Java Remote Message Protocol),这协议是为Java定制的协议,只能用于两个Java程序之间进行通信。

RMI主要组成

RMI主要由三个组件组成Server,Client,Registry.

Server将想要被调用的对象放置到通过包装放到某个端口之中

Client通过lookup进行查找

因为一个远程对象对应了一个Socket,一个Socket对应了一个端口,所以Client想要调用远程对象首先得知道那个对象对应的端口在哪。

但是Server放置对象是随机放置的,具体端口不容易知道。所以Registry就起到了一个中转作用,Registry一般运行在Server的1099端口,Registry起到了一个类似于Map的作用,将String作为Key,远程对象作为value,Client只需要知道key就能通过Registry获取到一个value的stub。(后面再解释Stub是什么)

简单实现

  1. 先编写一个远程接口
1
2
3
public interface RemoteObj extends Remote{
pubilc void hello() throws RemoteException;
}

这接口有三个要求

  1. 作用域为public

  2. 继承Remote

  3. 让其接口中的方法抛出RemoteException

  4. 接下来定义该接口的实现类

1
2
3
4
5
6
7
public class RemoteImpl extends UnicastRemoteObject implements RemoteObj{
public RemoteImpl() throws RemoteException {
}
public void hello() {
System.out.println("hello");
}
}

实现类的要求

  1. 实现远程接口

  2. 继承UnicastObject

  3. 构造函数抛出RemoteException

  4. 实现类中所有使用的对象都得实现Serialize接口,因为Server和Client是通过序列化进行数据传输

  5. 接下来注册远程对象

1
2
3
4
5
6
7
public class Server {
public static void main(String[] args) throws RemoteException {
RemoteImpl remote = new RemoteImpl();
Registry registry = LocateRegistry.createRegistry(1099);
registry.rebind("Impl", remote);
}
}

至此服务端就写好了

客户端只需去Registry那获取对象即可,但是因为要给获取到的对象一个编译类型,所以客户端也得有一个RemoteObj接口

1
2
3
public interface RemoteObj extends Remote{
pubilc void hello() throws RemoteException;
}

从Wireshark抓包分析RMI通信流程

这部分是复制的其他师傅的文章,讲的非常好

数据端与注册中心(1099 端口)建立通讯

  • 客户端查询需要调用的函数的远程引用,注册中心返回远程引用和提供该服务的服务端 IP 与端口。

img

数据端与注册中心(1099 端口)建立通讯完成后,RMI Server 向远端发送了⼀个 “Call” 消息,远端回复了⼀个 “ReturnData” 消息,然后 RMI Server 端新建了⼀个 TCP 连接,连到远端的 33769 端⼝

img

AC ED 00 05是常见的 Java 反序列化 16 进制特征
注意以上两个关键步骤都是使用序列化语句

客户端新起一个端口与服务端建立 TCP 通讯

客户端发送远程引用给服务端,服务端返回函数唯一标识符,来确认可以被调用

img

同样使用序列化的传输形式

以上两个过程对应的代码是这两句

JAVA

1
2
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);  
RemoteObj remoteObj = (RemoteObj) registry.lookup("remoteObj"); // 查找远程对象

这里会返回一个 Proxy 类型函数,这个 Proxy 类型函数会在我们后续的攻击中用到。

客户端序列化传输调用函数的输入参数至服务端

  • 这一步的同时:服务端返回序列化的执行结果至客户端

img

以上调用通讯过程对应的代码是这一句

JAVA

1
remoteObj.sayHello("hello");

可以看出所有的数据流都是使用序列化传输的,那必然在客户端和服务带都存在反序列化的语句。

总结一下 RMI 的通信原理

实际建⽴了两次 TCP 连接,第一次是去连 1099 端口的;第二次是由服务端发送给客户端的。

在第一次连接当中,是客户端连 Registry 的,在其中寻找 Name 为 hello 的对象,这个对应数据流中的 Call 消息;然后 Registry 返回⼀个序列化的数据,这个就是找到的 Name=Hello 的对象,这个对应数据流中的ReturnData消息。

到了第二次连接,服务端发送给客户端 Call 的消息。客户端反序列化该对象,发现该对象是⼀个远程对象,地址在 172.17.88.209:24429,于是再与这个地址建⽴ TCP 连接;在这个新的连接中,才执⾏真正远程⽅法调⽤,也就是 sayHello()

RMI Registry 就像⼀个⽹关,他⾃⼰是不会执⾏远程⽅法的,但 RMI Server 可以在上⾯注册⼀个 Name 到对象的绑定关系;RMI Client 通过 Name 向 RMI Registry 查询,得到这个绑定关系,然后再连接 RMI Server;最后,远程⽅法实际上在 RMI Server 上调⽤。

原理图如图

#

那么我们可以确定 RMI 是一个基于序列化的 Java 远程方法调用机制。

通过这一部分我们就可以知道

1
RemoteObj remoteobj = (RemoteObj) registry.lookup("Impl");

这一条语句Client向Registry发送数据包,Registry返回序列化的对象(这个对象就是前文提到的stub),对象中包含了远程对象的地址,Client接收到了之后对其进行了反序列化,并赋值给了remoteobj

1
remoteobj.hello();

这条语句就是Client发送序列化后的信息,Server反序列化并调用hello(),再将输出的结果序列化后返回给Client.

Registry就是一个地址簿,Server告诉他远程对象的名字是什么,地址在哪。Client问Registry它想找的远程对象的地址在哪,Registry告诉Client它地址,最后Client与Server进行直接通信,至于为什么最开始Client不与Server直接通信,前文已经说过,因为Server分配远程对象端口时是随机分配的。

RMI通讯原理源码分析

Server端如何发布远程对象

这部分对应的是蓝色行的语句image-20250911153716721

步入,因为RemoteImpl构造函数什么都没写所以直接进入其父类的构造函数,前文我们构造这个Impl时让其继承了UnicastRemoteObject

image-20250911154230748

这里传入的port为0代表了随机端口

image-20250911154347018

exportObject函数很关键,就是这个函数将对象绑定到随机端口上。

继续调试

步入UnicastRemoteObject.exportObject(Remote,port)

image-20250911154845345

可以看到这里创建了一个Ref,步入看看

image-20250911154947688

先看看super干了什么

image-20250911155027213

将这个新建的LiveRef赋值给UnicastServerRef的父类UnicastRef.ref,要注意的是LiveRef至始至终只会存在一个

LiveRef的创建过程就不细看了,看一下创建好了的LiveRef的属性

image-20250911155650446

可以看到liveRef装着一个对应着Server端某个随机端口的TCPEndpoint

TCPEndpoint类封装了一些TCP的东西,只需要调用它的方法就可以使Client和Server通信。现在有了这个远程对象对应的TCPEndpoint.

创建好UnicastServerRef后,回到

image-20250911160206437

步入

image-20250911160622098

将UnicastServerRef赋值给UnicastRemoteObject.ref(其实UnicastRemoteObject没有ref,这是赋值给它的父类RemoteObject.ref),调用UnicastServerRef.exportObject()

image-20250911161637498

这里出现了Stub,可以看到stub其实就是一个动态代理对象Proxy,下面还有个将各种对象封装起来的Target,步入209行ref.exportObject(target)此处的Ref就是之前创建的LiveRef

image-20250911205304633

调用LiveRef.ep.exportObject()即TCPEndpoint.exportObject(),TCPEndpoint这个类前文已经提过

image-20250911210305060

TCPEndpoint.transport是一个TCPTransport

image-20250911210907530

注意看上图的绿字,翻译过来就是将对象暴露出去从而能接收传入的调用,进入listen函数

image-20250911211407239

335行步入newServerSocket()

image-20250911213600827

光标所指的函数就是随机分配一个port给这个ServerSocket,与我们前文提到的Server端会随机为对象分配一个端口对应了

回到listen函数,注意341-344行,进入AcceptLoop构造函数。因为AcceptLoop实现了Runnable接口,所以new NewThreadAction()在new AcceptLoop后会调用acceptLoop.run(),进入executeAcceptLoop()

image-20250911211516023

image-20250911211851868

还是注意上面的绿字。接收服务端的连接,并在线程池中为其执行处理程序,因为在listen函数中是为AcceptLoop.run()单开了一个线程的。

所以listen()的作用其实就是监听随机分配的端口

image-20250911215653196

可以看到现在ref中port有值了,不再是之前的0了,进入super.exportObject

image-20250911215957540

这两条语句就是把target的信息放进一些map里,与日志相当。

以上就是Server发布远程服务的大致过程。

小结一下,我对这个发布远程服务的大致印象是要发布的类继承了UnicastRemoteObject,在实例化的过程中调用了其父类的构造函数,之后的流程就是不断地调用不同类的exportObject方法,最终创建了一个ServerSocket并且将要封装了各种各样的类的target对象给绑定在了一起,在RMI流程中很重要的stub在这一环节被创建了,是一个动态代理类。

注册中心如何被发布

其实注册中心Registry与远程对象发布流程几乎一模一样

image-20250915135721752

image-20250915135804251

因为没有安全管理器所以直接进入else,可以看到这里也创建了一个LiveRef

进入setup

image-20250915135945752

后面跟远程对象的流程几乎一模一样

不停调用别的类的exportObject

image-20250915140040649

但是这里因为RegistryImpl的特殊性,创建stub的步骤有点不一样,进入createProxy

image-20250915140152394

发布自定义远程对象时这个判断是false直接跳过,但是进入这个判断看一下

image-20250915140249515

image-20250915141151461

返回true

image-20250915163756734

image-20250915163827064

返回了RegistryImpl_Stub实例

image-20250915163924761

这一步是发布自定义类没有的,因为Proxy没有实现RemoteStub,但是RegistryImpl_Stub实现了

image-20250915164040765

Skeleton是干嘛的暂时别管,后面会解释。withoutSkeleton现在也是空的,进入createSkeletonimage-20250915164336021

很简单,就是加载RegistryImpl_Skel并实例化

回到exportObject可以看到后面的Target和LiveRef.exportObject和发布远程自定义类一模一样。后面的环节就不赘述了

image-20250915165248246

再看下这句

image-20250915165328929

bindings就是一个HashTable,将名字和对象放进去而已。

客户端与注册中心的交互-客户端

RMI中客户端去找远程对象进行调用最开始是去找Registry

image-20250915170648028

跟注册中心创建RegistryImpl_Stub的流程一样,Util.createProxy返回了一个RegistryImpl_Stub,所以说registry其实是RegistryImpl_Stub

image-20250915170849913

获取到了RegistryImpl_Stub,接下来进行查找

image-20250915173957258

进入newCall看看

image-20250915174113198

LiveRef.getChannel获取了一个管道,new Connection与目标对象创建了连接

最后返回了StreamRemoteCall这个类其实也是对Connection的封装,

image-20250916095912198

可以看到这个类的方法名大多都是对connection中输入输出流的操作

image-20250916100032383

回到lookup继续调试

获取call后之后再获取call中的输出流并写入我们要查找的对象名,进入ref.invoke看看

invoke调用了executeCall,这个函数大致分为两部分

image-20250916103056004

第一部分释放输出流并读取输入流前面几个字节,根据返回的类型returnType决定后面的操作

image-20250916103223905

可以看到如果返回的类型是个异常,就会反序列化这个类,如果说Registry是个恶意的Registry,这里就存在反序列化漏洞了。

回到lookup

image-20250916103607079

这里也存在一个反序列化洞,但是这个洞没有刚才那里用途广泛,因为这是写在lookup里面的,而ref.invoke在RegistryImpl_Stub#bind/rebind/list/lookup/unbind都有,因为ref.invoke(call)是用来与Registry实现通信的

lookup最后返回反序列化的对象

客户端与服务端交互-客户端

image-20250916122222941

image-20250916122456583

image-20250916122515694

image-20250916122546821

序列化调用方法的参数

image-20250916122631368

String不属于上面的类型所以writeObject写入输出流中

image-20250916122749214

反序列化服务端返回的序列化数据

客户端与注册中心的交互-注册中心

我累了,以后再学吧

About this Post

This post is written by DashingBug, licensed under CC BY-NC 4.0.