RMI基础

一、RMI前言介绍

RMI作为后续漏洞中最为基础的利用手段之一,非常需要进行学习和深入理解。
需要注意的是:

  • 影响版本:<=jdk8u121;
  • 原因:>jdk8u121,bind、rebind、unbind三个方法只能对localhost进行攻击;

二、RMI基础

2.1 RMI介绍

RMI 全称 Remote Method Invocation(远程方法调用):在一个 JVM 中 Java 程序调用在另一个远程 JVM 中运行的 Java 程序,这个远程 JVM 既可以在同一台实体机上,也可以在不同的实体机上,两者之间通过网络进行通信。

RMI 依赖的通信协议为 JRMP(Java Remote Message Protocol,Java 远程消息交换协议),该协议为 Java 定制,要求服务端与客户端都为 Java 编写。

JRMP协议如HTTP协议一样,规定了客户端和服务端要满足的规范

  • RMI包括以下三个部分
  • Server服务端:服务通过绑定远程对象,这个对象可以封装很多网络操作,也就是Socket
  • Client客户端:客户端调用服务端的方法

因为有了C/S的交互,而且Socket是对应端口的,这个端口是动态的,所以这里引进了第三个RMI的部分——Registry部分

  • Registry注册端:提供服务注册与服务获取。即Server端向Registry注册服务,比如地址、端口等一些信息,Client端从Registry获取远程对象的一些信息,如地址、端口等,然后进行远程调用。

2.2 RMI的实现

这里为了便于理解,将服务端和客户端分为两个模块进行编写。

服务端RMI_Server

首先定义一个远程接口,其中定义一个sayHello()的方法

  • RemoteObj.java(编写一个远程接口,其中定义了一个 sayHello() 的方法)
1
2
3
public interface RemoteObj extends Remote {
public String sayHello(String keywords) throws RemoteException;
}

要求:

  1. 此远程接口要求作用域为public
  2. 继承Remote接口;
  3. 让其中的接口方法抛出异常;
  • RemoteObjImpl.java(定义该接口的实现类 Impl)
1
2
3
4
5
6
7
8
9
10
11
12
public class RemoteObjImpl extends UnicastRemoteObject implements RemoteObj {
public RemoteObjImpl() throws RemoteException {

}

@Override
public String sayHello(String keywords) throws RemoteException {
String upKeywords = keywords.toUpperCase();
System.out.println(upKeywords);
return upKeywords;
}
}

要求:

  1. 实现远程接口
  2. 继承UnicastRemoteObject类,用于生成Stub(存根)和Skeleton(骨架)。
  3. 构造函数需要抛出一个RemoteException错误
  4. 实现类中的对象必须都可序列化,即都继承java.io.Serializeable
  • RMIServer.java(注册远程对象)
1
2
3
4
5
6
7
public class RMIServer {
public static void main(String[] args) throws RemoteException {
RemoteObj remoteObj = new RemoteObjImpl();
Registry registry = LocateRegistry.createRegistry(1099);
registry.rebind("remoteObj", remoteObj);
}
}

port默认是1099端口,不写会自动补上,其他端口必须写;

bind的绑定,只要和客户端去查找的registry一致即可


客户端RMI_Client

客户端需要从注册器中获取远程对象,然后调用方法。当客户端还需要一个远程对象的接口,不然不知道获取回来的对象是什么类型的。

  • RemoteObj.java(编写一个接口,定义远程对象类型)
1
2
3
public interface RemoteObj extends Remote {
public String sayHello(String keywords) throws RemoteException;
}
  • RMIClient.java(编写客户端的代码,获取远程对象,并调用方法)
1
2
3
4
5
6
7
public class RMIClient {
public static void main(String[] args) throws RemoteException, NotBoundException {
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
RemoteObj remoteObj = (RemoteObj)registry.lookup("remoteObj");
remoteObj.sayHello("hello");
}
}

这样我们就能够从远端服务端中调用RemoteHelloWorld对象的sayHello()方法了。

三、从Wireshark抓包分析RMI通信原理

在这里直接说明RMI的通信原理,详细的分析流程可以参考从Wireshark抓包分析RMI通信原理

