Java序列化和反序列化机制
/这里主要讲解Java序列化及反序列化机制。
何为序列化
Java 提供了一种对象序列化的机制:一个对象可以被表示为一个字节序列,该字节序列包括该对象的数据、有关对象的类型的信息和存储在对象中数据的类型。将序列化对象写入文件之后,可以从文件中读取出来,并且对它进行反序列化,也就是说,对象的类型信息、对象的数据,还有对象中的数据类型可以用来在内存中新建对象。整个过程都是JVM独立的,也就是说,在一个平台上序列化的对象可以在另一个完全不同的平台上反序列化该对象。
序列化:把对象转换为字节序列的过程。
反序列化:把字节序列恢复为对象的过程。
为何设计序列化
1、可以弥补不同操作系统之间的差异,如Windows上创建的对象进行序列化后通过网络传到Linux上可直接反序列化得到该对象而无需担心数据在不同机器、系统上的表示会不同;
2、对Java的远程方法调用RMI是必需的。RMI是为了使得存在于其他主机上的对象使用起来像本机上的对象一样,当向远程对象发送信息时,需要通过对象序列化来传输参数和返回值;
3、对Java Bean是必需的。使用一个Bean时,一般情况下是在设计阶段对它的状态信息进行配置,然而这种状态信息需要保存下来,并在程序启动时进行后期恢复,这时是靠反序列化机制来完成的;
4、方便保存对象信息以便于下次JVM启动时可以直接使用。
序列化的条件
一个类对象要想实现序列化,必须满足两个条件:
1、该类必须实现 java.io.Serializable 对象。
2、该类的所有属性必须是可序列化的。如果有一个属性不是可序列化的,则该属性必须注明是短暂的。
如何序列化
要序列化一个对象,首先要创建OutputStream对象,再将其封装在一个ObjectOutputStream对象内,接着只需调用writeObject()即可将对象序列化,并将其发送给OutputStream(对象是基于字节的,因此要使用InputStream和OutputStream来继承层次结构)。
要反序列化出一个对象,需要将一个InputStream封装在ObjectInputStream内,然后调用readObject()即可。
ObjectInputStream/ObjectOutputStream类
类 ObjectInputStream 和 ObjectOutputStream 是高层次的数据流,它们包含反序列化和序列化对象的方法。
ObjectOutputStream 类包含很多写方法来写各种数据类型,但是一个特别的方法例外即writeObject(),其用来序列化一个对象,并将它发送到输出流:
1 | public final void writeObject(Object x) throws IOException |
同样的,ObjectInputStream类包含反序列化一个对象的方法readObject(),其用于从流中取出下一个对象,并将对象反序列化,它的返回值为Object,因此需要将它转换成合适的数据类型:
1 | public final Object readObject() throws IOException, ClassNotFoundException |
Demo
定义一个User类,继承Serializable接口,其中address字段设置transient关键字:
1 | import java.io.Serializable; |
编写一个进行序列化操作的类:
1 | import java.io.FileOutputStream; |
运行后查看输出,给该User对象赋值成功并保存到了目标文件中:
用WinHex打开user.ser文件查看:
在刚刚的test.class中添加反序列化操作的函数:
1 | import java.io.*; |
注意,readObject()方法中的try/catch代码块尝试捕获 ClassNotFoundException 异常。对于JVM可以反序列化对象,它必须是能够找到字节码的类。如果JVM在反序列化对象的过程中找不到该类,则抛出一个ClassNotFoundException异常。
查看反序列化结果:
可以看到反序列化出来的对象,除了Address属性外其余的属性值都成功地反序列化出来了得到几乎和序列化之前一样的对象。这里Address属性是因为设置了transient关键字,具体的原理在下面会讲解。
serialVersionUID
serialVersionUID即序列化的版本号,适用于Java的序列化机制。简单来说,Java的序列化机制是通过判断类的serialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体类的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常,即是InvalidCastException。
具体的序列化过程是这样的:序列化操作的时候系统会把当前类的serialVersionUID写入到序列化文件中,当反序列化时系统会去检测文件中的serialVersionUID,判断它是否与当前类的serialVersionUID一致,如果一致就说明序列化类的版本与当前类版本是一样的,可以反序列化成功,否则失败。
serialVersionUID有两种显示的生成方式:
一是默认的1L,比如:private static final long serialVersionUID = 1L;
二是根据类名、接口名、成员方法及属性等来生成一个64位的哈希字段,比如:
1 | private static final long serialVersionUID = xxxxL; |
当一个类实现了Serializable接口,如果没有显示的定义serialVersionUID,Eclipse会提供相应的提醒。面对这种情况,我们只需要在Eclipse中点击类中warning图标一下,Eclipse就会自动给定两种生成的方式。如果不想定义,在Eclipse的设置中也可以把它关掉的,设置如下:
Window ==> Preferences ==> Java ==> Compiler ==> Error/Warnings ==> Potential programming problems
将Serializable class without serialVersionUID的warning改成ignore即可。
当实现java.io.Serializable接口的类没有显式地定义一个serialVersionUID变量时候,Java序列化机制会根据编译的Class自动生成一个serialVersionUID作序列化版本比较用,这种情况下,如果Class文件(类名,方法明等)没有发生变化(增加空格,换行,增加注释等等),就算再编译多次,serialVersionUID也不会变化的。
显式地定义serialVersionUID有两种用途:
1、 在某些场合,希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有相同的serialVersionUID;
2、 在某些场合,不希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有不同的serialVersionUID。
序列化的控制
在某些情况下,我们可能需要对序列化的过程进行控制,比如说不需要哪些部分被反序列化等,这时可通过Externalizable接口替代Serializable接口来实现对序列化过程的控制。
1、Externalizable
Externalizable接口继承了Serializable接口,同时添加了两个方法即writeExternal()和readExternal(),两者会在序列化和反序列化的过程中被自动调用,以便于执行一些特殊操作来实现过程控制。
2、transient关键字
当我们对序列化进行控制时,可能有些特殊子对象不想让Java的序列化机制自动保存和恢复,比如说子对象表示的是敏感信息。即使对象中的这些信息是private属性,但经过序列化处理后人们就可以通过读取文件或拦截数据报文来非法获取该信息。
这时存在两个方法。一是通过将类实现为前面的Externalizable,这时无任何东西可以自动序列化,并且可在writeExternal()内部只对所需部分进行显式的序列化;二是当我们正在操作的是一个Serializable对象,则所有序列化操作会自动执行,这时就可应用到transient(瞬时)关键字来逐个字段地关闭序列化,即说明指定字段内容在序列化中是不需要保存或恢复操作的。
由于Externalizable对象在默认情况下不保存它们的任何字段,所以transient关键字只能和Serializable对象一起使用。
3、自己实现Serializable
若非十分坚定地使用Externalizable接口,那么还可以自己实现Serializable接口,并添加writeObject()和readObject()方法,一旦对象被序列化和反序列化还原,就会自动地分别调用这两个方法。值得注意的是,这里说的是“添加”而非“覆盖”或“实现”,也就是说,只要我们提供了这两个方法,程序就会使用这两个方法而非默认的序列化机制。
这些方法必须具有准确的方法特征签名:
1 | private void writeObject(ObjectOutputStream stream) throws IOException |
注意,这些方法被定义成了private。
在调用ObjectOutputStream.writeObject()时,会检查所传递的Serializable对象,看看是否实现了自己的writeObject(),若实现了,则跳过正常的序列化过程并调用自己实现的writeObject()。readObject()方法的情形类似。
序列化的二进制格式
看回刚才用WinHex打开的序列化Demo生成的user.ser文件:
AC ED:STREAM_MAGIC,声明使用了序列化协议,从这里可以判断保存的内容是否为序列化数据。
00 05:STREAM_VERSION,序列化协议版本。
0x73:TC_OBJECT,声明这是一个新的对象。
0x72:TC_CLASSDESC,声明这里开始一个新Class。
00 04:Class名字的长度,这里Class名为User,长度为4。
55 73 65 72:类名称User。
64 D4 C4 D2 26 CA C4 8D:SerialVersionUID,序列化ID,如果没有指定,则会由算法随机生成一个8byte的ID。
0x02:标记号,该值声明该对象支持序列化。
00 02:该类所包含的域个数。
0x49:域类型,49 代表”I”,也就是Int。
00 06:域名字的长度,该域名为number、长度为6。
6E 75 6D 62 65 72:域名字描述,这里为number。
0x4C:域的类型,4C代表String。
00 04:域名字的长度,该域名为name、长度为4。
6E 61 6D 65:域名字描述,这里为name。
0x74:TC_STRING,代表一个new String,用String来引用对象。
00 12:该String长度,这里String为“Ljava/lang/String;”、长度为0x12.
4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B:JVM的标准对象签名表示法,这里是指String为“Ljava/lang/String;”。
0x78:TC_ENDBLOCKDATA,对象数据块结束的标志。
0x70:TC_NULL,说明没有其他超类的标志。
00 00 02 9A:这个暂时不知道,各位大佬知道的麻烦指点一下 :)
0x74:TC_STRING,代表一个new String,用String来引用对象。
00 07:域名字的长度,该域名为Mi1k7ea、长度为7。
4D 69 31 6B 37 65 61:域名字描述,这里为“Mi1k7ea”。
自定义实现序列化
如前面序列化的控制中的自己实现Serializable所说,可以添加writeObject()和readObject()这两个方法,它们并不属于任何的类和接口,只要在要序列化的类中提供这两个方法,就会在序列化机制中自动被调用。
自定义实现的好处是:程序员可以更加精细或者说可以去定制自己想要实现的序列化,如下面例子中将name和address变量值反转。利用这种特点,可以在序列化过程中对一些敏感信息做特殊的处理。
User.class:添加了private关键字的writeObject()和readObject()两个方法
1 | import java.io.IOException; |
test.class:
1 | import java.io.*; |
运行查看,确实是经过自己编写的writeObject()和readObject()两个方法来实现序列化和反序列化操作的:
what’s more!——反序列化漏洞点
Java反序列化漏洞点通常在于自定义实现的readObject()方法的代码逻辑存在缺陷,导致可能会触发反序列化漏洞。
其实这和PHP反序列化漏洞原理差不多,PHP中是魔法函数若存在缺陷代码则可能会存在反序列化漏洞风险,这类似于Java中自定义实现的readObject()函数。
下面的例子只在前面的例子中添加if语句中的Runtime.getRuntime().exec():
User.class:
1 | import java.io.IOException; |
test.class
1 | import java.io.*; |
运行代码,查看到readObject()中的不安全代码块中执行了命令: