序言
遥知兄弟登高处,遍插茱萸少一人。
前一阵总结反序列化漏洞,发现RMI作为跳板,该角色必不可少。
RMI概念简述
RMI(Remote Method Invocation)即远程方法调用,是分布式编程中的一个基本思想。实现远程方法调用的技术有很多,比如CORBA、WebService,提到的这两种都是独立于编程语言的。
Java RMI是专为Java环境设计的远程方法调用机制,是一种用于实现远程调用(RPC,Remote Procedure Call)的一组Java API,能直接传输序列化后的Java对象和分布式垃圾收集。它的实现依赖于JVM,支持从一个JVM到另一个JVM的调用。
在Java RMI中:
- 远程服务器实现具体的Java方法并提供接口
- 客户端本地仅需根据接口类的定义,提供相应的参数即可调用远程方法
- 其中对象是通过序列化方式进行编码传输的
- RMI全部的宗旨就是尽可能简化远程接口对象的使用
平时说的反序列化漏洞的利用经常涉及到RMI,就是这个意思。
RMI依赖的通信协议为JRMP(Java Remote Message Protocol,Java远程消息交换协议),该协议是为Java定制的,JRMP协议运行在Java RMI下、TCP/IP层之上的一种协议,要求服务端与客户端都必须是Java编写的。
Java RMI极大地依赖于接口。在需要创建一个远程对象的时候,程序员通过传递一个接口来隐藏底层的实现细节。客户端得到的远程对象句柄正好与本地的根代码连接,由后者负责透过网络通信。这样一来,程序员只需关心如何通过自己的接口句柄发送消息。
多说两句:
Java本身对RMI规范的实现默认使用的是JRMP协议,而Weblogic对RMI规范的实现使用T3协议,Weblogic之所以开发T3协议,是因为他们需要可扩展、高效的协议来使用Java构建企业级的分布式对象系统。
众所周知,一般情况下Java方法调用指的是同一个JVM内部方法的调用,而RMI与之恰恰相反。
到这里,做个简短通俗的总结:RMI是一种行为,这种行为指的是Java远程方法调用,调用的是方法。
RMI能够帮助我们查找并执行远程对象的方法。通俗地说,远程调用就像A主机存放一个Class文件,然后在B机器中调用这个Class的方法。
Java RMI,跨JVM,是允许运行在JVM内部的一个对象调用运行在另一个JVM内部的对象的方法。 这两个JVM可以是运行在相同计算机上的不同进程中,也可以是运行在网络上的不同计算机中。
RMI和序列化的缘分
RMI的传输是基于序列化机制的。
如果一个RMI接口的参数类型为一个对象,那么我们客户端就可以发送一个自己构建的对象,来让服务端将这个对象反序列化出来。当然,前提是服务端的classpath里面有合适的类,或者后续恶意类加载也可以。
RMI代理模式
设计模式
RMI的设计模式中,主要包括以下三个部分的角色:
- Registry:提供服务注册与服务获取。即Server端向Registry注册服务,比如地址、端口等一些信息,Client端从Registry获取远程对象的一些信息,如地址、端口等,然后进行远程调用。
- Server:服务端,远程方法的提供者,并向Registry注册自身提供的服务
- Client:客户端,远程方法的消费者,从Registry获取远程方法的相关信息并且调用
交互过程
一般习惯,注册中心和服务端都在一起,RMI交互过程如图所示:
RMI由3个部分构成:
- RMI Registry(JDK提供的一个可以独立运行的程序,在bin目录下)
- 第二个是Server端的程序,对外提供远程对象
- 第三个是Client端的程序,想要调用远程对象的方法。
在设计模式中,3个角色的交互过程可简单概述为:
- 服务端创建远程对象,Skeleton侦听一个随机的端口x,这个端口是用来进行远程通信服务的,以供客户端调用。
- 启动RMI Registry服务,启动时可以指定服务监听的端口,也可以使用默认的端口(1099)。
- Server端在本地先实例化一个提供服务的实现类,然后通过RMI提供的Naming/Context/Registry等类的bind或rebind方法将刚才实例化好的实现类注册到RMI Registry上并对外暴露一个名称Name;
- 客户端对RMI Registry发起请求,根据提供的
Name
从RMI Registry得到Stub
。Stub
中包含与Skeleton
通信的信息(地址,端口等),两者建立通信,Stub
作为客户端代理请求服务端代理Skeleton
并进行远程方法调用。 - 客户端
Stub
调用远程方法,调用结果先返回给Skeleton
,Skeleton
再返回给客户端Stub
,Stub
再返回给客户端本身。
多说一句,一般我们知道RMI Registry的端口(1099)就可以了。
通信端口会包含在Stub中,Stub是RMI Registry给Client分发的。
此外,我们可以看到,从逻辑上来看数据是在Client和Server之间横向流动的,但是实际上是从Client到Stub,然后从Skeleton到Server这样纵向流动的。
通信过程
方法调用从客户端经存根(Stub)、远程引用层(Remote Reference Layer)和传输层(Transport Layer)向下,传递给主机,然后再次经传输层,向上穿过远程调用层和骨干网(Skeleton),到达服务器对象。
存根Stub位于客户端,扮演着远程服务器对象的代理,使该对象可被客户激活。远程引用层处理语义、管理单一或多重对象的通信,决定调用是应发往一个服务器还是多个。
传输层管理实际的连接,并且追踪可以接受方法调用的远程对象。Skeleton完成对服务器对象实际的方法调用,并获取返回值。返回值向下经远程引用层、服务器端的传输层传递回客户端,再向上经传输层和远程调用层返回。最后,存根Stub获得返回值。
总结:
Stub:扮演着远程服务器对象的代理的角色,使该对象可被客户激活。
远程引用层:处理语义、管理单一或多重对象的通信,决定调用是应发往一个服务器还是多个。
传输层:管理实际的连接,并且追踪可以接受方法调用的远程对象。
Skeleton:完成对服务器对象实际的方法调用,并获取返回值。
返回值向下经远程引用层、服务器端的传输层传递回客户端,再向上经传输层和远程调用层返回。最后,存根获得返回值。
Stub和Skeleton
RMI的客户端和服务器并不直接通信,客户与远程对象之间采用的代理方式进行Socket
通信。为远程对象分别生成了客户端代理和服务端代理,其中位于客户端的代理类称为Stub即存根(包含服务器Skeleton
信息),位于服务端的代理类称为Skeleton
即骨干网。
RMI Registry
RMI注册表
,默认监听在1099
端口上,Client
通过Name
向RMI Registry
查询,得到这个绑定关系和对应的Stub
。
远程对象
在RMI中的核心就是远程对象,一切都是围绕这个东西来进行的。
顾名思义,远程对象存在于服务端,以供客户端调用。
任何可以被远程调用的对象都必须实现 java.rmi.Remote 接口,远程对象的实现类必须继承UnicastRemoteObject类。
如果不继承UnicastRemoteObject类,则需要手工初始化远程对象,并且在远程对象的构造方法中调用UnicastRemoteObject.exportObject()静态方法。
这个远程对象中可能有很多个函数,但是只有在远程接口中声明的函数才能被远程调用,其他的公共函数只能在本地的JVM中使用。
序列化传输数据
客户端远程调用时传递给服务器的参数,服务器执行后传递给客户端的返回值。参数或者返回值,在传输时候会被序列化,在被接受时会被反序列化。
因此这些传输的对象必须可以被序列化,相应的类必须实现java.io.Serializable
接口,并且客户端的serialVersionUID
字段要与服务器端保持一致。
工厂模式
如图,先假设:
- 有两个远程服务接口可供Client调用,Factory和Product接口
- FactoryImpl类实现了Factory接口,ProductImpl类实现了Product接口
工厂模式的处理流程为:
- FactoryImpl被注册到了RMI Registry中;
- Client端请求一个Factory的引用;
- RMI Registry返回Client端一个FactoryImpl的引用;
- Client端调用FactoryImpl的远程方法请求一个ProductImpl的远程引用;
- FactoryImpl返回给Client端一个ProductImpl引用;
- Client通过ProductImpl引用调用远程方法;
可以看到,客户端向RMI Registry
请求获取到指定的FactoryImpl的引用后,再通过调用FactoryImpl的远程方法请求一个ProductImpl的远程引用,从而调用到ProductImpl引用指向的远程方法。
上面提到的创建FactoryImpl对象
,设置FactoryImpl对象
的指向ProductImp
(通过HTTP等协议定位,可以位于其他服务器),具有指向功能的对象也可以叫做reference对象
。
这种RMI+Reference的技术在JNDI注入中是单独作为一种利用方式。
这里执行远程对象的方法的是RMI通讯的客户端,为攻击客户端的方式,是在具体的代码和利用场景可以参考FastJson中的JNDI注入。
java.rmi包简介
Remote
一个接口interface,这个interface中没有声明任何方法。只有定义在“remote interface”,即继承了Remote的接口中的方法,才可以被远程调用。
RemoteException
RemoteException是所有在远程调用中所抛出异常的超类,所有能够被远程调用的方法声明,都需要抛出此异常。
Naming
提供向RMI Registry
保存远程对象引用或者从RMI Registry
获取远程对象引用的方法。这个类中的方法都是静态方法,每一个方法都包含了一个类型为String的name参数, 这个参数是URL格式,形如://host:port/name。
Registry
一个interface, 其功能和Naming类似,每个方法都有一个String类型的name参数,但是这个name不是URL格式,是远程对象的一个命名。Registry的实例可以通过方法LocateRegistry.getRegistry()获得。
LocateRegistry
用于获取到RMI Registry
的一个连接,这个连接可以用于获取一个远程对象的引用。也可以创建一个注册中心。
RemoteObject
重新覆写了Object对象中的equals,hashCode,toString方法,从而可以用于远程调用。
UnicastRemoteObject
用于获得一个stub。这个stub封装了底层细节,用于和远程对象进行通信。
Unreferenced
一个interface, 声明了方法:void unreferenced()。如果一个远程对象实现了此接口,则这个远程对象在没有任何客户端引用的时候,这个方法会被调用。
RMI动态类加载
RMI核心特点之一就是动态类加载,如果当前JVM中没有某个类的定义,它可以从远程URL去下载这个类的class,动态加载的对象class文件可以使用Web服务的方式进行托管。这可以动态的扩展远程应用的功能,RMI注册表上可以动态的加载绑定多个RMI应用。对于客户端而言,服务端返回值也可能是一些子类的对象实例,而客户端并没有这些子类的class文件。如果需要客户端正确调用这些子类中被重写的方法,则同样需要有运行时动态加载额外类的能力。客户端使用了与RMI注册表相同的机制。RMI服务端将URL传递给客户端,客户端通过HTTP请求下载这些类。
在之后的JNDI注入和反序列化漏洞的利用中,正是涉及到了RMI动态类加载。
编写RMI的步骤
定义服务端供远程调用的类
在此之前先定义一个可序列化的Model层的用户类,其实例可放置于服务端进行远程调用:
1 | import java.io.Serializable; |
定义一个远程接口
远程接口必须继承java.rmi.Remote接口,且抛出RemoteException错误:
1 | import java.rmi.Remote; |
开发接口的实现类
建立PersonServiceImpl实现远程接口,注意此为远程对象实现类,需要继承UnicastRemoteObject(如果不继承UnicastRemoteObject类,则需要手工初始化远程对象,在远程对象的构造方法的调用UnicastRemoteObject.exportObject()静态方法):
1 | public class PersonServiceImpl extends UnicastRemoteObject implements PersonService { |
创建Server和Registry
其实Server和Registry可以单独运行创建,其中Registry可通过代码启动也可通过rmiregistry命令启动,这里只进行简单的演示,将Server和Registry的创建、对象绑定注册表等都写到一块,且Registry直接代码启动:
1 | import java.rmi.Naming; |
创建客户端并查找调用远程方法
这里我们通过Naming.lookup()来查找RMI Server端的远程对象并获取到本地客户端环境中输出出来:
1 | import java.rmi.Naming; |
最后,我们看下模拟运行的场景。
先启动Server和Register,开启成功后显示“Server Start!”,然后运行Client程序,可以看到客户端成功获取到了在Register注册的Server中的远程对象的内容:
几个函数
这里小结下几个函数:
- bind(String name, Object obj):注册对象,把对象和一个名字name绑定,这里的name其实就是URL格式。如果该名字已经与其他对象绑定,则抛出NameAlreadyBoundException错误;
- rebind(String name, Object obj):注册对象,把对象和一个名字name绑定。如果改名字已经与其他对象绑定,不会抛出NameAlreadyBoundException错误,而是把当前参数obj指定的对象覆盖原先的对象(更暴力);
- lookup(String name):查找对象,返回与参数name指定的名字所绑定的对象;
- unbind(String name):注销对象,取消对象与名字的绑定;
经典例子
这里我再写一个例子,用于强化。
首先我还是生成一个接口,叫它IRemoteMath
。
1 | import java.rmi.Remote; |
注意:
- 这个远程接口必须继承Remote类;
- 内部抽象方法必须都有throw RemoteException
接下来我写一个接口的实现类,叫做RemoteMath
。
1 | /** |
注意:
- 这个类其实就是接口实现类
- 实现接口的全部方法,这个不必多说@override
- 必须继承UnicastRemoteObject
- 用它来实现接口中声明的所有抽象方法,它就是接口的实现类。
接下来是第一个关键角色Server,这里我是把Server和RMI Registry合在一起,实现如下:
1 | import java.rmi.registry.LocateRegistry; |
注意:
- 在这里,首先
接口=new 实现类
,用到了Java多态,从这里开始也是用接口去注册,用接口去迎接; - 接下来注册一个端口,默认是1099;
- 接下来将名字与接口实现类对象绑定,上面就是“Compute”和remoteMath
- 之后客户端就可以通过RMI Registry请求到该远程服务对象的stub了
客户端实现:
1 | public class MathClient { |
注意:
- 首先Naming.lookup去寻找Compute服务,用接口去迎接,拿到远程对象
- 拿到远程对象之后,通过它去调用远程方法,实现整个流程
结果截图:
多说两句
这里说明一下,当执行Registry registry = LocateRegistry.createRegistry(1099);
的时候,返回的registry对象类是sun.rmi.registry.RegistryImpl,其内部的ref,也就是sun.rmi.server.UnicastServerRef,持有sun.rmi.registry.RegistryImpl_Skel类型的对象变量ref。
而服务端以及客户端,执行Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
返回的是sun.rmi.registry.RegistryImpl_Stub。
当服务端对实现了HelloService接口并继承了UnicastRemoteObject类的HelloServiceImpl实例化时,在其父类UnicastRemoteObject中,会对当前对象进行导出,返回一个当前对象的stub,也就是HelloService_stub,在其执行registry.bind("hello", helloService);
的时候,会把这个stub对象,发送到RMI Registry存根。
当客户端执行HelloService helloService = (HelloService) registry.lookup("hello")
的时候,就会从RMI Registry获取到服务端存进去的stub。
接着客户端就可以通过stub对象,对服务端发起一个远程方法调用helloService.sayHello()
,stub对象内部存储了如何跟服务端联系的信息,以及封装了RMI的通讯实现细节,对开发者完全透明。
RMI攻击实例
定义一个接口
sayHello
1 | public interface RemoteHello extends Remote { |
定义接口实现类
其中:
exp1是客户端攻击服务端接口;
exp2是服务端攻击客户端接口;
1 | public class RemoteHelloImpl implements RemoteHello { |
Server&Registry
创建RMI Registry,创建远程对象,绑定Name和远程对象,运行RMI服务器
1 | public class Server { |
Client
1 | public class Client { |
以上为基本操作,接下来进行攻击。
JDK:1.7u21
JAR:Commons-Collections3.1
Maven;
1 | <dependency> |
客户端攻击服务器
先上代码:
1 | public class PoC_Client2Server { |
结果如图:
如果客户端传递给服务端恶意序列化对象,服务端反序列化这个对象时,就会调用对象的readObject
就会遭到攻击。
服务器攻击客户端
反之,服务端同样可以通过恶意反序列化数据攻击客户端。
先上代码:
1 | public class PoC_Server2Client { |
攻击截图:
总结
对于第一种客户端攻击服务器,其实是客户端自己有payload恶意函数,序列化对象发送给服务器时进行反序列化,造成漏洞触发。
第二种服务器攻击客户端,其实是服务器内部本身就有payload恶意函数,当对象请求调用的时候,服务器将序列化对象返还给客户端,客户端本地进行反序列化,漏洞遭到触发。