Java反序列化RMI篇-RMI基础
Introduction
RMI , Remote Method Invocation , 远程方法调用;
即在一个 JVM 中 Java 程序调用在另一个远程 JVM中运行的 Java 程序;这个远程JVM可以在本地也可以在不同的实体机上;两者通过网络进行通信。
https://docs.oracle.com/javase/tutorial/rmi/overview.html
由三部分组成:
- Server - 服务端,通过绑定远程对象,可以进行很多网络操作,也就是socket
- Client - 客户端,客户端调用服务端的方法
- Register - 注册端,提供服务注册和服务获取
RMI Implementation
- Server
编写一个远程接口,定义一个sayHello方法 - RemoteObj
1 | public interface RemoteObj extends Remote { |
此远程接口要求作用域为 public;
继承 Remote 接口;
让其中的接口方法抛出异常
编写该接口的实现类Impl - RemoteObjImpl
1 | public class RemoteObjImpl extends UnicastRemoteObject implements RemoteObj { |
实现远程接口
继承UnicastRemoteObject类,用于生成 Sub(存根)和 Skeleton(骨架)
抛出错误
继承序列化接口
注册远程对象 - RMIServer.java
1 | public class RMIServer { |
port 默认为 1099 端口
- Client
同样编写一个远程接口,定义一个sayHello方法 - RemoteObj
1 | public interface RemoteObj { |
编写client代码,获取远程对象并调用方法 - RMIClient
1 | public class RMIClient { |
- Test
先启动Server端,然后运行RMIClient,可以发现在RMIServer端已经收到了从client发出的信息,并且成了大写
Wireshark RMI Stream Analysis
Analysis
通过 tcp.port == 1099 正则表达式来过滤其他杂包。
- TCP 三次握手

因为是 tcp 协议,链接第一件事肯定是走 tcp 的三次握手协议,这里不做解释了。
JRMP 协议握手
JRMI Call
远程调用包
可以在ASCII 区域查看到调用方法的名字
ReturnData

ReturnData 会返回一个 Stub,也就是存根;里面包含了 ip 地址、端口号等。
接下来客户端会进行解析并对新的端口发起 TCP 链接。
我这里的端口是 00 00 0c e4 ,转换过来就是 3300 port
所以我们继续加正则表达
1 | tcp.port == 1099 || tcp.port == 3300 |
- TCP 链接

接下来会有一个问题,wireshark 会把对3300端口的访问标记为默认协议,所以我们需要去修改一下:
选中这里本应该是 JRMI 协议请求的 2093号, 右键 Decode as…
选择 Current 这一列,选择 RMI 即可。
JRMI Call
ReturnData

Summary
实际上建立了两次 TCP 链接
第一次是链接1099Register,寻找名字为 hello 的对象(Call),然后 Register 返回一个序列化的数据,也就是 name=hell0 (ReturnData);
第二次是链接 Register 分发给客户端的端口。是服务端发送给客户端 Call 的数据;客户端拿到数据后发序列化,链接到新的地址。再一次进行 TCP 协议链接;在此连接中才调用了真正的远程方法,也就是 sayHello
从 IDEA 断点分析 RMI 通信原理
创建远程服务
- 该过程不存在漏洞,因为是在本地创建
分析如何发布远程对象:
1 | RemoteObj remoteObj = new RemoteObjImpl(); |
下断点调试跟进
如果这里步入不了可以尝试强制步入。
Mac快捷键 Fn + Shift + Opt + F7
继续跟进
因为实现类中申明了是继承于父类UnicastRemoteObject的,所以会从父类中寻找构造方法。
这里默认是 port=0,代表随机端口
我们跟进到 exportObject 方法中分析
该方法是一个静态函数,第一个参数为 obj 对象,第二个参数是 new UnicastServerRef(port)
在实现类中有这么一段代码:
1 | public RemoteObjImpl() throws RemoteException { |
可以看出来这段代码就是实现网络连接的关键点
跟进第二个参数:
(这里 Mac 可以按 Shift + Fn + F7 来选择参数步入)
(如果这里步入到了 class 文件中,添加 sun 包,详见 cc 1)
这里就算是一个网络引用的类
跟进 LiveRef 方法
继续跟进 this
看到了 TCPEndpoint 类,进入看一下构造方法
传入一个 ip 和一个 port;继续跟进到 this 构造函数中
看到数据是放在 endpoint 中,而 endpoint 又封装于 LiveRef,所以也可以理解为数据存放于 LiveRef,并且 LiveRef 只会存在一个。
以上就是 LiveRef 创建的过程
回到 super(new LiveRef(port)); 处,进入 super 父类中查看:
只是作为赋值,继续 F7 步进
看到出现了 stub
这里解释一下为什么会出现 stub:
RMI 在 Server 中会创建一个 stub,然后 stub 会传到 RMI register 中,最后让 RMI Client 获取 stub。
我们先研究 stub 产生的步骤,跟进 createProxy 方法
此处的判断我们先不研究,后续会学。
后面就是标准的创建动态代理的方法
第一个参数,类加载器为 AppClassLoader,第二个参数是一个远程接口,第三个参数是调用处理器,调用处理器里面只有一个 ref,就是我们之前看到的一路封装的 ref
走到此处就已经把动态代理创建完成了
接下来创建了一个新的 Target,也就是一个总的封装,把所有有用的东西都放在 Target 中
可以看到 ref 和 id 都是相同的。
F8 走到 new Target 的下一条语句,分发 target
1 | ref.exportObject(target); |
走到这里,是真正处理网络请求的地方。
1 | TCPEndpoint ep = getEndpoint(); |
获取了 TCPEndpoint,继续往下走到 server = ep.newServerSocket();
创建了一个新的 socket,已经准备好了等待别人来连接,
并且会给 port 进行赋值:
赋值逻辑如下:如果 listen 为 0,则随机赋一个值
发布完成后 RMI 会把所有的信息都保存在两个 table 中,有点像日志。
- 小结
不算难,只是代码逻辑很复杂。
远程发布对象,用exportObject()指定到发布的 ip 和端口,端口为随机值。
只是赋值和封装的部分很复杂。
发布完成后的记录,只需要知道是保存在静态的 HashMap 中。
这一块就是服务端自己创建远程服务的一个操作,所以这一块是不存在漏洞的。
创建注册中心 + 绑定
下断点:
走到这里:
看到是创建了一个 LiveRef 和一个新的 UnicastServerRef
和上面分析的创建远程对象很相似
跟进这里的 setup 看一下:
和之前的代码一样
看到只是传入的第三个参数有区别,创建远程服务时候是 false,现在创建注册中心的时候是 true。
第三个参数为 permanent ,意为永恒的。
这就代表创建注册中心的时候是一个永久对象,但之前创建远程服务的时候是临时对象
接下来就是和创建远程对象一样了,创建 stub 的阶段。
但是在 stub 创建过程中又不一样了,跟进看一下:
在 if 逻辑中进入 !(ignoreStubClasses || !stubClassExists(remoteClass))) 中的 stubClassExists 查看
1 | private static boolean stubClassExists(Class<?> remoteClass) { |
也就是判断是否能找到 当前类名 + "_Stub" 这个类
走一下这个 if,发现是可以走进去的,也就是可以找到该类。
对比一下创建远程服务阶段,
创建注册中心是进入 createStub 方法
而创建远程服务是直接创建动态代理
方法内就是通过反射创建这个对象,里面放了 ref
相比于创建远程服务是直接代理创建,现在是 forName 创建,但是里面放的都是 ref,是一样的。
继续往下走
会进入到 setSkeleton 方法中
里面就是 createSkeleton 方法创建 skel
继续走到 Target 方法
这里还是和之前一样,没什么好说的,继续往下走到这里:
调用了一个 putTarget 方法,把封装的数据放进去,跟进看一下都放了什么东西进去
static -> objTable
可以看到有三个表,逐个分析一下:
第一个表中的 stub 是 DGCImpl_Stub,是分布式垃圾回收的一个对象,不是我们刚才创建的但是很重要:
第二个表,最开始创建远程服务的 stub:
第三个表就是我们刚刚创建的:
所以这里就是起了几个远程服务,一个端口是固定的,另外两个是不固定的。为什么会有三个 Target 后面学。
- 绑定
绑定,也就是最后一步,bind 操作
1 | registry.bind("remoteObj", remoteObj); |
下断点然后调试:checkAccess 方法,就是检测是否在本地绑定。是会通过的。
下一句就是检查 binding 这里是否有内容,bindings 就是一个 hashtable,如果有数据的话会抛出异常。
继续走就是 put 了,很好理解,就是把名字和远程对象 put 进去。
- 小结
其实依旧挺简单的,依旧是代码逻辑非常复杂。
和创建远程对象很类似,只不过多了一个持久对象,这个对象就是注册中心。
绑定的话也很简单,就判断判断然后 put 进去了。
客户端请求注册中心 - 客户端
- 客户端请求部分是存在漏洞的,上面在 wireshark 抓包的时候分析过了,RMI 是一个基于序列化的 java 远程方法调用机制,有一些有问题的反序列化。
客户端的请求分为三个部分,获取注册中心,查找对象,调用方法。
- 获取注册中心:
这一部分不存在漏洞。
三行代码都下断点后开始调试
会进入 getRegister 方法中
可以看到是先创建了一个 liveRef 对象,把 ip、port 传入;然后进行封装。
这里的 createProxy 流程是和前面的创建 stub 一样的,新建了一个 ref,把该封装的东西都封装到 Ref 中。
就获取到了注册中心的 Ref,是在服务端给的参数,本地重新创建的 stub。
获得到注册中心的 stub 对象,接下来要去查找远程对象。
- 查找远程对象
这里对应的是第二个断点处
如果直接调进去的话,会发现是调试不了的,反编译出来是 class 文件
原因应该是版本的问题
但是代码依旧是可以正常运行,不妨碍我们分析。
这里的 var1 就是我们传入的参数 remoteObj,写进了一个输出流,序列化进去。
那注册中心肯定会调用反序列化操作进行读取。
下面会走到
1 | super.ref.invoke(var2); |
调用父类的 invoke 方法,也就是
继续调用了方法,跟进跟进,进入到 executeCall 方法中
该方法是真正调用网络请求的方法,暂时先不学,后面再讲。
先继续看整个代码的逻辑
在此处下一个断点:
运行到此处后先分析一下逻辑 :
1 | invoke() |
该函数有一个处理异常的方法
这里的 in 实际上就是数据流里面的东西
本意应该就是想在报错的时候把一整个信息都拿出来,这样会更加清晰一点
但是这里就会有一个问题:
这里的 readObject 会进行反序列化操作,如果注册中心返回一个恶意的对象,客户端进行反序列化的话
这就会导致反序列化漏洞的产生
也就是说只要 stub 中调用网络请求,就会调用 invoke 方法,就会收到攻击。
继续运行到下一个断点,看一下获取到了什么数据
简单来说就是获取到了 RemoteObj 这个动态代理,其中包含一个 ref。
客户端请求服务端 - 客户端
此处存在漏洞。
1 | remoteObj.sayHello("hello"); |
的 Debug
F7步入,无法步入的 Windows: Shift + Alt + F7 强制步入。
走到了 invoke 方法中,该方法是动态代理调用方法时候自动调用的。
一堆 if 都是抛出异常的
我们跟进进最后一行语句
1 | return invokeRemoteMethod(proxy, method, args); |
看到还会调用一次重载的 invoke 方法
重载的方法调用了链接
接着往下走
循环中有一个 marshalValue 方法
它会序列化一个值,实际上也就是我们传入进来的 hello
下面就会调用 executeCall 方法
继续往下走,看到了 unmarshalValue 方法
步过后同样看到了存在攻击的点
这个数据会被 return
1 | Object returnValue = unmarshalValue(rtype, in); |
- 小结
存在两个可能的攻击点
executeCall()
unmarshalValue()
客户端请求数据中心 - 注册中心
之前研究的是客户端请求数据中心中,客户端的代码执行逻辑
现在分析一下注册中心的执行逻辑
因为在客户端中,操作的是 stub,服务端操作的是 Skel
Skel 创建之后应该是被封装在 Target 中的
断点下在 Server 处的处理 Target 的地方:
Target 内容:
接着走,把 stub 的数据放入 disp
然后调用 disp 的 dispatch 方法
我们手动跳进去看一下
进入到分支
跟进后发现
走到这里,下面就是 skel.dispatch() 的过程了
数据中心代码中的几个方法:
list (case1)
bind (case0)
rebind (case3)
unbind (case4)
lookup (case2)
分析代码可以发现,bind、rebind、unbind、lookup 中都有 readObject 方法
如果向控制中心传入构造好的序列化内容
就会触发反序列化攻击
- 小结
客户端请求数据中心 - 数据中心的操作
主要就是处理 Target,进行 Skel 的生成和处理
漏洞点在 dispatch 方法,存在反序列化的入口类,可以配合 cc 链进行攻击利用。
客户端请求服务端 - 服务端
和上面一样,打两个断点
因为是研究服务端最后的运行逻辑,所以需要到最后一次的代码执行逻辑。
按 2x2 次 F9,直到 Stub 为动态代理为止
在此状态下进入 dispatch 方法
可以看到 skel 为 null,并没有走入 if 语句。
往下走,获取到输入流以及 method
Method 就是我们之前写的 sayHello() 方法。
继续往下走就是上面分析的 unmarshalValue() 方法。
这里和我们之前说的一样,是存在漏洞的。
这里的流程相同,也就是我们的 hello 传参传进去,序列化读进去,反序列化读出来,和之前是一致
总结
太底层代码实现了,看的脑袋昏
下一篇开始学习 rmi 攻击
但是不学原理又不行
参考资料
- Title: Java反序列化RMI篇-RMI基础
- Author: xekOnerR
- Created at : 2026-03-01 15:31:14
- Updated at : 2026-03-01 15:41:56
- Link: https://xekoner.xyz/2026/03/01/Java反序列化RMI篇-RMI基础/
- License: This work is licensed under CC BY-NC-SA 4.0.