Java RMI反序列化深度解析
RPC
Remote Procedure Call,远程过程调用。像本地调用方法一样调用一个远程方法。
RMI概念
RMI,全程Remote Method Invocation,远程方法调用,一种用于实现远程过程调用的应用编程接口。
它使客户端上运行的程序可以调用远程服务器上对象的方法,即通过某个java虚拟机上的对象来调用另一个java虚拟机中对象的方法。
客户端获取的是远程主机上对象的引用,无论何处使用引用,方法调用都发生在原始对象上
RMI实现了RPC,通常使用Java原生反序列化,并且可以结合动态类加载和安全管理器来安全传输一个Java类。
远程对象和非远程对象
远程对象:RMI中的远程对象首先需要可以序列化;并且需要实现特殊远程接口的对象,该接口指定可以远程调用对象的哪些方法;其次该对象是通过一种可以通过网络传递的特殊对象引用来使用的。和普通的 Java 对象一样,远程对象是通过引用传递。也就是在调用远程对象的方法时是通过该对象的引用完成的。
非远程对象:非远程对象与远程对象相比只是可被序列化而已,并不会像远程对象那样通过调用远程对象的引用来完成调用方法的操作,而是将非远程对象做一个简单地拷贝,也就是说非远程对象是通过拷贝进行传递。
Stub和Skeleton
RMI引入Stub(客户端存根)和Skeleton(服务端骨架)两个概念。
当我们在远程对象上调用方法,实际上是调用一些本地代码作为该对象的代理。也就是当客户端试图调用一个远端对象,实际上会调用客户端本地的一个代理类,也就是Stub。而在调用服务器端的目标类之前,也会经过一个对应的代理类,也就是Skeleton。它从Stub接受远程方法调用并将他们传递给对象。
RMI架构
- client:客户端,发起远程调用请求,持有Stub作为远程对象的本地代理
- server:服务端,提供远程服务,通过Skeleton接受并处理请求
- Registry:注册中心,存储服务名称与远程对象引用的映射,类似于RMI的电话薄。类似于一个网关,自己并不执行远程方法。但服务器可以在上面注册一个Name到对象的绑定关系。客户端通过Name向注册中心查询,得到绑定关系后,再链接到服务端。使用注册中心查找对另一台主机上已经注册远程对象的引用。
实现接口和类
远程接口
该接口指定可以远程调用远程对象的哪些方法。远程接口必须继承java.rmi.Remote接口,远程对象将实现这个远程接口。
注意即使是public修饰字段都不能通过远程接口来进行访问,如果需要访问,可通过编写一些setter和getter方法
远程接口中所以方法都需要抛出java.rmi.RemoteException异常
java.rmi.Remote接口
java.rmi.Remotie接口用于标识可从非本地虚拟机调用其方法的接口。作为远程对象必须直接或间接实现该接口。只有那些实现了远程接口(java.rmi.Remote接口或继承java.rmi.Remote接口)的方法才能被远程调用。如:
1 | import java.rmi.Remote; |
如果服务端实现该接口并重写了该方法就能被调用
java.rmi.server.UnicastRemoteObject类
RMI提供了一些远程对象实现可以继承的便利类,这些类有助于远程对象的创建,其中包括java.rmi.server.UnicastRemoteObject类。
通常远程对象类需要继承java.rmi.server.UnicastRemoteObject类,在RMI中 UnicastRemoteObject类是与Object超类等效的,该类提供了equals( ) , hashcode( ), toString( )方法;并且在RMI运行时,继承UnicastRemoteObject类的子类会被exports 出去,绑定随机端口,开始监听来自客户端(Stubs)的请求。
而如果去掉该类,就无法生成Stub代理对象,而RMI注册表Registry在绑定对象时需要传递Stub对象,则导致序列化对象时对象未导出使序列化错误。
RMI动态加载类
前面说过RMI可使用动态加载类和安全管理器来安全传输Java类
在RMI过程中客户端和服务器数据传输有以下特点:
RMI的客户端和服务端是通过将数据进行序列化来传输的,所以当我们传递一个可序列化的对象作为参数进行传输时,在服务端肯定会对其进行反序列化。
在RMI的动态加载类机制中,如果需要用到某个类但JVM中没有这个类,它可以通过远程URL去下载这个类。那么这个URL可以是http、ftp协议,加载时可以加载某个第三方类库jar包下的类,或者在指定URL时在最后以\结束来指定目录,从而通过类名加载该目录下的指定类。
RMI动态加载时用到的是java.rmi.server.codebase属性,该属性表示一个或多个URL位置,可以从中下载本地(CLASSPATH)找不到的类,相当于一个代码库。
设置java.rmi.server.codebase属性:
1 | System.setProperty("java.rmi.server.codebase","http://127.0.0.1:5432/"); |
对于客户端而言,如果服务端方法的返回值可能是一些子类的对象实例,而客户端并没有这些子类的class文件,如果需要客户端正确调用这些子类中被重写的方法,客户端就需要从服务端提供的java.rmi.server.codebaseURL去加载类。
对于服务端而言,如果客户端传递的方法参数是远程对象接口方法参数类型的子类,那么服务端需要从客户端提供的java.rmi.server.codebaseURL去加载对应的类。
客户端与服务端两边的java.rmi.server.codebaseURL都是互相传递的。
但无论是客户端还是服务端要远程加载类,都需要满足:
- 由于Java SecurityManager的限制,默认是不允许远程加载的,如果需要远程加载类,需要安装RMISecurityManager并配置java.security.policy
- 属性java.rmi.server.useCodebaseOnly的值必须为false
有三种方法配置policy:
1 | //client.policy |
RMI Demo
接口实现
先定义一个远程接口,远程接口定义我们需要使用的方法:
1 | package rmi; |
服务端
服务端需要在注册中心注册远程对象,所以一般服务端和注册中心registry一起的。
先定义一个实现该接口的类来重写Hello方法,即我们的远程对象:
1 | package rmi; |
然后注册一个Name,在注册中心绑定远程对象:
1 | package rmi; |
客户端
向注册中心查询相应的Name,并调用远程对象的方法:
1 | package rmi; |
首先启动服务端RMI服务,运行服务端代码,然后运行客户端查询并调用远程对象方法:
服务端:
客户端:
源码分析
服务端
定义rmi服务器地址
1 | //rmi服务地址 |
这部分是为了定义rmi的服务器地址
创建RMI注册中心
1 | LocateRegistry.createRegistry(PORT); |
跟进一下:
LocateRegistry.createRegistry方法会返回一个RegistryImpl对象,继续跟进:
这里if和else逻辑是相似的,都会创建一个LiveRef对象,port传入作为注册中心的端口,LiveRef是一个网络引用的类,用于网络请求方面,后面会提到,这里我们传入了端口。
然后将其封装进UnicastServerRef对象,随后调用RegistryImpl.setup方法传入UnicastServerRef对象,所以注意这个LiveRef在后面的分析中都会存在:
在setup方法中会调用uref.exportObject(this,null,ture)方法:
其中this为RegistryImpl对象
先看第一个,在UnicastServerRef.exportObject方法中会调用Util.createProxy创建一个代理类,看名字似乎是创建stub客户端存根,跟进createProxy方法:
关键是这段,他先调用stubClassExists方法:
该方法会寻找RegistryImpl_stub,然后返回ture
接着进入createStub方法,remoteClass就是我们的RegistryImpl对象:
这里的逻辑就是会返回RegistryImpl_stub实例,即stub变量就是RegistryImpl_stub实例
接着回到我们的UnicastServerRef.exportObject方法,看第二部分:
当stub为RemoteStub实例时会调用setSkeleton(impl),而stub是RegistryImpl_stub实例继承RemoteStub,所以会调用setSkeleton(impl),impl为RegistryImpl对象,看方法名似乎是设置服务端存根Skeleton,跟进一下:
这里调用Util.createSkeleton(impl)方法并赋值给skel:
这里获取Registry_Skel赋值给skelname变量,最后返回Registry_Skel实例,所以第二部分就是将Registry_Skel实例赋值给skel,相当于创建Skeleton根存
最后看第三部分:
最后实例化了一个Target类,这个Target对象封装了我们RegistryImpl对象和RegistryImpl_Stub对象
接着调用liveRef.exportObject将我们封装好的对象发布出去,具体看看它的发布逻辑:
ep的值就跟我们前面的创建的LiveRef对象有关了,查看构造方法:
ep为endpoint,而根据我们之前创造的LiveRef对象,ep为TCPEndpoint.getLocalEndpoint(port):
该方法调用getLoclaEndpoint方法:
而在getLoclaEndpoint方法中会创建一个TCPEndpoint对象并获取了端口号等信息,最后返回ep,所以最后会调用TCPEndpoint.exportObject(target)方法:
然后调用transport.exportObject(target),这里transport为TCPTransport对象,即调用TCPTransport.exportObject(target):
这里调用了listen()方法开启监听:
在TCPTransport.listen中,先获取 TCPEndpoint,获取端口号后执行server = ep.newServerSocket();创建一个新的socket,就等别人来连接:
而在建立socket过程中,如果端口号为0即默认值,就会创建一个随机端口,而创建注册中心时我们传入了端口,所以端口为5432:
之后在调用super.exportObject(target);完成连接之后的事,跟进Transport.exportObject(target):
主要将Target对象存放进ObjectTable中,ObjectTable用来管理所有发布的服务实例Target
这时我们来看看Stub的值:
新出现了DGCImpl_Stub,而Skel也变为了DGCImpl_Skel,这是分布式垃圾回收的一个对象,这个后面再提。
发布完后最后返回stub
最后看下发布对象后的变量:
skel为Registry_Skel对象,stub为RegietryImpl_Stub对象
创建远程对象
1 | RMIInterface o=new RMItest(); |
这里创建了远程对象实例
调用父类的构造方法,即UnicastRemoteObject的构造方法:
这里默认port为0,所以后面创建socket时会分配随机端口,调用exportObject方法:
返回一个exportObject对象,传入obj和UnicastServerRef对象,UnicastServerRef对象主要用于指定端口创建远程对象通信,obj为我们的远程对象RMItest,继续跟进exportObject方法:
后面进入sref.exportObject方法了:
后面的流程就跟创建注册中心时的一样,但参数不一样,先看stub赋值:
由于在Util.createProxy中会调用stubClassExists方法来寻找是否存在RMItest_Stub对象,但由于我们并没有注册,所以肯定是没有的,此时就不会调用createStub方法,而是跳出执行后面的方法:
clientRef是UnicastRef对象,这里用RemoteObjectInvocationHandler为UnicastRef对象创建了动态代理
最后返回一个Remote类型的代理类,当调用代理类的方法时,就会调用RemoteObjectInvocationHandler.invoke方法:
在invoke方法中会对调用方法类型进行判断,allowFinalizeInvocation默认为true,当调用方法为Object对象时调用invokeObjectMethod方法,否则调用invokeRemoteMethod方法
而在invokeObjectMethod方法中,只对hashCode、equals、toString方法进行处理
而在另一个invokeRemoteMethod方法中,调用了另一invoke方法:
这里的ref是我们创建动态代理时传入的对象:
为UnicastRef,跟进UnicastRef.invoke:
这段代码主要用于建立通道连接
然后进行远程方法的调用,try部分主要是序列化调用参数,序列化失败时会抛出错误
接着看UnicastServerRef.exportObject方法
由于这时stub是代理类,所以不会执行setSkeleton方法
最后实例化了一个Target类,这个Target对象封装了我们远程对象和生成的动态代理类
然后调用exportObject(target)发布我们封装好的对象:
注意TCPEndpoint.newServerSocket在创建socket过程中会随机分配端口
可以看到执行完后随机分配了56272端口
后面同样的流程,开启监听后执行super.exportObject(target);方法:
即transport.exportObject(target):
主要就是将我们主要将Target对象存放进ObjectTable中,ObjectTable用来管理所有发布的服务实例Target,执行期间Stub和Skel成为了DGCImpl_Stub和DGCImpl_Skel分布式垃圾回收对象
绑定远程服务对象
1 | Naming.rebind(RMI_NAME,o); //也可以选用bind方法 |
将相应的远程对象和RMI服务端地址RMI_NAME进行绑定
先获取服务器地址URL,然后对其名字,host,port进行解析,然后获取相应端口的Stub(RegistryImpl_Stub对象),然后调用Stub.rebind(parsed.name, obj);方法,传入的是远程对象和RMI_NAME解析后的名字:
先调用super.ref.newCall方法,也就是UnicastRef.newCall方法,该方法主要是建立一个网络连接:
然后获取remoteCall的流,用同个流对名字和远程对象进行序列化操作,这里的操作主要是为了将RMI服务器名字和远程对象进行绑定:
接着执行ref.invoke方法进行网络传输,这里this.ref为UnicastRef,传入remoteCall:
跟进去,发现在StreamRemoteCall.executeCall方法中调用了readObject方法:
最后在服务端骨架处反序列化绑定
客户端
获取注册中心
1 | Registry registry = LocateRegistry.getRegistry(HOST,PORT); |
通过指定host和port获取指定注册中心的客户端存根
调用getRegistry方法:
该方法最后返回调用Util.createProxy方法返回指定注册中心的客户端代理Stub
查找获取远程对象
1 | RMIInterface o=(RMIInterface) Naming.lookup(RMI_NAME); |
通过 RMI 命名服务查找并获取远程对象的存根(stub)
Naming.lookup()是RMI 的命名服务类,提供基于名称的对象查找功能:
获取服务器地址URL,然后对其名字,host,port进行解析,然后获取相应端口的Stub(RegistryImpl_Stub对象):
然后调用Stub.lookup,即RegistryImpl_Stub.lookup方法:
先通过UnicastRef.newCall方法建立连接,然后获取remoteCall的流将其序列化发送到服务端,接着调用UnicastRef.invoke方法执行远程调用将请求发送到 RMI 注册表服务器,服务端在注册表中查找对应名称的绑定:
调用call.executeCall()方法:
调用了in.readObject方法:
其中in是通过getInputStream获得的,是数据流里的东西
然后通过反序列化动态获取注册远程对象时创建的代理类:
调用远程对象方法
1 | System.out.println(o.Hello()); |
获取了远程对象的Stub代理类后,当执行方法时就会触发RemoteObjectInvocationHandler.invoke方法:
在创建远程对象时我们已经分析过了,这里的hello方法自然不是一个Object方法,会调用invokeRemoteMethod方法作为返回值:
接着调用UnicastRef.invoke方法,这和前面lookup和rebind中的invoke不一样不要混淆了:
它先创建一个连接通道,然后建立连接
这里将call序列化到输出流中
调用streamRemoteCall.executeCall()方法触发调用进行网络通信
然后进行反序列化,返回反序列化结果
注册中心处理请求
在注册端,是由sun.rmi.transport.tcp.TCPTransport#handleMessages方法来处理请求的
当服务传入rmi调用时,如客户端或服务端与 RMI 注册中心交互或者客户端调用远程方法时,就是进入第一个swith/case语句:
调用了serviceCall方法,call就是通过某个连接对象获取相应的remoteCall对象
TCPTransport继承了Transport类,由于子类没有这个方法,会去调用父类的serviceCall方法:
大体逻辑是从ObjectTable中获取封装的Target对象:
获取其中的RegistryImpl对象
获取disp,就是前面创建注册中心和创建远程对象时看到的UnicastServerRef对象:
然后调用了对象的dispatch方法:
跟进UnicastServerRef.dispatch方法:
调用oldDispatch方法:
接着调用skel.dispatch方法,即Registrylmpl_Skel.dispatch方法:
当var3为0时是处理bind的请求,将remoteCall字节流进行反序列化以获取这个远程对象和RMI服务名称,并通过Registry.bind绑定RMI服务
而当var3处理lookup的请求对应另一个case语句:
逻辑是类似的,其他情况分别对应其他操作注册表的方法,如rebind,unbind,list等等。
总结
总结一下RMI请求序列化和反序类化的过程:
- remoteCall序列化RMI服务名称和远程对象
- Registry_Skel.dispatch方法处(注册中心)反序列化remoteCall字节流
- 反射调用RMI服务实现类的对应方法并序列化执行结果
- 将方法执行结果的序列化流反序列化,返回给客户端
DGC
前面在分析源码时提到了DGC,其实这个挺重要的,这里简要说一下
DGC(Distributed Garbage Collection),分布式垃圾回收。
在Java虚拟机中,一个远程对象不仅会被本地虚拟机内的变量引用,也能被远程引用,而只有当一个远程对象不受到任何本地引用和远程引用或者如果引用的“租期”过期并没有更新时,这个远程对象才会结束生命周期,此时服务器会将垃圾回收远程对象。
服务端的一个远程对象一般在3个地方被引用:
- 服务端的一个本地对象持有它的本地引用
- 服务端的远程对象已经注册到RMI Registry注册表中,即RMI Registry注册表有它的远程引用
- 客户端获得远程对象的存根对象,即客户端持有它的远程引用
而服务端判断客户端是否持有远程对象引用的方法:
- 当客户端获得一个服务端的远程对象的存根时,就会向服务器发送一条租约(lease)通知,以告诉服务器自己持有了这个对象的引用了。
- 客户端定期的向服务器发送租约通知,以保证服务器始终都知道客户端一直持有远程对象的引用。
- 租约是有期限的,如果期约到期了,服务器则认为客户端已经不再持有远程对象的引用了。
在前面创建RMI注册中心和远程对象时,在调试时发现了新的对象:
发现在执行ObjectTable.putTarget(target);将target对象存入ObjectTable时,出现了DGCImpl_Stub和DGCImpl_Skel对象。跟进ObjectTable.putTarget(target);方法:
这里调用DGCImpl的静态变量,会对DGCImpl进行初始化,主要作用是利用DGC跟踪远程对象的引用并记录日志,而在DGCImpl类中存在静态代码块,跟进一下怎么实现的:
这里就说得通了,这里新建了一个DGCImpl对象,并用它来新建了DGCImpl_Skel对象和DGCImpl_Stub代理类,并创建了一个新的target对象存入ObjectTable中。
同时Java提供了java.rmi.dgc.DGC接口,这个接口继承了Remote接口,并且定义了两个重要的方法dirty和clean方法:
涉及的类:
- Lease:一个lease包含了一个唯一的VM标识符和一个租借期限
- VMID是跨所有Java虚拟机的唯一标识符
其命名规则和处理逻辑都类似于Registry对象
而在客户端查找远程对象时RMIInterface o=(RMIInterface) Naming.lookup(RMI_NAME);,在Registry_Stub.lookup中:
在接受了服务端的返回值后,通过done的后续调用创建DGCImpl_Stub,并调用了其中的DGCImpl_Stub.dirty。在DGCImpl_Stub.dirty方法中,调用完invoke后,进行反序列化操作:
而注册中心在处理请求部分对应于DGCImpl_Skel.dispatch方法,当DGC调用完DGCImpl_Stub.dirty方法
DGCImpl_Skel.dispatch会处理这个方法的请求,对应的switch/case语句:
这里同样存在反序列化,还原Lease对象
RMI攻击
攻击注册中心
基本方法及原理
我们与注册中心进行交互可以使用如下几种方式
- 0 —- bind -缺少readObject方法,所以没法达到序列化效果,就无法利用
- 1 —- list
- 2 —- lookup
- 3 —- rebind
- 4 —- unbind
这几种方法位于RegistryImpl_Skel#dispatch中,如果存在readObject,则可以利用
服务端和客户端攻击注册中心的方式是相同的,都是远程获取注册中心后传递一个恶意对象进行利用。
其对应的反序列化触发点在RegistryImpl_Skel.dispatch方法,当客户端调用Naming.lookup方法或者服务端调用Naming.bind等需要操作注册表的方法,注册中心为了处理这些方法的请求都会调用RegistryImpl_Skel.dispatch方法
服务端攻击注册中心
bind/rebind攻击
通过从服务端注册某个恶意远程对象,在注册中心反序列化RemoteCall字节流,从而导致攻击,这个反序列化对象就是远程注册时创建的动态代理类。
其中bind和rebind方法的逻辑是差不多的,bind参数需要是一个Remote类型的对象,而AnnotationInvocationHandler是InvocationHandler的子类,所以我们可以利用AnnotationInvocationHandler来代理Remote接口,通过反射来获取AnnotationInvocation实例:
1 | Class invocation=Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); |
而看到AnnotationInvocationHandler自然就会想到CC1链,这里直接利用CC1,只是与CC1不同的是RMI是通过执行bind方法将恶意远程对象proxy与注册中心绑定,而在传输时会序列化,当注册中心处理请求时执行RegistryImpl.dispatch时就会进行反序列化绕过调用我们的链子。
完整poc:
1 | package rmi; |
需要注意的是,在处理请求时,注册端会验证服务端地址是否被注册端允许的(默认是只信任本机地址):
unbind攻击
其实unbind和lookup攻击手法是一样的,看后面客户端lookup的分析
由于checkAccess限制,在jdk8u121之后,会变成先验证再反序列化,即会验证是否为本地地址,此时服务端攻击注册端就不可用了,只能在本地调用bind、unbind、rebind方法。
客户端攻击注册中心
lookup攻击
由于jdk8u121之后Java远程访问注册中心做了限制,只有来源地址为本地才能调用bind、rebind、unbind方法。但客户端能执行lookup方法
lookup和unbind攻击手法是一样的,先看注册中心处理请求的源码:
实际上这两者的攻击思路和bind/rebind是相类似的,但是lookup这里只能传入String字符串,我们可以通过伪造lookup连接请求利用,修改lookup方法,使其可以传入对象
而对象输入跟paramRemotecall相关,所以看能不能控制传输过去的paramRemotecall:
通过RegistryImpl_Stub.lookup可以看到,是通过super.ref.invoke()方法来传输到注册中心的
这里我们需要重写lookup,而RemoteCall var2的重写需要this,oprerations,其他的已知,按照传输过程进行编写
先获取RegistryImpl_Stub对象
1 | Registry r = LocateRegistry.getRegistry(HOST, PORT); |
然后获取ref:
是一个UnicastRef对象,这是我们被代理的类
这个变量的定义位于Registry_Stub对象父类的父类中,利用反射这样获取
1 | Registry registry = LocateRegistry.getRegistry(HOST,PORT); |
还需要获取operations,一样通过反射得到:
1 | Registry registry = LocateRegistry.getRegistry(HOST,PORT); |
接着我们编写一个恶意的evilLookup方法:
1 | public static void evilLookup(Registry r, Object evilObject) throws Exception { |
前面恶意代理类流程不变,完整poc:
1 | package rmi; |
攻击客户端
注册中心攻击客户端
对于注册中心来说,我们还是从这几个方法触发:
- bind
- unbind
- rebind
- list
- lookup
原理就是因为在发起请求后,RegistryImpl_Stub会将请求序列化发送给注册中心RegistryImpl_Skel来进行处理,而除了unbind和rebind都会返回数据给客户端,返回的数据也是经过序列化的,所以到客户端后就会进行反序列化。如果我们能控制注册中心返回数据,就能实现攻击。
可以使用ysoserial的JRMPListener来进行演示
工具地址:
1 | https://github.com/frohoff/ysoserial |
命令:
1 | java -cp .\ysoserial-all.jar ysoserial.exploit.JRMPListener 1099 CommonsCollections1 'calc' |
客户端访问:
1 | public static void main(String[] args) throws Exception { |
服务端攻击客户端
服务端攻击客户端,通过 服务端返回Object对象 来攻击
当执行远程方法时,传递回来不一定是基础数据类型(String,int),也有可能是对象,当服务端返回给客户端一个对象时,客户端就要进行对应的反序列化操作。
我们需要伪造一个服务器,当客户端调用某个远程方法时,返回的参数是我们的恶意对象:
RMIInterface接口:
1 | package rmi; |
RMItest:
1 | package rmi; |
其他的不用变
攻击服务端
注册中心攻击服务端
与攻击客户端一样,原理都是当调用与注册中心交互的方法时如果从注册中心返回的数据可控,那么可以构造恶意的数据客户端进行反序列化造成攻击
客户端攻击服务端
当服务端的某个方法接受的参数是Object对象时,那么当客户端调用远程对象的方法传入恶意的Object对象,服务端在接受时会先将对象进行反序列化从而造成攻击
远程接口RMIInterface:
1 | package rmi; |
服务端RMItest:
1 | package rmi; |
客户端Client:
1 | package rmi; |
总结
下面是对rmi利用中jdk版本时间线的一个总结,也是后面要涉及的:
- 从jdk8u121开始,RMI加入了反序列化白名单机制,JRMP的payload登上舞台,这里的payload指的是ysoserial修改后的JRMPClient。
- 从jdk8u121开始,RMI远程Reference代码默认不信任,RMI远程Reference代码攻击方式开始失效。
- 从jdk8u191开始,LDAP远程Reference代码默认不信任,LDAP远程Reference代码攻击方式开始失效,需要通过javaSerializedData返回序列化gadget方式实现攻击。
参考
https://www.cnblogs.com/CoLo/p/15468660.html
https://nivi4.notion.site/Java-RMI-8eae42201b154ecc89455a480bcfc164#bf438eb4ecbd4c7c8aa60abf6e5ed450
https://paper.seebug.org/1194/
https://www.jianshu.com/p/de85fad05dcb
https://www.freebuf.com/articles/web/340633.html
https://mp.weixin.qq.com/s/BEctzUsH7HfkY8EEJ6yg9g
https://mp.weixin.qq.com/s/qiI7Mh4P-xTqY11Fy35-uA
https://curlysean.github.io/2025/03/06/RMI%E6%94%BB%E5%87%BB%E6%89%8B%E6%B3%95/#Evwbq












































































































