前言
长期可持续化摸鱼,偶尔学习。
Dubbo SpringBoot
Dubbo是一个远程过程调用(RPC)框架,允许客户端远程调用服务端定义的函数。
从Github上下载一个示例项目,在1-basic/dubbo-samples-spring-boot目录下找到示例项目,里面包括三个模块,分别是接口(服务定义者)、服务提供者(服务端)和服务消费者(客户端):
interface, provides Dubbo service definition
provider, implements Dubbo service
consumer, consumes Dubbo service
用IDEA打开项目,然后安装依赖,如果SpringBoot依赖有问题可以换换版本,启动发现需要连接zookeeper,不然会报错:
Opening socket connection to server 127.0.0.1/127.0.0.1:2181. Will not attempt to authenticate using SASL (unknown error)
Socket error occurred: 127.0.0.1/127.0.0.1:2181: Connection refused: no further information
下载一个docker镜像:
docker pull zookeeper
docker run -id --name zookeeper -p 2181:2181 zookeeper
开启zookeeper镜像,并将zookeeper服务端口映射出来,然后修改application.yml中的zookeeper服务地址配置为虚拟机地址:
address: zookeeper://${zookeeper.address:192.168.88.129}:2181
可以看到dubbo服务端正常启动了,修改consumer的配置,将application.yml中的zookeeper地址配置修改为虚拟机地址,然后可以看到客户端和服务端正常通信了:
Tue Apr 25 20:00:09 CST 2023 Receive result ======> Hello Aluvion1634565036, i am Twings.
Tue Apr 25 20:00:10 CST 2023 Receive result ======> Hello Aluvion-356148887, i am Twings.
Tue Apr 25 20:00:11 CST 2023 Receive result ======> Hello Aluvion-2026383264, i am Twings.
Tue Apr 25 20:00:12 CST 2023 Receive result ======> Hello Aluvion-279126879, i am Twings.
Tue Apr 25 20:00:13 CST 2023 Receive result ======> Hello Aluvion-9261720, i am Twings.
Tue Apr 25 20:00:14 CST 2023 Receive result ======> Hello Aluvion1084163910, i am Twings.
Tue Apr 25 20:00:15 CST 2023 Receive result ======> Hello Aluvion699243408, i am Twings.
Tue Apr 25 20:00:16 CST 2023 Receive result ======> Hello Aluvion-796289112, i am Twings.
由于客户端是一个反复执行的脚本,因此可以看到服务端的sayHello函数被多次执行了。
CVE-2021-30179
漏洞分析
影响版本:
Apache Dubbo 2.7.0 to 2.7.9
Apache Dubbo 2.6.0 to 2.6.9
Apache Dubbo all 2.5.x versions (官方已不再提供支持)
泛化调用官方文档中介绍,感觉像是在没有接口的情况下,通过全限定接口名、函数名和函数类型等信息远程调用函数的方法。
仿照示例代码,给服务端加上group和version信息:
@DubboService(group = "group1",version = "1.0")
再给客户端填上这些信息:
GenericService genericService = buildGenericService("org.apache.dubbo.springboot.demo.DemoService",
"group1","1.0");
//传入需要调用的方法,参数类型列表,参数列表
Object result = genericService.$invoke("sayHello", new String[]{"java.lang.String"}, new Object[]{"Aluvion"});
System.out.println("GenericTask Response: " + result);
可以看到正常的泛化调用结果:
GenericTask Response: Hello Aluvion, i am Twings.
一般理性而言,这样根据全限定接口名、函数名和参数类型进行函数调用的方式多半是反射,而如果这种客户端可控的反射方式没有做好过滤,可能就会导致任意函数调用或者getter/setter调用等安全问题。
漏洞利用
由于访问注册的服务,调用函数需要group和version等信息,所以一般理性而言不能直接通过这个反射方法调用危险函数。
此外,通过调试可以找到取出函数参数的处理代码在DecodeHandler类的received函数中,该函数调用decode函数解码参数,一路走到DecodeableRpcInvocation类的decode函数。
调试找到传输参数数据的请求,当待调用函数存在参数时,使用FastJson2作为反序列化器反序列化参数:
args = new Object[pts.length];
for (int i = 0; i < args.length; i++) {
args[i] = in.readObject(pts[i]);
}
FastJson2反序列化:
if (securityFilter.isCheckSerializable()) {
return (T) JSONB.parseObject(bytes, Object.class, securityFilter,
JSONReader.Feature.UseDefaultConstructorAsPossible,
JSONReader.Feature.ErrorOnNoneSerializable,
JSONReader.Feature.UseNativeObject,
JSONReader.Feature.FieldBased);
}
此时的bytes是一个看不懂的东西:
经过FastJson2反序列化后是一个正常的HashMap:
看一下反序列化过程,似乎限定了只能反序列化继承了Serializable接口的类,其他类比如Runtime就不行:
cause: not support none serializable class java.lang.Runtime
或许修改了客户端代码可以发送过去,不过估计服务端既然存在ErrorOnNoneSerializable这个Feature,那么也会再校验一次。
尝试传输一个TemplatesImpl对象:
com.alibaba.fastjson2.JSONException: autoType not support input com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl
看来被黑名单拦截了,想从FastJson2入手应该不容易了。
给sayHello函数下一个断点,调试找到GenericFilter类的invoke函数,当客户端远程调用使用的是$invoke函数时就会进入该函数的处理流程:
String generic = inv.getAttachment(GENERIC_KEY);
if (StringUtils.isBlank(generic)) {
...
}
if (StringUtils.isEmpty(generic)
...
} else if (ProtocolUtils.isGsonGenericSerialization(generic)) {
...
} else if (ProtocolUtils.isJavaGenericSerialization(generic)) {
...
} else if (ProtocolUtils.isBeanGenericSerialization(generic)) {
...
} else if (ProtocolUtils.isProtobufGenericSerialization(generic)) {
...
}
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);
}
根据客户端setGeneric的不同,进入不同的分支用不同的方式修整函数调用的参数。
raw.return
处理方式:
args = PojoUtils.realize(args, params, method.getGenericParameterTypes());
来到PojoUtils类的realize0函数,如果输入参数为Map类型,则会尝试重新制作一个调用主体对象:
Map<Object, Object> map;
// when return type is not the subclass of return type from the signature and not an interface
if (!type.isInterface() && !type.isAssignableFrom(pojo.getClass())) {
try {
map = (Map<Object, Object>) type.getDeclaredConstructor().newInstance();
Map<Object, Object> mapPojo = (Map<Object, Object>) pojo;
map.putAll(mapPojo);
if (GENERIC_WITH_CLZ) {
map.remove("class");
}
} catch (Exception e) {
//ignore error
map = (Map<Object, Object>) pojo;
}
} else {
map = (Map<Object, Object>) pojo;
}
就算服务端定义了String类型的参数,而实际输入的却是Map也没有关系,类型转换失败时会不管定义直接使用输入的参数。
for (Map.Entry<Object, Object> entry : map.entrySet()) {
Object key = entry.getKey();
if (key instanceof String) {
String name = (String) key;
Object value = entry.getValue();
if (value != null) {
Method method = getSetterMethod(dest.getClass(), name, value.getClass());
Field field = getField(dest.getClass(), name);
if (method != null) {
if (!method.isAccessible()) {
method.setAccessible(true);
}
Type ptype = method.getGenericParameterTypes()[0];
value = realize0(value, method.getParameterTypes()[0], ptype, history);
try {
method.invoke(dest, value);
} catch (Exception e) {
...
}
} else if (field != null) {
...
}
}
}
}
可以看到,如果键为String类型,就会将其作为成员名尝试调用setter函数。
阅读源码,发现type来自函数参数,实际上是待调用远程函数的,因此在一般情况下并不可控。唯一可以修改该变量的地方需要在Map中存入键为class,值为String类型的类名:
Object className = ((Map<Object, Object>) pojo).get("class");
if (className instanceof String) {
if (!CLASS_NOT_FOUND_CACHE.containsKey(className)) {
try {
type = DefaultSerializeClassChecker.getInstance().loadClass(ClassUtils.getClassLoader(), (String) className);
} catch (ClassNotFoundException e) {
CLASS_NOT_FOUND_CACHE.put((String) className, NOT_FOUND_VALUE);
}
}
}
这里的实例化与函数调用与FastJson无关,因此不会触发黑白名单之类的验证方式,但是由于实例化使用的是无参构造函数,因此需要先调用setter存入对象数据,参考文章中的JNDI注入:
HashMap map = new HashMap();
map.put("class", "com.sun.rowset.JdbcRowSetImpl");
map.put("dataSourceName", "1234567890");
map.put("autoCommit", true);
但实际上似乎不行,根据class加载类时会触发黑名单验证:
Caused by: org.apache.dubbo.remoting.RemotingException: org.apache.dubbo.rpc.RpcException: java.lang.IllegalArgumentException: [Serialization Security] Serialized class com.sun.rowset.JdbcRowSetImpl is not in allow list. Current mode is `STRICT`, will disallow to deserialize it by default. Please add it into security/serialize.allowlist or follow FAQ to configure it.
看来这就是漏洞修复方式。
bean
for (int i = 0; i < args.length; i++) {
if (args[i] instanceof JavaBeanDescriptor) {
args[i] = JavaBeanSerializeUtil.deserialize((JavaBeanDescriptor) args[i]);
} else {
...
}
}
传递一个JavaBeanDescriptor对象,同样可以调用setter:
Method method = getSetterMethod(result.getClass(), property, value.getClass());
boolean setByMethod = false;
try {
if (method != null) {
method.invoke(result, value);
setByMethod = true;
}
} catch (Exception e) {
...
}
加载类时同样有了黑名单:
DefaultSerializeClassChecker.getInstance().loadClass(loader, name);
nativejava
存在原生Java反序列化点:
if (byte[].class == args[i].getClass()) {
try (UnsafeByteArrayInputStream is = new UnsafeByteArrayInputStream((byte[]) args[i])) {
args[i] = applicationModel.getExtensionLoader(Serialization.class)
.getExtension(GENERIC_SERIALIZATION_NATIVE_JAVA)
.deserialize(null, is).readObject();
} catch (Exception e) {
throw new RpcException("Deserialize argument [" + (i + 1) + "] failed.", e);
}
}
但是有了默认配置为不开启该模式:
Configuration configuration = ApplicationModel.ofNullable(applicationModel).getModelEnvironment().getConfiguration();
if (!configuration.getBoolean(CommonConstants.ENABLE_NATIVE_JAVA_GENERIC_SERIALIZE, false)) {
String notice = "Trigger the safety barrier! " +
"Native Java Serializer is not allowed by default." +
"This means currently maybe being attacking by others. " +
"If you are sure this is a mistake, " +
"please set `" + CommonConstants.ENABLE_NATIVE_JAVA_GENERIC_SERIALIZE + "` enable in configuration! " +
"Before doing so, please make sure you have configure JEP290 to prevent serialization attack.";
logger.error(CONFIG_FILTER_VALIDATION_EXCEPTION, "", "", notice);
throw new RpcException(new IllegalStateException(notice));
}