在客户端远程调用Java程序的过程中其实建立了两次TCP连接,第一次连接是连接1099端口;第二次连接是由服务端发送给客户端的。

  • 第一次连接:客户端连接Registry在其中寻找Name为hello的对象,这个对应数据流中的Call消息,然后Registry返回一个序列化的数据,这个就是找到的Name=Hello的对象,这个对应数据流中的ReturnData消息。
  • 第二次连接:服务端发送给客户端的Call消息客户端反序列化该对象,发现该对象是一个远程对象,地址在ip:port,于是再与这个地址建立TCP连接;在这个新的连接中,才执行真正的远程方法调用,也就是sayHello()

总的来说,RMI Registry就像一个网关,他自己是不会执行远程方法的,但RMI Server可以在上面注册一个Name到对象的绑定关系;RMI Client通过Name像RMI Registry查询,得到这个绑定关系,然后再连接RMI Server。最后,远程方法实际上在RMI Server上调用。

原理如下图所示:

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

四、从IDEA断点分析RMI通信原理

4.1 流程分析总览

首先RMI有三部分:

  1. RMI Registry
  2. RMI Server
  3. RMI Client

如果两两通信就是 3+2+1 = 6 个交互流程,还有三个创建过程,一共是九个过程。

RMI 的工作原理可以大致参考这张图

4.2 创建远程服务

声明:创建远程服务其实并不存在任何漏洞

断点打在RMI_Server的创建远程对象上,如上图所示;

4.2.1 发布远程对象

开始调试,首先是远程对象的构造函数RemoteObjImpl,现在我们要把它发布到网络上,分析的是它如何被发布到网络上去的

RemoteObjImpl这个类是继承于UnicastRemoteObject的,所以先会到父类的构造函数,父类的构造函数这里的port传入0,它代表了一个随机端口。

这个过程不同于注册中心的1099端口,这是远程服务的。

远程服务这里如果传入的是0,它会被发布到网络上的一个随机端口。继续往下看,先F8到exportObject(),在F7步入进去查看。

1
2
3
4
public static Remote exportObject(Remote obj, int port) throws RemoteException
{
return exportObject(obj, new UnicastServerRef(port));
}

exportObject()是一个静态函数,它就是主要负责将远程服务发布到网络上
如何更好的理解exportObject()的作用?
如果不继承UnicastRemoteObject这个类的话,可以通过手动调用函数实现相对应的功能。For example:

1
2
3
4
5
public class RemoteObjImpl implements RemoteObj {
public RemoteObjImpl() throws RemoteException {
UnicastRemoteObject.exportObject(this, 0);
}
}

再次回到exportObject()这个静态函数,第一个参数是obj对象,第二个参数是new UnicastServerRef(port),第二个参数是用来处理网络请求的。
继续向里跟进F7,来到UnicastServerRef的构造函数。

UnicastServerRef的构造函数,我们看到它new了一个LiveRef(port),它算是一个网络引用的类,继续跟进查看。

先是一个构造函数,在继续跟进this查看

1
2
3
public LiveRef(ObjID objID, int port) {
this(objID, TCPEndpoint.getLocalEndpoint(port), true);
}

第一个参数ID,第三个参数为true,所以我们重点关注一下第二个参数。
TCPEndpoint是一个网络请求的类,我们可以去看一下它的构造函数,传参进去一个IP与一个端口,也就是说传进入一个IP和一个端口,就可以进行网络请求。

1
2
3
public TCPEndpoint(String host, int port) {
this(host, port, null, null);
}

继续F7跟进LiveRef的构造函数this里面

这时候我们可以看一下一些赋值,发现host和port是赋值到了endpoint里面,而endpoint又是被封装在LiveRef里面的,所以记住数据实在LiveRef里面即可,并且这一LiveRef至始至终只存在一个。

上述即是LiveRef创建的过程,继续F7跟进会再回到之前出现LiveRef(port)的地方


继续F7跟进super看一看父类UnicastRef,这里就证明整个创建远程服务的过程只会存在一个LiveRef。一路F7到一个静态函数exportObject(),后续的操作过程都与exportObject()有关,基本都是再调用它,这一段不是很重要,一路F7就好。直到此处出现Stub

再服务端创建远程服务这一步居然出现了stub的创建(其实也就是一个代理对象),其实原理是这个样子的,来结合上述我们提到的原理图进行讲解:

  • RMI现在Service的地方,也就是服务端创建一个Stub,再把Stub传到RMI Registry中,最后让RMI Client去获取Stub。

