RMI学习

 Von's Blog     2020-08-25   5613 words    & views

前言

今天就开始具体Java安全的学习了,首先从RMI安全开始,但是由于RMI的不少安全问题都涉及了反序列化的问题,这方面的内容为目前暂未涉足,因此本文只是对RMI入门的第一篇文章啦。

什么是RMI

RMI全称为Remote Method Invocation(远程方法调用),是让某个Java虚拟机上的对象调⽤另⼀个Java虚拟机中对象上的⽅法(也即客户端如何调用服务器端的方法)。

RMI和RPC(远程进程调用,Remote procedure call)其实想实现的目标是一致的,只不过RPC更多的只是强调一种技术概念,因此可以说RMI是RPC在Java上的一种具体实现

RMI实现

RMI中主要有以下三个角色:

  • Client(客户端):客户端通过查询注册表来获取对应名称的对象引用,以及该对象实现的接口
  • Server(服务端):运行方法,执行方法结束后向客户端返回执行结果
  • Registry(注册中心):服务实例将被注册表注册到特定的名称中,在低版本的JDK中,Server与Registry是可以不在一台机器上的,而在高版本的JDK(JDK8u141之后)中,Server与Registry只能在一台机器上,否则无法注册成功(感觉功能有点像DNS服务器,提供一个别名的中介功能)

Server端实现

声明包含可调用的方法的接口

首先Server需要声明一个接口,这个接口中声明了Client能够调用的服务端的方法:

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

public interface RMIinterface extends Remote {
    String test() throws RemoteException;
}

这个接口有以下几个需要注意的地方:

1、使用public修饰,否则Client在尝试加载实现远程接口的远程对象时会出错(Client和Server位于同一机器上时可不用)。

2、需要继承Remote类。

3、接口声明的方法需要声明RemoteException报错。

实现接口

接下来Server需要实现该接口:

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

public class Server extends UnicastRemoteObject implements RMIinterface{
    protected Server() throws RemoteException {
    }

    @Override
    public String test() throws RemoteException {
        System.out.println("Server:Hello!");
        return "Hello!";
    }
}

实现类有几个需要注意的地方:

1、需要继承UnicastRemoteObject

继承了之后会使用默认socket进行通讯,并且该实现类会一直运行在服务器上(如果不继承UnicastRemoteObject类,则需要手工初始化远程对象,在远程对象的构造方法的调用UnicastRemoteObject.exportObject()静态方法)

2、实现类的构造方法需要声明RemoteException报错,这是由于其父类UnicastRemoteObject的几个构造方法声明了RemoteException报错,因此其子类也需要显式声明RemoteException。(如果使用IDEA的话可以很方便的自动生成)

不少文章在这里都还显示的去使用了super()去调用了父类的构造方法,但在这由于类的继承关系,即使我们不去显式调用super(),子类也会去自动父类的无参构造方法,因此在此实现中我们直接空开构造方法。

3、实现类中使用的对象必须都可序列化,即都继承java.io.Serializable

Registry端实现

Registry需要注册远程对象,其中又具体分为三步:

1、创建远程对象

2、创建Registry对象

3、将远程对象注册到Registry中

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

public class Registry {
    public static void main(String[] args) throws RemoteException, AlreadyBoundException {
        Server server = new Server();//创建远程对象
        Registry registry = LocateRegistry.createRegistry(1099);//创建registry对象
        registry.bind("test",server);//将远程对象注册到注册表里面,并且设置值为test
    }
}

其中最后一步设置值的指定,完整表示形式是rmi://ip:port/Objectname,即应该是rmi://127.0.0.1:1099/test,但rmi可以省去,即省为//127.0.0.1:1099/test,当端口为默认的1099时,前面部分又可完全省去只剩下Object名。

而当我们不想自定义端口等操作时,我们可以省去创建registry对象的过程,换成使用java.rmi.Naming提供的静态方法:

Server server = new Server();
testNaming.bind("test",server);

由于在高版本里面Server和Registry位于同个机器上,因此我们也可以将其合并进行结合

import java.net.MalformedURLException;
import java.rmi.AlreadyBoundException;
import java.rmi.Naming;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;

public class Server extends UnicastRemoteObject implements RMIinterface {

    protected Server() throws RemoteException {
    }

