Java安全-RMI基础(RMI通信原理)

RMI简介

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

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

  • 这个协议就像 HTTP 协议一样,规定了客户端和服务端通信要满足的规范。

RMI 包括以下三个部分

RMI 系统使用现有的 Web 服务器,从服务到客户端以及从客户端到服务器进行通信

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

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

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

实际上在java中,动态二字是比较核心的,不管是类的动态加载还是动态代理,都是存在一个类似注册端的东西来执行分发请求获取服务等类似操作,有了这个注册端的存在,才让”动态”得以实现

RMI实现demo

新建两个项目,Sever和Client

RMISever

1.先编写一个远程接口,其中定义了一个 sayHello() 的方法

1
2
3
4
public interface RemoteObj extends Remote {  

public String sayHello(String keywords) throws RemoteException;
}

此远程接口要求作用域为 public;
继承 Remote 接口;
让其中的接口方法抛出异常

2.定义实现接口的类(服务的具体实现内容)

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RemoteObjImpl extends UnicastRemoteObject implements RemoteObj { 

public RemoteObjImpl() throws RemoteException {
// UnicastRemoteObject.exportObject(this, 0); // 如果不能继承 UnicastRemoteObject 就需要手工导出
}

@Override
public String sayHello(String keywords) throws RemoteException {
String upKeywords = keywords.toUpperCase();
System.out.println(upKeywords);
return upKeywords;
}
}
  • 实现远程接口
  • 继承 UnicastRemoteObject 类,用于生成 Stub(存根)和 Skeleton(骨架)。 这个在后续的通信原理当中会讲到
  • 构造函数需要抛出一个RemoteException错误
  • 实现类中使用的对象必须都可序列化,即都继承java.io.Serializable

3.注册远程对象

1
2
3
4
5
6
7
8
9
10
public class RMIServer {  
public static void main(String[] args) throws RemoteException, AlreadyBoundException, MalformedURLException {
// 实例化远程对象
RemoteObj remoteObj = new RemoteObjImpl();
// 创建注册中心
Registry registry = LocateRegistry.createRegistry(1099);
// 绑定对象示例到注册中心
registry.bind("remoteObj", remoteObj);
}
}
  • port 默认是 1099,不写会自动补上,其他端口必须写
  • bind 的绑定这里,只要和客户端去查找的 registry 一致即可。

RMIClient

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

所以在客户端这里,也需要定义一个远程对象的接口:

1
2
3
4
public interface RemoteObj extends Remote {  

public String sayHello(String keywords) throws RemoteException;
}

然后编写客户端的代码,获取远程对象,并调用方法

1
2
3
4
5
6
7
8
9
10
public class RMIClient {  
public static void main(String[] args) throws Exception {
//获取注册中心
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
//查找远程服务
RemoteObj remoteObj = (RemoteObj) registry.lookup("remoteObj");
//调用远程服务
remoteObj.sayHello("hello");
}
}

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

运行后发现远程服务成功被调用

大写后的字符串成功在服务端输出

IDEA调试(源码层面分析)

工作原理

创建部分

创建远程对象分析

下一个断点进行调试

发布远程对象

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

RemoteObjImpl 这个类是继承于 UnicastRemoteObject 的,所以先会到父类的构造函数

这里如果想要走入父类的构造函数,需要我们手动在这里下一个断点,不然不会走进此函数

父类的构造函数这里的 port 传入了 0,它代表一个随机端口(因为这个端口是随机的,客户不知道会分配到什么,所以才需要注册端)

继续跟进,走入继承父类的一个核心的函数

有说法是如果实现remote接口时如果没有继承UnicastRemoteObject类,就需要加一条代码

1
2
UnicastRemoteObject.exportObject(this, 0); 
// 如果不能继承 UnicastRemoteObject 就需要手工导出

这个静态函数

1
return exportObject(obj, new UnicastServerRef(port));

第一个参数是实现远程服务的,第二个参数是用来处理网络请求的

继续往下面跟,去到了 UnicastServerRef 的构造函数

跟进去之后 UnicastServerRef 的构造函数,我们看到它 new 了一个 LiveRef(port),这个非常重要,它算是一个网络引用的类,跟进看一看

继续跟进,来到它的构造函数

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

第一个参数是远程服务ID,跟进查看第二个参数控制什么

TCPEndpoint 是一个网络请求的类,我们可以去看一下它的构造函数,传参进去一个 IP 与一个端口,也就是说传进去一个 IP 和一个端口,就可以进行网络请求

继续刚才的调试

可以看到ip和端口被传入到LiveRef中

继续跟进,直接步过剩余步骤,来到之前出现 LiveRef(port) 的地方

回到这里,我们的LiveRef实例已经封装好了,看到它调用了父类的函数

进入到它调用的父类函数中查看,可以看到ref = liveRef

