前言

长期可持续化摸鱼,偶尔学习。


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));
}

参考

Java安全-Dubbo


Web Java 反序列化

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

Dubbo generic invoke漏洞2
Mybatis学习