-
-
[原创]Java序列化反序列化源码---Jackson反序列化漏洞源码分析
-
发表于: 2020-8-31 09:29 1560
-
前言:
本次分析从Java序列化和反序列化源码开始分析,进一步分析Jackson源码,找出造成漏洞的原因,最后以Jackson2.9.2版本,JDK1.80_171,resin4.0.52,CVE-2020-10673为例复现漏洞。
每一个类都有一个Class对象,Class对象包含每一个类的运行时信息,每一个类都有一个Class对象,每编译一个类就产生一个Class对象,Class类没有公共的构造方法,Class对象是在类加载的时候由JVM以及通过调用类加载器中的
DefineClass()方法自动构造的,因此不能显式地声明一个Class对象。在类加载阶段,类加载器首先检查这个类的Class对象是否已经被加载。如果尚未加载,默认的类加载器就会根据类的全限定名查找.class文件。一旦某个类的Class对象被载入内存,我们就可以它来创建这个类的所有对象以及获得这个类的运行时信息。
获得Class对象的方法:
1).Class.forName(“类的全名”);//com.xx.xx.xx
2).实例对象.getClass()
3).类名.class
JAVA反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制。
实现Java反射机制的类都位于java.lang.reflect包中:
1).Class类:代表一个类
2).Field类:代表类的成员变量(类的属性)
3).Method类:代表类的方法
4).Constructor类:代表类的构造方法
5).Array类:提供了动态创建数组,以及访问数组的元素的静态方法
简单反射调用代码
Class clz=this.getClass();
Object obj= clz.getMethod("方法名",Class对象序列).invoke(this,Object参数序列);
Java 序列化是指把 Java
对象转换为字节序列的过程便于保存在内存、文件、数据库中,ObjectOutputStream类的
writeObject() 方法可以实现序列化。
Java 反序列化是指把字节序列恢复为 Java 对象的过程,ObjectInputStream 类的
readObject() 方法用于反序列化。
RMI:是 Java 的一组拥护开发分布式应用程序的
API,实现了不同操作系统之间程序的方法调用。值得注意的是,RMI 的传输 100%
基于反序列化,Java RMI 的默认端口是 1099 端口。
JMX:JMX 是一套标准的代理和服务,用户可以在任何 Java
应用程序中使用这些代理和服务实现管理,中间件软件 WebLogic 的管理页面就是基于 JMX
开发的,而 JBoss 则整个系统都基于 JMX 构架。
只有实现了Serializable接口的类的对象才可以被序列化,Serializable
接口是启用其序列化功能的接口,实现 java.io.Serializable
接口的类才是可序列化的,没有实现此接口的类将不能使它们的任一状态被序列化或逆序列化。
readObject()
方法的作用正是从一个源输入流中读取字节序列,再把它们反序列化为一个对象,并将其返回,readObject()
是可以重写的,可以定制反序列化的一些行为。
readObject()主要做的事情,其实就是读取正常应该被序列化的字段信息后,再构造出一个map,再通过对象流,将原有通过对象流写进文件里面的map信息(容量,每个item信息等)全部读取出来,然后重新构造一个map,这样就使得我们保存在set里面的信息,在经历过对象流的序列化和反序列化后,都没有丢失。
1).进入readObject()源码,首先判断是否调用readObjectOverride(),其中enableOverride的值由ObjectInputStream类的构造函数决定,带参构造为false,无参构造为true。但readObjectOverride()函数为空方法。因此一般需要带参构造。
2).调用readObject0(),进入函数中,发现有一大部分的switch判断,该部分判断读入的流是什么类型。其中readOrdinaryObject()值得注意,因为读入的大部分都是要反序列化的对象数据流。
1.进入readOrdinaryObject()函数,发现一开始,调用readClassDesc(),进而调用checkDeserialize()。如下图:
2.readClassDesc()函数中,switch判断顶端字节特征,选择执行的函数
3.无参构造创建ObjectStreamClass()对象,调用initNonProxy().
4.过程中调用了lookup(Class,boolean)方法
5.进入lookup方法,生成一个带参构造的ObjectStreamClass对象,这个对象就是下面要提到的readOrdinaryObject()函数中的readSerialData()的第一个参数。
1.进入readSerialData(),注意obj参数
2.进入invokeReadObject(),发现obj参数(该参数上文提到)直接经过反射调用了里面的方法
1.单步调试ObjectMapper初始化过程,发现在com.fasterxml.jackson.databind.deser.BeanDeserializerFactory类下,存在一个HashSet\<>集合,这里是定义了一个黑名单DEFAULT_NO_DESER_CLASS_NAMES(默认不反序列化的类)
并且将该黑名单设置为unmodifiableSet(不可修改,若修改则抛出异常)。
2.继续跟进,回到主程序,进入enableDefaultTyping(),这是一个Jackson在序列化和反序列化时配置的项目,当ObjectMapper对象调用该函数时,那么
OBJECT_AND_NON_CONCRETE就会生效,导致需要json字符串包含类名来反序列化。且接收封装的数组形式:[“...”,{“...”:“...”}]
3.writeValueAsString(Object value)
跟踪value参数,在_configAndWriteValue()函数中会判断该value对象是否是可关闭资源,如果是,则进入_configAndWriteCloseable(),对value强制转换,防止意外异常发生。如果不是,则直接进入serializeValue()
4.进入serializeValue(),通过反射获取value的Class对象
5.跟踪cls参数流向,进入typedValueSerializer()
7.继续跟进,进入_addFields(),implName是代表类属性名,从findImplicitPropertyName()内获取
该函数接收的参数为
[field com.caucho.config.types.ResourceRef#_location]
这个参数是从AnnotatedMember类的getFullName()获取
代码含义前文已有描述。
8.进入findImplicitPropertyName()函数,再次进入_findConstructorName(),并携带[field
com.caucho.config.types.ResourceRef#_location]
发现_findConstructorName()函数下有一个类型判断,该类型判断是判断进入的参数是否是AnnotatedParameter对象实例,通过观察代码,发现AnnotatedParameter继承自AnnotatedMember,而进入的参数是AnnotatedMember类型,因此参数是其父类对象实例,根据JAVA多态规则,该参数显然不能是子类对象实例,而之所以参数可以传进来,是因为Annotated是AnnotatedMember的父类,也就是向下转型合理,向上转型是不可以的。该函数执行结果为null。
9.回到_addFields()(该函数主要目的是为了遍历Json串对象下的所有字段属性名以及对该属性名的一些判断)函数,继续往下跟进,发现implName通过getName()获取到字段名_location
继续向下,进入hasIgnoreMarker()这里是判断字段是否被标记为可忽略,可以忽略的话就不会严格按照json串来映射对象,通俗的说就是Json串里面可以有对象所不包含的字段。
继续判断该字段是否被声明为transient(JAVA中添加该属性,则字段不会被序列化)。
10.继续跟进,断点到build()函数,经过几次遍历上文提到的HashMap,此时field字段值如下,这是由FieldBuilder()创建的,这是类内字段属性的完整表示。
private java.lang.String com.caucho.config.types.ResourceGroupConfig._lookupName
11.所序列化的类中有多少字段就会遍历多少次,且所遍历到的字段都会存入一个POJOPropertyBuilder中,如下图
props中保存了字段的许多属性。且这些字段,会先从父类开始遍历,再遍历子类,以下是ResourceRef
和 ResourceGroupConfig的所有字段
12.属性字段收集完成之后,回到collectAll()函数,再次进行收集方法的过程
13.进入_addMethods()函数,首先memberMethods会遍历所有的成员方法。构造迭代器对象,对成员方法进行迭代遍历,将每一个方法在getParameter()中判断所传入的方法参数数量。
14.进入_addSetterMethod()中,一直跟踪,进入okNameForSetter()中,判断方法名是否以”set”开头。
进入legacyManglePropertyName()对方法名去除set并变成小写,即将set方法对应的字段取出(此处涉及到JAVA开发的内容,是由于封闭性以及安全性,要想使用类内的私有成员必须要对外开放一个接口,而这样的接口一般写法是将变量名首字母大写,前面加上get/set,get有返回值无参数,set无返回值,一般有一个参数,因此前文判断方法的类别是依靠参数以及开头三个字母判别,这样的形式大多用在实体类内,也是数据交互传输的中转类)
在_property内,寻找是否存在上一步的对应字段名称
此处,如果不存在,则去缓存中寻找,在牵扯到缓存的时候,就必须要考虑多线程安全问题,这里是通过ConcurrentHashMap(通过volatile语义以及CAS操作实现,详细内容不做赘述)来存储,取出缓存后返回。
最后回到_property(),一通判断之后,程序认为该字段值不存在相对应的set方法,就将该字段存入props中
15.先进入_addGetterMethod()中,一直跟踪,到okNameForRegularGetter(),在此判断方法名是否以get开头,同时还会判断该方法是否是回调函数,是否是MetaClass或者Groovy的内容。
进入legacyManglePropertyName()对方法名进行去除get以及首字母小写处理。处理完返回字段名。进入prop查找是否存在该字段
由于先前的判断,已经添加过了,因此,可以查找到。可以查找到的话就在相应的字段上添加所收集到的对应的方法
注:如果方法开头不是以get/set开头,则不进行任何处理。
总结:Jackson是依靠收集到的get和set方法名,对方法名进行处理,得到字段值,与原收集到的字段值对比,可以对比到,就将该set/get方法添加到该字段下,对应不到,就将处理好的字段名添加,并将该方法添加到新的属性字段下,即原类所拥有的属性字段就会丢失get/set方法。
以下小案例证明:
17.接下来就是对没有注解的字段以及没有get和set方法的字段进行删除。
往下就没什么可以说的了,观察调用栈,最后会调用到readValue(),至此反序列化完成
17.当进行序列化时,先调用set方法对该字段赋值,再通过前面生成的get方法取出需要序列化字段的值。
由此处看以看出,lookupName属性并不是原类里面的值,而是Jackson自己生成的。
1.在serializeFields()函数处下断点,此处是序列化时关键函数
直接将值传入serializeAsField(),其中bean存储了两个实体字段的值
2.此处就出现了反射调用,直接调用对应属性字段的get/set方法,就会触发命令执行。
1.第一种情况
JacksonMain.java
User.java
正常运行后会执行命令。
2.第二种情况
JacksonMain.java
User.java
运行后报错。找不到name字段。根据以上两种情况即可说明,Jackson在序列化时出现了很大的问题,仅仅只是根据set/get方法名进行判别。要想触发该漏洞,只需要利用set/get方法后面的属性字段值即可产生反射调用。
1.Resin
非常流行的支持servlet
和jsp的引擎,速度非常快。Resin本身包含了一个支持HTTP/1.1的WEB服务器。虽然它可以显示动态内容,但是它显示静态内容的能力也非常强,速度直逼APACHE
SERVER。许多站点都是使用该WEB服务器构建的。
Resin也可以和许多其他的WEB服务器一起工作,比如Apache
server和IIS等。Resin支持Servlets 2.3标准和JSP
1.2标准。熟悉ASP和PHP的用户可以发现用Resin来进行JSP编程是件很容易的事情。
2.JNDI
1).JNDI是命名与目录接口,是一组应用程序接口,它为开发人员查找和访问各种资源提供了统一的通用接口,可以用来定位用户、网络、机器、对象和服务等各种资源。
2).J2EE 规范要求所有 J2EE 容器都要提供 JNDI 规范的实现。JNDI 在 J2EE
中的角色就是“交换机” —— J2EE
组件在运行时间接地查找其他组件、资源或服务的通用机制。在多数情况下,提供 JNDI
供应者的容器可以充当有限的数据存储,这样管理员就可以设置应用程序的执行属性,并让其他应用程序引用这些属性(Java
管理扩展(Java Management Extensions,JMX)也可以用作这个目的)。JNDI 在 J2EE
应用程序中的主要角色就是提供间接层,这样组件就可以发现所需要的资源,而不用了解这些间接性。
3).举例说明
配置JNDI访问数据库
同样的JNDI也可以访问LDAP服务器。
3).LDAP
LDAP是一种轻型目录访问协议,首先LDAP是一种通讯协议,LDAP支持TCP/IP。协议就是标准,并且是抽象的。可以把LDAP理解成存储数据的数据库。像是其他数据库一样,LDAP也是有client端和server端。server端是用来存放资源,client端用来操作增删改查等操作。而我们通常说的LDAP是指运行这个数据库的服务器。
3.根据上文提到的黑名单列表,寻找一个不存在于黑名单的类,该类具有远程访问能力,所以使用Resin容器下的ResourceRef类下的getValue()函数,配置ldap服务让服务器连接自己的ldap服务器,获取本地恶意文件。
4.进入ResourceRef源码,他是继承自ResourceGroupConfig,进入该类,发现以下函数。跟踪getLookupName()
在ResourceRef下面存在getValue(),里面有Jndi调用,这里就可以访问远程服务。借助Jackson反序列化以及序列化的特性(可以主动调用字段值的get方法,即主动调用getValue()方法)就可以实现命令执行。
5.攻击图解
开启LDAP服务,配置目录指向本地恶意文件,监听本地Tomcat端口
构造Json数据,指向本地IP地址,指向本地文件Poc
将Poc编译成class文件,因为Jackson序列化时会反射调用这个Poc执行。
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!