整个创建远程服务的过程只会存在一个 LiveRef,这样记的话就不会乱

继续跟进到一个静态函数 exportObject(),后续的操作过程都与 exportObject() 有关,基本都是在调用它,这一段不是很重要,一路步入或者步过就好了。直到此处出现 Stub

翻译了一下这段英文注释,意思大概是这样的

把实现类对象导出 →生成客户端桩(stub)和服务端骨架(skeleton,旧版 RMI 才有) →把 stub 和实现类绑定起来 →注册到 RMI Registry,供客户端调用

实际上就是在服务端创建了一个客户端真正操作的代理,注册到 RMI Registry,供客户端调用

可以对照一下这张图

继续跟进,查看一下stub这个代理是如何创建的

1
stub = Util.createProxy(implClass, getClientRef(), forceStubUse);

我们直接跟进到createProxy这个函数里

先进行了基本的赋值

继续往下走入到一个判断中,这个先跳过了

继续跟进,看到类加载的语句

newProxyInstance之后就能创建好stub代理了

创建代理结束后来到Target

Target会把之前创建好的有用的所有东西封装起来,相当于一个总封装

跟进一下看看Target里到底有什么

这里可以看到服务端和客户端中都是传入的LiveRef,也就可以证明如果服务端和客户端要通信其实用的是同一个网络请求

实际上打开继续展开查看ref可以看到它的id与target中封装的id是一样的,所以LiveRef相当于是最核心的东西了

继续跟进,跳过一些有关Target的分装操作

来到此处,看到这行代码是把封装好的target发布成远程的object

我们跟进这个函数,看看具体是如何发布的

直接跟进到TCPTransport类的exportObject

看到第一句listen,开始处理网络请求,我们跟进这个函数

进来之后看到创建了一个服务端的socket,又开启了一个新线程,之后在 Thread 里面去做完成连接之后的事

Thread中具体做的就是处理网络请求的一系列操作

我们只需要知道处理网络请求是一个新的线程,与代码逻辑不在同一个线程中

需要注意的是,newServerSocket() 方法会给 port 进行赋值,核心语句如图

如果端口为0,则随机赋一个值

记录发布结果

跳过中间执行代码的部分(处理网络请求并发布),来到ObjectTable的putTarget方法

一路跟进来到记录处

实际上objTable和implTable是两个Map类型的实例,RMI会把发布结果记录到这两个Map里

小结

总结一个整个创建远程对象的流程,主要就是先发布远程对象,利用exportObject函数指定到发布的 IP 与端口,端口的话是一个随机值。至始至终复杂的地方其实都是在赋值,创建类,进行各种各样的封装,实际上并不复杂。

其中网络请求被封装在LiceRef对象里,客户端和服务端中封装在里面的都是这个对象

要特别注意的是,处理网络请求是一个单独的线程,与代码逻辑执行不在同一个线程中

最后发布了远程对象之后,会将相关对象和信息封装在两个Map中做一个记录

创建注册中心+绑定

这次在创建注册中心的位置下一个断点

创建注册中心

跟进一下发现进入了RegistryImpl这个对象里,if循环中是进行了一系列的安全检查

继续跟进可以看到再次创建了一个LiveRef对象,又创建了一个UnicastServerRef对象,这跟我们干刚看到的创建远程对象的步骤很像

跟进setup函数看一下

同样可以看到此处也调用了exportObject方法,但是这里的第三个参数,就是permanent的值是false,因为就代表着注册中心这个对象,是一个永久对象,而之前创建远程对象时,第三个参数是false,代表它是一个临时对象

继续跟进,要开始创建stub了

创建stub这里是跟之前创建远程对象那里有点不一样的

我们还是跟进 createProxy() 中,这里依旧要先做一个判断

判断是否存在文件名+_stub后缀的这个类

实际上,这个类是存在的

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

进入到createStub方法,发现这个方法就是利用反射创建一个对象,将ref放进去

实际上创建远程对象和创建注册中心,新建stub的过程本质上是一样的,都是放了ref进去,只是一个用的是版本自带的stub,一个使用类加载新建了一个动态代理当作stub

反射加载stub成功之后我们继续跟进,发现又会走入一个条件判断

服务端定义好的,就调用 setSkeleton() 方法

跟进去。然后这里有一个 createSkeleton() 方法,一看名字就知道是用来创建 Skeleton 的,而 Skeleton 在我们的那幅图中,作为服务端的代理

Skeleton 是用 forName() 的方式创建的,其实与刚刚创建stub的过程类似,因为版本中也自带RegistryImpl_Skel类,不过多赘述了

创建成功后顺利返回,这里需要记住skel是UnicastServerRef的内部变量

再往后走,又到了 Target 的地方,Target 部分的作用也与之前一样,用于储存封装的数据