    @Override
    public String test() throws RemoteException {
        System.out.println("Server:Hello!");
        return "Hello!";
    }

    public static void main(String[] args) throws RemoteException, MalformedURLException, AlreadyBoundException {
        Server server = new Server();
        Naming.bind("test",server);
    }
}

Client实现

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

public class Client {
    public static void main(String[] args) throws RemoteException, NotBoundException {
        Registry registry = LocateRegistry.getRegistry("127.0.0.1");    //获取Registry对象
        RMIinterface test = (RMIinterface) registry.lookup("test");    //通过别名获取远程对象
        String result = test.test();    //执行Server上的远程方法
        System.out.println(result);
    }
}

有以下几点值得注意的点:

1、通过LocateRegistry获取Registry对象的步骤中,如果端口不是默认的1099,则要调用getRegistry的另一种重载形式来指定端口:

Registry registry = LocateRegistry.getRegistry("127.0.0.1",2000);

2、通过lookup方法获取远程对象返回的是一个Remote接口,但其实我们真正想利用的是RMIinterface这种接口类型,因此需要对返回值进行一次向下转型。可以看到这也是采用接口定义的方便之处,即Client其实不关心Server上方法的具体写法只在意结果,但同时Client又需要知道有哪些方法可以调用以及参数类型,接口就很好的实现这两个目标。

3、也可以直接利用Naming的静态方法直接得到远程对象RMIinterface test = (RMIinterface) Naming.lookup("test");

其中的test完整表达形式仍为之前提过的:rmi://ip:port/Objectname只不过在本地服务器且端口为1099的情况下可以只传参别名。

通信过程

这部分我内容我没有去实际进行Wireshark抓包进行实践,直接总结各位师傅们的经验

  1. 首先Client会和Registry进行TCP三次握手,建立连接
  2. Client向Registry发出一个Call请求(根据别名来获取远程对象),然后Registry会返回一个ReturnData,ReturnData中会包含Server的IP地址、端口(端口号就紧接着IP,两个字节)和序列化后的远程对象(\xAC\xED开始就是序列化的标志)
  3. Client反序列化远程对象并通过Server的IP和端口号访问Server进行远程方法调用

信息泄露

假如Client可以访问到Registry,那能想象到几种可行的攻击方式,比如说最容易被人想到的绑定恶意远程对象,但是实际上是不可行的,Registry的bind,rebind,unbind方法只能由localhost调用。外部的client只能调用registry对象的list和lookup方法。其中list方法被用来列出远程对象所拥有的方法:

String[] method =  Naming.list("test");
for (String s : method) {
  System.out.println(s);
}

输出:

//:1099/test

假设远程对象存在恶意方法,我们可以通过list方法列出所有可能的恶意方法然后进行远程调用

Codebase安全问题

codebase就是远程加载类的路径,当对象在发送序列化的数据的时候会带上codebase信息,当接受方在本地classpath中没有找到类的话,就会去codebase所指向的地址加载类。

如果我们指定 codebase=http://example.com/,然后加载 org.vulhub.example.Example 类,则 Java虚拟机会下载这个文件 http://example.com/org/vulhub/example/Example.class ,并作为 Example类的字节码。RMI的流程中,客户端和服务端之间传递的是一些序列化后的对象,这些对象在反序列化时,就会去寻 找类。如果某一端反序列化时发现一个对象,那么就会去自己的CLASSPATH下寻找想对应的类;如果在 本地没有找到这个类,就会去远程加载codebase中的类。

那很自然的,我们想到,RMI中我们已经可控codebase,不就可以恶意加载类了么,但是要完成这一攻击对Server有两个前置要求:

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

java.rmi.server.useCodebaseOnly=true时Java虚拟机将只信任预先配置好的codebase,不再支持从RMI请求中获取,在这两个版本之后,默认值由false改为了true

我们可以通过设置VM选项为:-Djava.rmi.server.codebase=http://myserver.com/classes/进行codebase的设置,实际中该利用的条件较为苛刻,通常难以遇到。

最后

这应该只是对RMI流程和最浅显的安全问题的一个总结,RMI最可能被人利用的反序列化问题由于我还未涉足所以暂且跳过,等未来学到相关知识再来进行完善。

参考资料

马士兵的视频

浅谈Java RMI

P神的Java安全漫谈4-6