接着我们研究Stub产生的这一步,先进到createProxy这个方法里面

先进行了基本的复制,然后继续F8往下看,去到判断的地方。

这个判断暂时不用管,后续我们会碰到,那个时候再讲。
再往下走,我们可以看到这是很明显的类加载的地方。

第一个参数是AppClassLoader,第二个参数是一个远程接口,第三个参数是调用处理器,调用处理器里面只有一个ref,它也是和之前我们看到的ref是同一个,创建远程服务当中永远只有一个ref,那就是(LiveRef)。

在此处就把远程服务的动态代理创建好了,如图Stub。

继续F7跟到Target这里,Target这里相当于一个总的封装,将所有用的东西放到Target里面,我们可以进去看一看里面都放了什么。

通过比较ID可以知道在disp、stub中的ref是同一个,且ID值与存储在id中的值相同。

一路F8,回到之前的Target定义的位置,下一条语句是ref.exportObject(target),也就是把target这个封装好的对象发布出去。

F7跟进exportObject查看发布逻辑,跟进到如下图位置

此处的第一条语句listen(),真正处理网络请求,继续跟进查看逻辑。

跟进之后,可以知道其运行逻辑。
先获取了TCPEndpoint,然后到server = ep.newServerSocket();创建了一个新的socket,等待连接,所以之后再Thread里面去做完成连接之后的事儿。

来到NewServerSocket()函数中可以发现,当listenPort端口等于0的时候,会设置一个默认端口。

下面贴上线程中执行任务的图示

这里就是比较常见的Socket连接请求的处理流程,

一路执行回到exportObject()中可以发现,target里面的port变量已经被赋值。意味着刚才的NewServerSocet()函数确实已经对port端口进行赋值。

4.2.2 发布完成之后的记录

其实经过上述的操作之后就已经完成了发布stub,继续向下查看一下记录;

从这里F7跟进内部查看远程服务被记录到什么地方。

第一个语句target.setExportedTransport(this);是一个简单的赋值,可以不看;
第二个语句ObjectTable.putTarget(target);继续跟入;前面都是一些简单的赋值语句,调用的put函数

RMI这里会把所有的信息保存到两个table里面,从而形成相应的表进行记录(其实就是一个Map),应该是可以简单理解为一个日志的。

4.2.3 创建远程服务小结

从个人理解的角度来说,发布远程对象,就是通过exportObject()指定到发布的IP与端口,端口为随机端口。

至始至终复杂的地方其实都是再赋值,创建类、进行各种各样的封装,真正实现功能的代码还是比较短的,即listen();以及创建线程开始执行监听;

还有一个过程就是发布完成之后的记录,类似于日志,这些记录时保存到静态的HashMap当中。

创建远程服务这部分的内容基本就是这样,事实上并不存在漏洞;

4.3 创建注册中心+绑定

创建注册中心与服务端是独立的,所以谁先谁后其实没有什么关系,本质上都是一整个东西;断点打在Registry registry = LocateRegistry.createRegistry(1099);

4.3.1 创建注册中心

首先从断点createRegistry()函数中通过F7跟进函数内部查看其详细内容。第一步是创建一个RegistryImpl对象,继续跟进到RegistryImpl构造当中。

在这个构造函数当中,首先会判断传入的端口port是否已经是注册中心的端口以及进行一系列的安全检查判断(不重要)。继续F8往下走……

可以发现其创建了一个LiveRef对象,id参数值不明,但是port是我们的注册中心端口1099;并且创建了一个UnicastServerRef对象,这段代码和我们么那上面讲的创建远程对象比较类似,可以继续跟进查看一下setup()函数

发现其实跟创建远程对象的原理相似,先赋值,然后再进行exportObject()方法的调用。

区别在于第三个参数的不同
创建远程对象中为true,代表创建一个临时对象;
创建注册中心中为false,代表创建注册一个永久对象;

然后再通过F7跟进exportObject()函数查看一下函数的实现细节;

发现其实跟发布远程对象中的exportObject()差不多,来到创建Stub的阶段;继续跟进Stub的详细创建的过程中createProxy()

Stub创建过程中的判断与创建远程对象服务中存在差异,就是就在于stubClassExists()判断的存在,继续跟进该判断函数中

我们看到这个地方,是判断是否能获取到 RegistryImpl_Stub 这个类,换句话说,也就是若 RegistryImpl_Stub 这个类存在,则返回 True,反之 False。我们可以找到 RegistryImpl_Stub 这个类是存在的。

