RMI 学习
RMI复盘 [TL;DR]
服务端
首先是创建RMI服务端,远程对象,远程接口
- 接口 必须继承自Remote,换言之,它必须是个远程接口
- 所有远程接口的方法必须声明
throws RemoteException,原因:让客户端能够处理远程调用可能出现的各种网络和服务器问题
因为在创建stub代理的时候会检查远程接口的方法isAssignableFrom(RemoteException.class)

1 | // 这里是sun包里面的源码,自己添加sdk的源路径 |
所以接口的方法需要throws RemoteException
1 | public interface IRemoteObj extends Remote { |
- 远程对象(实现了远程接口)
因为在UnicastRemoteObject里面构造函数会使用(Remote) this强制转换,所以前面接口需要extends Remote
1 | //usr/lib/jvm/jdk1.8.0_65/src.zip!/java/rmi/server/UnicastRemoteObject.java |
如果不继承UnicastRemoteObject就需要UnicastRemoteObject.exportObject(this, 0);手动导出为远程对象
1 | public class RemoteObjImpl extends UnicastRemoteObject implements IRemoteObj { |
- 服务端,通常和注册中心放在一起
1 | placeholder |
远程对象创建流程
对象的父类的字段会在对象的构造方法之前执行
现在有对象了,重点是对象发布到网络上去的过程,核心理解:export就是导出为远程的东西
前面的自定义对象,不管是继承UnicastRemoteObject还是手动导出为远程对象,都会走到这个静态方法
1 | //usr/lib/jvm/jdk1.8.0_65/src.zip!/java/rmi/server/UnicastRemoteObject.java |
前面的obj是我们创建的远程对象,有功能实现的逻辑,那么后面的UnicastServerRef(port)自然就是处理网络请求的部分
创建UnicastServerRef
UnicastServerRef创建了一个LiveRef,也就是UnicastRemoteObject.sref=UnicastServerRef,且这个sref.ref=LiveRef
这个LiveRef是核心
经过层层构造链,会把传入的obj的ref字段,设置为刚刚生成配置好的UnicastRemoteObject.sref,然后继续用这个sref导出obj,目前这一步还是在UnicastRemoteObject进行导出,从上面的公开方法调用自己的私有方法
1 | //usr/lib/jvm/jdk1.8.0_65/src.zip!/java/rmi/server/UnicastRemoteObject.java |
创建Stub
下一步走到UnicastServerRef进行导出,创建了给client用的stub代理对象
其实一路上都是在调用这个exportObject,只不过是在不同的类里面调用
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java |
stub不是客户端用的吗,为什么在服务端创建了?
因为实际上是服务端创建好放到注册中心,客户端去注册中心拿,然后再使用
也就是服务端在创建远程对象的时候,同时生成了stub和skeleton这两个代理,之后stub会放到registry里面,skeleton留在服务端,client通过注册中心获取stub,然后通过stub操作skeleton,进而操作服务端的远程对象
[!Note]
stub和skeleton的通信,其实就是client和service的通信

在stub = Util.createProxy(implClass, getClientRef(), forceStubUse);中
forceStubUse = true,强制使用stub:无论什么情况,都通过代理/存根进行远程调用forceStubUse = false,默认为false,根据实际情况优化调用路径,可能是本地实现,也可能是远程代理
而这里的clientRef实际上就是用UnicastRef封装的LiveRef,还是同一个东西,stub里面的handler也是这个
服务端UnicastServerRef和客户端的UnicastRef用的是同一个LiveRef,可以理解为服务端和客户端它们之间要通信,所以用同一个

后面就是一个标准的创建动态代理的流程了
而后半部分创建的target,则相当于封装了目前所有有用的东西
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java |
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/LiveRef.java |
ep开始进入tcp transport之类的了,这里的ep好像要动态才能知道,静态分析不知道,这里只跟进到了接口
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/Endpoint.java |
在动态调试的时候,才能知道LiveRef的ep是个tcp Endpoint
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/tcp/TCPEndpoint.java |
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/tcp/TCPTransport.java |
TCPEndpoint.exportObject=>TCPTransport.exportObject,调用listen()方法
在一个新的线程创建socketserver = ep.newServerSocket();并等待客户端连接,接下来自然就是如果收到客户端请求该执行什么的逻辑了
走AcceptLoop的run,java多线程的标准写法
网络请求的线程和代码逻辑的线程是独立的
值得一提的是在创建这个ServerSocket的过程中,一开始默认port=0,最后会走到JNI调用系统原生的方法随机获取空闲端口

存表
target导出完,exportObject之后,服务端调用了ObjectTable的静态方法,进行了记录ObjectTable.putTarget(target);
要知道自己的对象发布到哪去了,所以要记录
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/Transport.java |
保存在了两个静态的表里
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/ObjectTable.java |
之后就没什么特别的了,至此完成了service创建并发布的过程
关于网络请求
LiveRef默认传入ep=TCPEndpoint.getLocalEndpoint(0),isLocal=true
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/LiveRef.java |
UnicastServerRef继承自UnicastRef,UnicastRef实现了RemoteRef接口,同时自己有一个ref字段,存储这个LiveRef
[!Note]
这里的UnicastServerRef相当于服务端,UnicastRef相当于客户端
从始至终只创建一个LiveRef (对应远程服务的ip:port),剩下的都是在赋值
1 | public UnicastRef(LiveRef liveRef) { |
而这里的TCPTransport是底层真正处理网络连接的类

有点绕绕的啊,这个TCPTransport里面有个epList是LinkedList,里面的唯一一个元素又是我们刚才的TCPEndpoint@786

注册中心
使用静态方法创建注册中心,LocateRegistry.createRegistry(1099);
1 | //usr/lib/jvm/jdk1.8.0_65/src.zip!/java/rmi/registry/LocateRegistry.java |
创建RegistryImpl对象,之后export这个RegistryImpl对象
和上一节服务端创建远程对象如出一辙,都是Impl,各种UnicastServerRef,UnicastRef,LiveRef
在构造方法中,执行了setup
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/registry/RegistryImpl.java |
这个是和其实new UnicastServerRef(lref) 也执行了一遍,
[!Warning]
这里要自己调一下,forceStubUse 和 permanent 的区别
这里uref.exportObject(this, null, true);的permanent为true,说明这个RegistryImpl是“永久的”,使用jdk自带的RegistryImpl_Stub
这和上一节服务端导出远程对象的sref.exportObject(obj, null, false);不同,我们自己创建的对象不是“永久的”
创建Skeleton
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java |
之后同样走到创建stub,不同的是这里的stub,由于是jdk自带的stubClassExists(remoteClass)为true,在createProxy的时候会走进这个if分支,通过createStub返回一个RemoteStub对象
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/Util.java |
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/Util.java |
进而创建的stub代理是RemoteStub,会进行setSkeleton(impl);
这里本质是创建服务端的代理skeleton,不是动态代理,是直接forName反射创建的,是jdk里面自带的静态预生成的类,和客户端的stub相对
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/Util.java |
skel 是一个内部对象
在UnicastServerRef中设置skel为RegistryImpl_Skel,然后把这个Ref赋值给RegistryImpl对象的ref字段(extends java.rmi.server.RemoteServer extends RemoteObject,来自RemoteObject)

之后同样把这些impl,serverRef,stub放到一个target里面,经过层层export,最终放到一个静态表里面
这里和刚才创建服务端的区别是impl里面有个skel,还放了stub

也就是说,这个时候,静态表里面的stub,ref不仅有我们自己的远程对象的,还有注册中心对象的
但事实上,有三个!

- 自己的obj
- RegistryImpl:endpoint是自己定的端口,如1099
- DGCImpl:和obj的endpoint是同一个,但是封装的ref不同
绑定远程对象
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/registry/RegistryImpl.java |
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/registry/RegistryImpl.java |
checkAccess("Registry.rebind");一个安全检查,大概意思就是检查是不是在本地绑定的
一般来说RMI的服务端和注册中心是在一台机器上的,也就是的确是本地绑定的
在低版本的话有一些实现上的问题,是允许远程绑定的,但是默认的话还是本地绑定,所以服务端和注册中心是在一台机器上的
客户端(接受结果)
与注册中心
1 | Registry r = LocateRegistry.getRegistry("127.0.0.1", 1099); |
LocateRegistry.getRegistry("127.0.0.1", 1099);这里相当于客户端根据参数,本地又创建了一个一模一样的RegistryImpl_Stub
1 | //usr/lib/jvm/jdk1.8.0_65/src.zip!/java/rmi/registry/LocateRegistry.java |
1 | //usr/lib/jvm/jdk1.8.0_65/src.zip!/java/rmi/registry/LocateRegistry.java |
[!Note]
上面的源码注释写的挺好的!!!正好借此机会再回顾一下前文提到的总结一句话就是:获取 Registry 的 stub 时,可以根据属性选择使用静态预生成 stub 还是动态代理
对于return (Registry) Util.createProxy(RegistryImpl.class, ref, false);这个,可以发现,客户端竟然是也创建了一个一模一样的RegistryImpl_Stub,区别于 RemoteObjectImpl_Stub (自定义的远程对象)
[!Tip]
就是按我们正常理解:本来在服务端创建了,按理来说不应该通过序列化/反序列化给到客户端吗?
但实际上这里没有这样的操作,反而是让客户端自己重新创建一个
接着r.lookup("remoteObj");根据这个服务的naming,通过上面的stub去注册中心获取存储在注册的remoteObj的stub
这里调试本来应该走到RegistryImpl_Stub的lookup方法,但是由于这个类没有源码,而且bytecode是Java1.1,反编译出来行号对不上,所以没法调试,只能静态地去看它做了什么,但是该执行的流程还是会走的

1 | //usr/lib/jvm/jdk1.8.0_65/jre/lib/rt.jar!/sun/rmi/registry/RegistryImpl_Stub.class |
可以看到这里把lookup传入的字符串进行了序列化操作,那么注册中心肯定就会有反序列化读的过程
接着super.ref.invoke(var2);这里的var2是个RemoteCall对象
这里如果直接去定义的话会走到接口,可以在左边找实现方法

1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastRef.java |
然后call.executeCall(),而这个就是客户端真正处理网络请求的方法
再完成网络通信后,又进行了反序列化的操作,把远程对象的动态代理stub读回来
[!Warning]
换言之,这个从注册中心获取Remote Stub的过程 (var23) ,是通过反序列化来实现的,自然也就有了漏洞的风险
更为关键是还是在这个super.ref.invoke(var2);=>call.executeCall();
- 这是所有客户端stub里面处理网络请求的都会调用的方法,这更加隐蔽
- 不只是lookup,RegistryImpl_Stub的list,bind,rebind等等,都调用了这个方法
- 也就是说:所有的RMI客户端,基本上都是可以被攻击的,因为有这个invoke
[!Note]
值得注意的是,这里还有个异常处理,如果触发TransportConstants.ExceptionalReturn,会通过反序列化来获取这个ex对象(本意可能是想获取这个ex对象,来查看更详细的Exception异常信息吧)
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/StreamRemoteCall.java |
与服务端
客户端进行远程调用,调用获取的stub代理的对象方法,走到RemoteObjectInvocationHandler.invoke
接着一步步走到UnicastRef的另一个重载的invoke方法,和前面的传参是 RemoteCall 然后直接调用call.executeCall();的不一样
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastRef.java |
然后在marshalValue中对传入的远程对象方法的参数进行序列化

接着,又调了…只要客户端想要进行远程网络通信,就会调用这个方法
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastRef.java |
然后,在服务端的对象执行方法后,返回的结果,仍然是通过反序列化获取的
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastRef.java |
然后走到类型判断,我这里远程对象方法调用后的返回类型是String所以getType之后是class java.lang.String,不是基本类型,所以会反序列化读取
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastRef.java |
综上,客户端的RMI有两个反序列化利用点
- 远程调用返回值反序列化,所以可以被恶意的服务端利用,服务端返回一个恶意的序列化对象,然后客户端触发反序列化链
- 协议的反序列点,也就是call.executeCall();
而这个协议,正是JRMP协议!
客户端(发起请求)
与注册中心 (在注册中心端进行调试)
前面讲stub的时候说到:会把target导出=>TCPEndpoint.exportObject=>TCPTransport.exportObject,调用listen()方法,在一个新的线程创建socketserver = ep.newServerSocket();,后面会有接收到请求对应处理的逻辑
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java |
…省略了endpoint那一条导出的链
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/tcp/TCPTransport.java |
这里RegistryImpl_Stub也是如此,得走到这个线程里面看看,是怎么处理来自客户端的请求的,于是定位到
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/tcp/TCPTransport.java |
这里的AcceptLoop是TCPTransport的内部类,会调用run方法,走到executeAcceptLoop()里面,又进入到一个新的线程
[!Note]
在 Java 中,创建一个线程要么实现Runnable接口,要么继承Thread类
前者更现代化,因为 Java 是单继承限制,如果已经继承了其他类,就无法再继承 Thread 了
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/tcp/TCPTransport.java |
这里的ConnectionHandler还是TCPTransport的内部类,还是看run方法=>run0方法

run0方法中,解析协议字段,根据不同协议类型(TransportConstants.SingleOpProtocol/StreamProtocol),调用外部类TCPTransport的handleMessages(conn, false/true);
[!Warning]
这里的 handleMessages 是重点
这里的bool是persistent参数,表示通信是否是持久连接
handleMessage进去之后,根据协议有不同的switch case,这里当然是研究RMI的ServiceCall了
之后会进行RMI call的handle,走到Transport抽象类的serviceCall方法

在这里面从target表中获取含ref(LiveRef)和skeleton(RegistryImpl_Skel)的disp(UnicastServerRef)
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/Transport.java |
[!Question]
这里的Dispatcher又是什么呢?其实,本质上还是属于skeleton的范畴
早期 Java RMI (JDK 1.1及以前),每一个对象都会创建一个skeleton,专门用于通信(接受解析客户端网络请求,然后调用实际远程对象上的对应方法,再返回字节流给客户端的Stub)
缺点:每个远程类都要单独生成 skeleton,而且一旦方法改变,skeleton 必须重新生成,缺乏灵活性
从 JDK 1.2 开始,Java 引入了反射 API,可以在运行时动态获取类的方法信息并调用远程对象的方法,从而废弃了预生成的skeleton
为了实现这种动态分派,RMI 引入了 Dispatcher 接口,在
sun.rmi.transport包中,属于非公开 API
Dispatcher 的作用
- 统一入口
- 方法分发,根据RemoteCall中读取的Method Hash,找到对应的
java.lang.reflect.Method对象 - 上下文切换,设置ContextClassLoader,远程调用
- 参数反序列化,在
method.invoke之前,根据方法签名,从连接流中反序列化客户端传来的参数 - 结果回写,执行完后,dispatcher负责捕捉返回值/异常,并将其序列化通过
RemoteCall发回给客户端
但是你会发现 RegistryImpl 还是使用了 skeleton,这是因为
[!note]
RegistryImpl_Skel是 JDK 源码中自带的,不需要在运行时通过反射去解析lookup,bind等方法。由于 Registry 的接口是固定的(java.rmi.registry.Registry),使用硬编码的 Skeleton 效率更高且更稳定而且为了向后兼容性,以及不同版本的 JVM 通信的统一,所以 Registry 采用的还是 Skel 的方式
对于自定义的远程对象RemoteObjectImpl,都采取更现代的Dispatcher进行管理,让client能反射调用目标对象
最终走到disp.dispatch(impl, call);在UnicastServerRef中进行处理,这里的impl是RegistryImpl
[!tip]
其实你仔细读读源码就知道我上面讲的是什么一回事了
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java |
这里显然UnicastServerRef的skel为RegistryImpl_Skel,不为null
走到oldDispatch(obj, call, num);,调用skel的dispatch,也就是服务端调用对象的代理了
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java |
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java |
这里的RegistryImpl_Skel同样因为Java版本问题,只能静态分析了,也有各种case,对应bind,rebind,lookup等等不同请求时如何处理
可以看到在case2(lookup)中,存在反序列化操作,别的case中也同样存在这个问题
1 | //usr/lib/jvm/jdk1.8.0_65/jre/lib/rt.jar!/sun/rmi/registry/RegistryImpl_Skel.class |
根据前面分析我们知道,在client端进行lookup时,r.lookup("remoteObj");,这个参数——远程对象的naming,是通过RegistryImpl_Stub序列化之后传进去的
与服务端
流程和注册中心大差不差
只是从target里面取出来的impl是远程对象自己的RemoteObjImpl,disp以及里面的ref对应也是远程对象service的,而不是注册中心的,另外也没有skel字段了
就走到远程方法调用执行的步骤了
由于前面客户端会把远程方法调用的参数序列化传进来,所以这里就会在服务端进行反序列化操作
也是unmarshalValue,之后就有参数可以调用执行了
最后再把调用的结果序列化后返回给客户端,于是和前面客户端反序列化调用结果相呼应
这是个对称的过程
DGC-分布式垃圾回收
DGC,我的理解就是一个使用 租约(references)来跟踪哪些客户端持有服务器对象的活引用(leases)
服务端service创建,在putTarget时会把,我们的远程对象的stub代理加进去
但是在这之前,会放入一个DGCImpl_Stub的代理
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/Transport.java |
1 | //usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/ObjectTable.java |
这里的DGCImpl.dgcLog是个静态变量,根据类加载的内容,调一个类的静态变量的时候,会完成这个类的初始化
初始化->静态代码块,接着走到DGCImpl的静态代码块里面

由于jdk自带有DGCImpl_Stub,所以会得到一个RemoteStub对象DGCImpl_Stub,而不是stub动态代理Proxy
这个dgc的stub也对应一个ref,一个端口,用于远程回收服务,之后调用流程和注册中心RegistryImpl类似
接着看看DGCImpl_Stub有dirty和clean两个方法,一个是referenced,一个是unreferenced
这两个方法都调用了super.ref.invoke(),也就是JRMP攻击
除了这两个方法,还有会从客户端反序列化一个对象回来
同理DGCImpl_Skel也会受到服务端的反序列化攻击
/usr/lib/jvm/jdk1.8.0_65/jre/lib/rt.jar!/sun/rmi/transport/DGCImpl_Skel.class
- 只要有远程对象,就会有回收机制,有DGC服务
- 而攻击远程对象需要知道参数类型,而攻击DGC则不需要
RMI总结
感觉或许可以从stub, skel中的readObject往前分析
像是DGCImpl_Stub, DGCImpl_Skel, RegistryImpl_Stub, RegistryImpl_Skel
- Skel 主要处理 dispatch,进行解包 Unmarshalling,序列化/反序列化,调用远程对象,结果返回等等 (但是JRMP 1.2+之后除了RegistryImpl都变成了动态反射)
- Stub (如RegistryImpl_Stub,既给服务端使用,也给客户端使用)处理 lookup, bind, rebind, invoke 这些的逻辑,后两种有 checkAccess,确保只在服务端使用,而lookup则不限制是否远程
然后总结反序列化就是,服务端客户端,两边都可以被打
因为 传参/ 调用后执行结果 的 网络传输,就是以序列化/反序列化实现的
所以
- 服务端/注册中心
- 客户端
- DGC
这三个都能被利用
RMI jdk8 高版本绕过
1. 对比 8u65 做了哪些过滤?
[!Note]
JDK 8u121 之后 引入了 JEP 290 机制
Registry 白名单化:为
RegistryImpl增加了registryFilter。现在它只允许反序列化一些基础类(如String,Number,Remote,Proxy等)。像CommonsCollections里的恶意类会被直接拦截。DGC 限制:为分布式垃圾回收(DGC)增加了
checkInput过滤,限制了 DGC 通信中允许反序列化的类型。可配置的全局过滤器:允许开发者通过
jdk.serialFilter属性定义全局的反序列化黑白名单。
2. 为什么 8u121 还能被攻击?(Bypass 原理)
虽然有了白名单,但攻击者发现:白名单里虽然没有恶意类,却有能发起“二次连接”的合法类。 这也就是著名的 JRMP 反弹攻击。
- 白名单里的“内鬼”:
UnicastRef或RemoteObject的子类是在白名单里的。 - 逻辑漏洞:虽然你不能直接塞一个“炸弹(恶意类)”给服务端,但你可以塞一个“诱饵(UnicastRef)”。
- 攻击链条:
- 攻击者向服务端发送一个合法的
RemoteObject对象,其内部封装了一个UnicastRef。 UnicastRef包含攻击者控制的 恶意 JRMP 服务端地址。- 服务端反序列化这个
RemoteObject时,并不会触发危险操作,因此通过了 JEP 290 过滤。 - 关键点:反序列化完成后,RMI 系统的分布式垃圾回收机制(DGC)为了维持引用计数,会自动向这个
UnicastRef指向的地址发起一个 JRMP 客户端请求(去拉取数据或同步状态),相当于 把服务端作为一个客户端 然后访问攻击者的服务端,然后返回一个Exception Response走 JRMP 的恶意payload。
- 攻击者向服务端发送一个合法的
为什么这样能行呢?因为客户端发起是没有设置防护的,主要还是防护服务端(服务是公有的嘛,客户端自己连接恶意服务端就自己负责了)
RMI客户端之所以能通过传递参数攻击服务端并绕过JEP290,核心原因在于JEP290默认只保护了RMI注册中心(Registry)和分布式垃圾收集器(DGC)的入口反序列化点,并未对注册中心作为客户端去连接恶意服务器时产生的出口反序列化进行过滤。
就是 把服务端 当成一个 JRMP 客户端,去连接 恶意的 JRMP 服务端(控制返回报错信息)
和DGC 层的反序列化是不一样的,虽然都会走 DGC 的一部分
DGC 与 lookup 对比
| 维度 | lookup 请求 | DGC (Dirty/Clean) 请求 |
|---|---|---|
| 触发者 | 开发者手动编写代码触发 | RMI 运行时自动触发 (引用管理) |
| 目的 | 获取远程对象的 Stub | 告诉远程端:“我还引用着你,别回收” |
| 数据方向 | 客户端 -> 注册中心 | 服务端 -> 攻击者服务器 |
| JEP 290 覆盖度 | 8u121 重点防御了输入流 | 8u121 早期对 DGC 返回值的过滤较弱 |
需要简单梳理一下RMI
看完这篇醍醐灌顶啊 https://xz.aliyun.com/news/7527

- 标题: RMI 学习
- 作者: OneZ3r0
- 创建于 : 2026-03-13 14:14:11
- 更新于 : 2026-05-05 18:31:44
- 链接: https://blog.onez3r0.top/2026/03/13/java-rmi-learning/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。