我们直接来到最后put的地方,看看Map中究竟放了哪些数据

打开static看到objTable中有三个Target,我们来分析一下

第一个是默认会创建的DGCImpl,先跳过

来看第二个

这个是我们创建的远程对象的stub(动态代理),可以看到UnicastServerRef中的skel为空,并且已经设置好了随机端口

而且stub和disp中放的ref实际上是一样的

第三个是记录了创建注册中心的信息

绑定

下断点在bind代码处

进入bind方法,先会进入一个检查,这个不是很重要

之后继续执行

会检查一下bindings(类似于HashMap)中是否有叫做remoteObj的东西,如果没有就会直接执行put进行绑定,就是把 IP 和端口放进去,到此处,绑定过程就结束了

小结一下创建注册中心 + 绑定

  • 总结一下比较简单,注册中心这里其实和发布远程对象很类似,不过多了一个持久的对象,这个持久的对象就成为了注册中心。

绑定的话就更简单了,一句话形容一下就是 hashTable.put(IP, port)

客户端部分

客户端请求注册中心-客户端

下一个断点,第一步会查找注册中心

进入之后发现这里是直接创建了一个LiveRef对象,将ip和port传进去然后封装起来

之后优惠走入createProxy方法,我们跟进看一下,发现它也是创建了一个stub。也就是说,当时在服务端创建的Registry_Stub并没有传过来,只是传过来了参数,这里客户端在本地又创建了一个stub

创建了这个stub之后,回到客户端代码执行下一句,进行lookup,传入name查找远程对象

现在对应的是流程图中Client传入name获取Stub的过程

这里我们直接进入RegistryImpl_Stub类来看一下lookup方法

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
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);
}
}

传入的name是会执行writeObject方法,被序列化,说明注册中心那里会执行反序列化

其中的invoke可以理解为用于激活的函数

1
super.ref.invoke(var2);

追踪到写有invoke的接口找到他的实现类

继续跟进,发现它调用了StreamRemoteCall类的executeCall()方法,这个方法才是真正处理网络请求的方法

回到lookup,继续查看代码逻辑

执行了invoke之后看到获取了输入流,之后反序列化读取了返回值

var23就是返回的远程服务对象的动态代理

1
2
ObjectInput var6 = var2.getInputStream();
var23 = (Remote)var6.readObject();

客户端向注册中心传入名称和获取返回值的过程经过了序列化和反序列化

我们再次回到executeCall()方法,在这里下一个断点

注意到executeCall()方法中有一个处理异常的地方,这里用到了反序列化

1
2
3
4
5
6
7
case TransportConstants.ExceptionalReturn:
Object ex;
try {
ex = in.readObject();
} catch (Exception e) {
throw new UnmarshalException("Error unmarshaling return", e);
}
  • case TransportConstants.ExceptionalReturn:
    当接收到的返回值类型是 异常返回 的情况时执行。

  • Object ex;
    定义一个对象 ex,用于存放反序列化出来的异常对象。

  • try { ex = in.readObject(); }
    尝试从输入流 in 中读取一个对象(反序列化),并赋值给 ex

  • catch (Exception e) { throw new UnmarshalException("Error unmarshaling return", e); }
    如果在反序列化过程中出现任何异常,就抛出一个 UnmarshalException,并附带原始异常 e,提示“反序列化返回值时出错”。

这里是可能存在安全问题的,如果注册中心返回一个恶意的流,会被这里的捕捉,也会正常反序列化

所以说只要一个方法中调用了invoke方法,就可能存在反序列化漏洞

我们看到bind方法中,也是调用了invoke的

继续调试,可以看到获取了远程对象的代理,可以看到服务运行端口

客户端请求服务端-客户端

客户端调用服务端方法时,lookup获取到的实际上是一个动态代理,不管调用什么方法,都会走到调用处理器的invoke方法

所以说执行到方法调用时,会先走到invoke方法

先跳过if判断流程,发现最后进入了invokeRemoteMethod方法

这个方法会调用另一个invoke

这个看一下这个invoke具体做了什么

首先关注到这里有一个marshalValue,它会序列化一个值,这里序列化的是我们传入的字符串hello

继续执行,看到它调用了executeCall()方法,客户端所有有关网络请求的操作都会调用这个方法

后面还有一段逻辑,如果返回值不为空,会调用unmarshalValue

这个函数中会调用readObject执行反序列化,可能存在安全问题

小结

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

具体表现形式是服务端打客户端,入口类在 call.executeCall(),里面抛出异常的时候会进行反序列化。

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

  • 再总结一下代码的流程

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

注册中心部分

客户端发起请求,注册中心的处理

这里的断点是要下在服务端的,我们知道客户端操作的是stub,那么服务端操作的就是skeleton

在有了 Skel 之后应当是存在 Target 里面的,所以我们的断点打到处理 Target 的地方