对比发布远程对象那个步骤,创建注册中心是走进到 createStub(remoteClass, clientRef); 进去的,而发布远程对象则是直接创建动态代理的。继续F7跟入createStub()

直接通过反射调用构造函数创建对象,里面放置的内容即ref。一路F8执行代码来到

如果服务端是定义好的,就调用setSkeleton()方法,跟进去

存在一个createSkeleton()方法,从函数名可以看出是用来创建Skeleton的。在RMI原理流程图中,Skeleton是作为服务端的代理使用的。

查看一下createSkeleton()函数源码,发现Skeleton是用forName()的方式创建的,如图。继续F8执行,来到下图

发现此处比发布远程服务对象多了skel,又到了Target的位置,Target部分的作用与发布远程服务的作用一致,用于存储封装数据;接下来的exportObject()的作用基本与前面分析的一致。listen()线程监听等待接收socket连接。F7跟进后,再次来到这个位置即可

继续F8到super.exportObject(target);然后F7跟进查看相关内容即可;

这里存在一个putTarget()方法,它会将target的数据封装并放进去。继续F7跟进查看;

执行的代码基本上与创建远程服务一致,但是还需要详细查看一下封装了哪些数据。

4.3.2 查看封装了哪些数据进去

查看 static 中的数据,点开 objTable 中查看三个 Target,我们逐个分析一下,分析的话主要还是看 ref

我们现在可以关注到这里总共有三个Target,我们逐一分析其中存储的内容;

  • Target@844

可以知道这是DGCImpl_Stub,是分布式垃圾回收的一个对象;它并不是我们刚才创建的。这个东西挺重要的。

  • Target@789

可以发现这里存储的是$proxy对象,具体信息如上图所示;

  • Target@880

可以发现这个target中应该是存储了我们创建的服务中心信息,包括监听端口等等;

4.3.3 绑定

现在来到了RMI服务端的最后一步,也就是bind绑定操作;

下断点在绑定语句进行调试分析;

这里可能不能直接通过F7跟进,可以找到RegistryImpl#bind()下一个断点进来,通过F7跟进,来到bind()方法的具体实现

继续F7根据到检查函数checkAccess()的具体实现

F8执行跳出该函数,回到bind()函数中

此处开始检查bindings 是否已经存在了相应名称的绑定,如果bindings中已经存在相应名称的绑定就抛出异常。其实 bindings 就是一个 HashTable。

继续F8执行,来到bindings.put(name, obj);中,

其实就是将服务端代理放进去(这里的调试经过了两次启动,与上面分析的代理端口不一致),到这里,绑定的过程就结束了,其实就是把上面创建的代理和相应的名称放入bindings这个hashtable中而已;

4.3.4 创建注册中心和绑定小结

  • 注册中心的创建过程和发布远程对象比较相似,只不是注册中心是创建一个持久对象,该持久对象就是注册中心;
  • 绑定过程比较容易理解,就是将创建的代理和相应的名称进行绑定;代理中包含了相应的ip地址和端口;

4.4 客户端请求,客户端调用注册中心

4.4.1 获取注册中心

这里不存在任意漏洞,为了深入理解RMI,还是进行调试一下;

对客户端连接请求下断点进行调试;

直接F7跟进getRegistry()方法中,查看具体的实现方法;

来到此处后,我们继续F8执行,来到LiveRef定义与赋值这里;

又看到了比较熟悉的流程,创建liveRef,再创建ref;其实本质上与注册中心和远程服务类似;

这里创建的是我们请求连接的ip地址和port端口的liveRef和ref,然后再creatProxy()
通过提供的IP地址和端口就获取到了注册中心的Stub,然后继续进行下一步的查找远程对象;

4.4.2 查找远程对象

存在漏洞

由于之前返回的对象是RegistryImpl_Stub类型,调用的对象应该在RegistryImpl_Stub类中的lookup方法

因为对应的 Java 编译过的 class 文件是 1.1 的版本,无法进行打断点,所以会直接跳到其他地方去,比如此处。直接硬分析!

首先就是我们调用lookup的时候传入了一个String类型的参数remoteObj,对应的就是方法中的形参var1

可以知道我们传入的数据经过了序列化在进行传入。后续注册中心也会经过反序列化读出数据;

接着下一步,我们看到 super.ref.invoke(var2);,super 就是父类,也就是我们之前说的 UnicastRef 这个类。这里的 invoke() 方法是类似于激活的方法,invoke() 方法里面会调用 call.executeCall(),它是真正处理网络请求的方法,也就是客户端的网络请求都是通过这个方法实现的。

这个方法后续再细讲,先看整个代码运行的逻辑。

我们的逻辑现在是从 invoke() —> call.executeCall() —> out.getDGCAckHandler(),到 out.getDGCAckHandler() 这个地方的时候,是 try 打头的,这里它有一个异常存在潜在攻击的可能性,如图,中间省略了部分代码。

分析in变量的赋值;

不难理解,in 就是数据流里面的东西。这里获取异常的本意应该是在报错的时候把一整个信息都拿出来,这样会更清晰一点,但是这里就出问题了(如果一个注册中心返回一个恶意的对象,客户端进行反序列化,这就会导致漏洞。)这里的漏洞相比于其他漏洞更为隐蔽。

也就是说,只要调用 invoke(),就会导致漏洞。RMI 在设计之初就并未考虑到这个问题,导致客户端都是易受攻击的。

上述就是注册中心与客户端进行交互时会产生的攻击。

我们这里继续 f8,看一下到最后一步的时候获取到了什么数据。简单来说就是获取到了 RemoteObj 这个动态代理,其中包含一个 ref。

4.4.3 总结

  • 获取注册中心

在该部分内容中确实不存在漏洞,过程也相对简单,直接通过传入的ip和port创建Stub即可;

  • 查找远程对象

该部分确实存在漏洞攻击的可能性,在处理网络请求的过程中,若存在异常,则将信息全部取出并进行反序列化,从而导致漏洞;
这是服务端攻击客户端的可能性,但是异常信息取出进行反序列化实现攻击,似乎没有那么简单。(这也恰恰说明,我的功夫不到家啊~~~)

据说,这里可以利用 URLClassLoader 来打,我继续学学吧!

4.5 客户端请求,客户端请求服务端

4.5.1 分析查询远程对象

这部分内容存在漏洞,重点关注分析

这里就是客户端请求的第三句代码——remoteObj.sayHello("hello");

这里如果 Debug 有问题的话,可以先在 RemoteObjectInvocationHandler 类下的 invoke() 方法的 if 判断里面打个断点,这样才能走进去。调试开始

报错:跳过的断点在java. rmi. server. RemoteObjectInvocationHandler:165, 因为它发生在调试器评估中 排除故障指南

算了,这里我硬看了!不调试了

首先经过一串的if判断,都是关于抛出异常的,直接跳过,不影响最后的理解。知道尾部的invokeRemoteMethod(proxy, method, args);,跟进;