断点下在Transport类的第176行

下好断点后,先点服务端的debug,再运行Client就饿可以了

可以看到成功停在断点处

看一下Target中封装了什么

可以看到里面的stub是Registry_Stub,里面ref封装了port=1099

再往下走 final Dispatcher disp = target.getDispatcher();这一步是对disp做一些处理

可以看到disp就是UnicastSeverRef,我们还记得skel是在这个里面的

继续跟进,看到它对disp调用了dispatch方法

跟进去看一下dispatch方法具体是做什么的

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

跳过一些不重要的步骤,最后走到了skel调用dispatch方法的地方

直接走进dispatch方法,发现来到了RegistryImpl_Skel类

这个类是调试不了的,所以只能看静态代码

把代码贴在这里

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
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;
switch (var3) {
case 0:
String var100;
Remote var103;
try {
ObjectInput var105 = var2.getInputStream();
var100 = (String)var105.readObject();
var103 = (Remote)var105.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(var100, var103);

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

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

Remote var101 = var6.lookup(var98);

try {
ObjectOutput var9 = var2.getResultStream(true);
var9.writeObject(var101);
break;
} catch (IOException var88) {
throw new MarshalException("error marshalling return", var88);
}
case 3:
Remote var8;
String var97;
try {
ObjectInput var11 = var2.getInputStream();
var97 = (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(var97, var8);

try {
var2.getResultStream(true);
break;
} catch (IOException var84) {
throw new MarshalException("error marshalling return", var84);
}
case 4:
String var7;
try {
ObjectInput 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");
}

}
}

在正式详细分析之前,我们还是先来梳理一下这个方法的大致逻辑

可以看到方法中有很多case分支

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

  • list
  • bind
  • rebind
  • unbind
  • lookup

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

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

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

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

0 -> bind 是可以攻击的

r57

lookup也可以

rebind

unbind

实际上只有list是不会产生反序列化漏洞的

小结

总结一下,在客户端发起请求后,注册中心接收后主要是操作了Target,之后调用了 dispatch方法

dispatch方法中存在反序列化漏洞点,可以结合CC链去打

服务端部分

客户端请求服务端-服务端响应

Stub=$proxy0

处理网络请求时在服务端都要先处理Target,所以我们依旧利用刚才的断点

先跳过封装有DGCImpl_Stub的Targrt

看到Target封装里stub是Proxy的开始跟进,这是我们创建的远程对象的动态代理

这里也会调用dispatch方法

但是这里的skel是空的,所以不会走到oldDispatch方法,而是直接跳过了

会直接获取输入流,然后执行 hashToMethod_Map.get(op) 代码,相当于getMethod

跳过中间的一些步骤,直接跟进到调用 unmarshalValue方法的地方,我们之前提到这个方法会反序列化传入的参数,所以说这里也是可能产生反序列化漏洞的

再次走入来看一下这个函数

可以看到调用了readObject函数反序列化了

继续跟进看到走进invoke方法进行真正的远程调用

调用后我们看到控制台输出结果”HELLO”

但是程序还会继续执行,看到这里会调用marshalValue方法,是会将输出结果序列化返回给客户端

Stub=RegistryImpl_Stub

分布式垃圾回收

看一下他是怎么创建的

断点需要下在 ObjectTable 类的 putTarget() 方法里面。并且将前面两个断点去掉,直接调试即可

这里调用了一个静态变量

在 DGC 这个类在调用静态变量的时候,就会完成类的初始化

类的初始化会执行这个类的静态代码块

就在这个类的静态代码块中,run方法new了一个对象

继续跟进

发现它创建了一个stub

这里和注册中心创建远程服务一样,尝试是否可以获取到这一个类 — DGCImpl_Stub

这一个 DGCImpl_Stub 的服务至此已经被创建完毕了,它也是类似于创建远程服务一样,但是它做的业务不一样。注册中心的远程服务是用于注册的,这个是用于内存回收的,且端口随机。

再来看一下这个stub的类中有什么方法

我们重点关注一下 DGC 的 Stub 里面有漏洞的地方。

DGCImpl_Stub 这个类下,它有两个方法,一个是 clean,另外一个是 dirty。clean 就是”强”清除内存,dirty 就是”弱”清除内存。

这里调用了 readObject() 方法,存在反序列化的入口类。

同样在 DGCImpl_Skel 这个类下也存在反序列化的漏洞,如图。

小结

  • 是自动创建的一个过程,用于清理内存。

漏洞点在客户端与服务端都存在,存在于 SkelStub 当中。这也就是所谓的 JRMP 绕过


Java安全-RMI基础(RMI通信原理)
http://huang-d1.github.io/2025/09/24/Java安全-RMI基础/
作者
huangdi
发布于
2025年9月24日
许可协议