来到该函数中在继续查看相应的内容,继续跟进,由于ref是UnicastRef,因此我们需要跟进UnicastRef#invoke()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
public Object invoke(Remote obj,
Method method,
Object[] params,
long opnum)
throws Exception
{
if (clientRefLog.isLoggable(Log.VERBOSE)) {
clientRefLog.log(Log.VERBOSE, "method: " + method);
}

if (clientCallLog.isLoggable(Log.VERBOSE)) {
logClientCall(obj, method);
}

Connection conn = ref.getChannel().newConnection();
RemoteCall call = null;
boolean reuse = true;

/* If the call connection is "reused" early, remember not to
* reuse again.
*/
boolean alreadyFreed = false;

try {
if (clientRefLog.isLoggable(Log.VERBOSE)) {
clientRefLog.log(Log.VERBOSE, "opnum = " + opnum);
}

// create call context
call = new StreamRemoteCall(conn, ref.getObjID(), -1, opnum);

// marshal parameters
try {
ObjectOutput out = call.getOutputStream();
marshalCustomCallData(out);
Class<?>[] types = method.getParameterTypes();
for (int i = 0; i < types.length; i++) {
marshalValue(types[i], params[i], out);
}
} catch (IOException e) {
clientRefLog.log(Log.BRIEF,
"IOException marshalling arguments: ", e);
throw new MarshalException("error marshalling arguments", e);
}

// unmarshal return
call.executeCall();

try {
Class<?> rtype = method.getReturnType();
if (rtype == void.class)
return null;
ObjectInput in = call.getInputStream();

/* StreamRemoteCall.done() does not actually make use
* of conn, therefore it is safe to reuse this
* connection before the dirty call is sent for
* registered refs.
*/
Object returnValue = unmarshalValue(rtype, in);

/* we are freeing the connection now, do not free
* again or reuse.
*/
alreadyFreed = true;

/* if we got to this point, reuse must have been true. */
clientRefLog.log(Log.BRIEF, "free connection (reuse = true)");

/* Free the call's connection early. */
ref.getChannel().free(conn, true);

return returnValue;

} catch (IOException e) {
clientRefLog.log(Log.BRIEF,
"IOException unmarshalling return: ", e);
throw new UnmarshalException("error unmarshalling return", e);
} catch (ClassNotFoundException e) {
clientRefLog.log(Log.BRIEF,
"ClassNotFoundException unmarshalling return: ", e);

throw new UnmarshalException("error unmarshalling return", e);
} finally {
try {
call.done();
} catch (IOException e) {
/* WARNING: If the conn has been reused early,
* then it is too late to recover from thrown
* IOExceptions caught here. This code is relying
* on StreamRemoteCall.done() not actually
* throwing IOExceptions.
*/
reuse = false;
}
}

} catch (RuntimeException e) {
/*
* Need to distinguish between client (generated by the
* invoke method itself) and server RuntimeExceptions.
* Client side RuntimeExceptions are likely to have
* corrupted the call connection and those from the server
* are not likely to have done so. If the exception came
* from the server the call connection should be reused.
*/
if ((call == null) ||
(((StreamRemoteCall) call).getServerException() != e))
{
reuse = false;
}
throw e;

} catch (RemoteException e) {
/*
* Some failure during call; assume connection cannot
* be reused. Must assume failure even if ServerException
* or ServerError occurs since these failures can happen
* during parameter deserialization which would leave
* the connection in a corrupted state.
*/
reuse = false;
throw e;

} catch (Error e) {
/* If errors occurred, the connection is most likely not
* reusable.
*/
reuse = false;
throw e;

} finally {

/* alreadyFreed ensures that we do not log a reuse that
* may have already happened.
*/
if (!alreadyFreed) {
if (clientRefLog.isLoggable(Log.BRIEF)) {
clientRefLog.log(Log.BRIEF, "free connection (reuse = " +
reuse + ")");
}
ref.getChannel().free(conn, reuse);
}
}
}

这方法真长啊!这是一个重载的方法,这个重载的 invoke 方法作用是创建了一个连接,和之前也比较类似。我们可以看一下它具体的逻辑实现。

在该方法中存在一个 marshalValue() 方法。

它会序列化一个值,这个值其实就是我们传进的参数 hello,它的逻辑如图。判断一堆类型,之后再进行序列化。

继续往后看刚才的invoke()方法我们看到一个注释 // unmarshal return,后面接的是 call.executeCall(),之前我们也看到了这个方法,也就是说只要 RMI 处理网络请求,就一定会执行到这个方法,这里是存在危险的,原理上面已经代码跟过一遍了 ~

继续往后看!

这里有一个 unmarshalValueSee 的方法,因为现在我们传进去的类型是 String,不符合上面的一系列类型,这里会进行反序列化的操作,把这个数据读回来,这里是存在入口类的攻击点的。

这里的in数据其实就是执行完毕远程函数后的输出结果;

4.5.2 总结

  • 在注册中心 –> 服务端这里,查找远程对象的时候是存在攻击的。

具体表现形式是服务端打客户端,入口类在 call.executeCall(),里面抛出异常的时候会进行反序列化。
这里可以利用 URLClassLoader 来打,具体的攻击在后续文章会写。

在服务端 —> 客户端这里,也是存在攻击的,一共是两个点:一个是 call.executeCall(),另一个点是 unmarshalValueSee 这里。

unmarshalValueSee 这里会将执行的输出返回到客户端,然后客户端进行反序列化从而实现攻击;

  • 再总结一下代码的流程

分为三步走,先获取注册中心,再查找远程对象,查找远程对象这里获取到了一个 ref,最后客户端发出请求,与服务端建立连接,进行通信。

4.6 客户端发起请求,注册中心如何处理

4.6.1 注册中心处理分析

先说说断点怎么打,因为客户端那里,我们操作的是 Stub,服务端这边操作的是 Skel。在有了 Skel 之后应当是存在 Target 里面的,所以我们的断点打到处理 Target 的地方。断点位置如图

先点 Server 的 Debug,再跑 Client 就可以了,成功的打断点如上图;F8往下执行,查看target中包含了什么内容;

可以发现target中包含了一个stub,stub中有一个ref,对应的IP和port为注册中心监听端口;继续向下执行;

final Dispatcher disp = target.getDispatcher();将target中的disp取出放到disp中,其中disp中包含了skelref等内容;

继续往下走,它会调用 disp 的 dispatch 方法,我们跳进去看一下 disp.dispatch()

继续走,我们目前的 skel 不为 null,会到 oldDispatch() 这里,跟进。

继续执行到410行的skel.dispatch(obj, call, op, hash);,这里无法继续跟进了,因为具体的方法实现在RegistryImpl_Skel#dispatch()无法下断点跟进;
下面就是 skel.dispatch() 的过程了,这里才是重点,这里就是很多师傅文章里面会提到的 客户端打注册中心 的攻击方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception {
if (var4 != 4905912898345647071L) {
throw new SkeletonMismatchException("interface hash mismatch");
} else {
RegistryImpl var6 = (RegistryImpl)var1;
String var7;
Remote var8;
ObjectInput var10;
ObjectInput var11;
switch (var3) {
case 0:
try {
var11 = var2.getInputStream();
var7 = (String)var11.readObject();
var8 = (Remote)var11.readObject();
} catch (IOException var94) {
throw new UnmarshalException("error unmarshalling arguments", var94);
} catch (ClassNotFoundException var95) {
throw new UnmarshalException("error unmarshalling arguments", var95);
} finally {
var2.releaseInputStream();
}

var6.bind(var7, var8);

try {
var2.getResultStream(true);
break;
} catch (IOException var93) {
throw new MarshalException("error marshalling return", var93);
}
case 1:
var2.releaseInputStream();
String[] var97 = var6.list();

try {
ObjectOutput var98 = var2.getResultStream(true);
var98.writeObject(var97);
break;
} catch (IOException var92) {
throw new MarshalException("error marshalling return", var92);
}
case 2:
try {
var10 = var2.getInputStream();
var7 = (String)var10.readObject();
} catch (IOException var89) {
throw new UnmarshalException("error unmarshalling arguments", var89);
} catch (ClassNotFoundException var90) {
throw new UnmarshalException("error unmarshalling arguments", var90);
} finally {
var2.releaseInputStream();
}

var8 = var6.lookup(var7);

try {
ObjectOutput var9 = var2.getResultStream(true);
var9.writeObject(var8);
break;
} catch (IOException var88) {
throw new MarshalException("error marshalling return", var88);
}
case 3:
try {
var11 = var2.getInputStream();
var7 = (String)var11.readObject();
var8 = (Remote)var11.readObject();
} catch (IOException var85) {
throw new UnmarshalException("error unmarshalling arguments", var85);
} catch (ClassNotFoundException var86) {
throw new UnmarshalException("error unmarshalling arguments", var86);
} finally {
var2.releaseInputStream();
}

var6.rebind(var7, var8);

try {
var2.getResultStream(true);
break;
} catch (IOException var84) {
throw new MarshalException("error marshalling return", var84);
}
case 4:
try {
var10 = var2.getInputStream();
var7 = (String)var10.readObject();
} catch (IOException var81) {
throw new UnmarshalException("error unmarshalling arguments", var81);
} catch (ClassNotFoundException var82) {
throw new UnmarshalException("error unmarshalling arguments", var82);
} finally {
var2.releaseInputStream();
}

var6.unbind(var7);

try {
var2.getResultStream(true);
break;
} catch (IOException var80) {
throw new MarshalException("error marshalling return", var80);
}
default:
throw new UnmarshalException("invalid method number");
}

}
}

上述的方法的实现源码,分析一下源码;

我们与注册中心进行交互可以使用如下几种方式:

  • list
  • bind
  • rebind
  • unbind
  • lookup

这几种方法位于 RegistryImpl_Skel#dispatch 中,也就是我们现在 dispatch 这个方法的地方。

如果存在对传入的对象调用 readObject 方法,则可以利用,dispatch 里面对应关系如下:

  • 0->bind
  • 1->list
  • 2->lookup
  • 3->rebind
  • 4->unbind

只要中间是有反序列化就是可以攻击的,而且我们是从客户端打到注册中心,这其实是黑客们最喜欢的攻击方式。我们来看一看谁可以攻击。

  • bind——可以进行反序列化攻击

  • lookup——可以进行反序列化攻击

  • rebind——可以进行反序列化攻击

  • unbind——可以进行反序列化攻击

4.6.2 总结

简单,注册中心就是处理 Target,进行 Skel 的生成与处理。

漏洞点是在 dispatch 这里,存在反序列化的入口类(除了list,其他都可以攻击)。这里可以结合 CC 链子打的。

4.7 客户端发起请求,服务端做了什么

与调试注册中心处理时一致,将断点打在如图的位置;

首先运行Server的debug,然后再运行Client即可,可以看到程序成功运行到断点处;

调试这里有一点小坑,打完两个断点之后,我们得到的第一个 Target 中的 Stub 是 RegistryImpl 的,我们要的不是这个,我们需要的服务端动态代理的Stub,即Proxy

4.7.1 服务端处理分析

继续F9执行程序,再查看target中的stub,知道得到Proxy的Stub为止,如下图;

我们继续F8执行到dispatch()方法处,跟进

继续执行程序来到判断结构可以知道,无论是num值还是skel,都表示我们不会直接执行oldDispatch()函数,如下图所示

继续F8执行程序可以看到下面就是到了获取输入流,以及Method,Method其实就是我们在客户端调用的sayHello()方法

继续执行就会来到循环当中的unmarshalValue()方法,之前也提到过很多次这个方法,

其中存在漏洞,会将我们传入的参数hello进行反序列化读出

4.7.2 总结

这里的服务端处理客户端的请求连接的过程中存在漏洞,会讲客户端发送过来的参数进行反序列化读取出来;

4.8 DGC

说实在的,个人认为这部分内容还是相当重要的,因为设置到后面的jrmp绕过相关知识,后面涉及的利用的还是蛮多的。

4.8.1 创建DGC

首先我们需要了解一下DGC的创建流程到底在哪里,如果我们对前面的调试流程的记忆比较清晰的大概可以记得,我们在ObjectTable中存在一个不明的判断。其实DGC就是在这个时候创建的。

实际上我们发现它这是调用了DGCImpl的静态变量dgcLog,在这个过程中会对类进行初始化,调用其中静态代码块中的内容然后执行,所以我们跟进DGCImpl类中的静态代码块。

可以看到在DGCImpl类中存在一个静态代码块,在我们调用DGCImpl的静态变量dgcLog的时候就会自动调用该静态代码快进行创建DGC,同时也创建了DGCImpl_skel,DGCImpl_Stub对象,以及封装Target对象,过程与之前的客户端与服务端分析的类似,这里就不在细说了。

4.8.2 DGC漏洞点

在了解了DGC漏洞点的基础之后,我们来看看DGC到底存在哪些问题?

首先,在DGCImpl_Stub类中存在两个方法,一个是 clean,另外一个是 dirty。
clean 就是”强”清除内存,dirty 就是”弱”清除内存。

来查看类的具体实现我们可以发现,其实在dirty()方法中存在readObject()方法的调用,存在反序列化的入口类,可以尝试去进行反序列化漏洞的利用。

相同的,我们来到DGCImpl_Skel类也可以发现,其中存在一个方法dispatch(),其中也调用了readObject()函数,存在反序列化的入口类,可以尝试去进行反序列化漏洞的利用。

这是在DGC我们需要关注的点,DGC这一块的具体利用可以说到jrmp绕过和JEP290等等,后续在开新章节详细说明;

五、总结

  • RegistryImpl_Stub和DGCImpl_Stub中远程方法(lookup/list/dirty)的返回值
  • 客户端调用UnicastRef.unmarshalValue反序列化读取远程方法的返回值
  • 客户端反序列化读取远程方法执行时出错抛出的异常类

参考连接:

  1. https://drun1baby.top/2022/07/19/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BRMI%E4%B8%93%E9%A2%9801-RMI%E5%9F%BA%E7%A1%80/
  2. https://www.freebuf.com/articles/security-management/365775.html

RMI基础
http://candyb0x.github.io/2024/12/06/RMI基础/
作者
Candy
发布于
2024年12月6日
更新于
2024年12月6日
许可协议