对Java序列化早作防备
本文是 IBM developerWorks中的 一篇文章,介绍了不使用加密与签章技术,如何防止对不可信数据输入的解序列化。(2013.01.17最后更新)
Java序列化允许开发者将Java对象保存为二进制格式,以便将该对象持久化到一个文件中或将其在网络中进行传递。远程方法调用(RMI)使用序列化作为客户端与服务器端之间的通信媒介。当服务从客户端接收二进制数据,以及将输入的数据进行解序列化去构造Java实例时,就会产生多种安全问题。本文关注其中一种问题:骇客可能序列化另一个类的实例,并将其传给服务程序。那么服务程序就会解序列化该恶意对象,并很可能将该对象强制转换为服务所期望得到的合法类型,而这将导致异常的发生。然而,该异常对于确保数据安全性则显得太晚了。本文解释了为什么要以及怎样去实现一种安全的序列化。
脆弱的类
你的服务程序不可能反序列化任意类的对象。为什么不能呢?简单的回答是:因为在服务器端的类路径中可能存有被骇客利用的脆弱类。这些类所包含的代码为骇客造就了拒绝服务(DOS)的条件,或者--在极端情况下--会允许骇客注入任意代码。
你可能会相信存在这种攻击的可能性,但考虑到一个典型的服务器端程序的类路径中存在太多的类,不仅包含你自己的代码,还包括Java核心类库,第三方的类库,以及其它的中间件或框架中的类库。另外,在应用程序的生命周期中,类路径可能会被改变,或者为了应对底层运行环境的变化,应用程序的类路径也可能被修改。当试图去利用这样的弱点时,通过传送多个序列化对象,骇客能够将这些操作组合到一块。
我应该强调一下,仅当满足如下条件时,服务才会解序列化一个恶意对象:
1. 恶意对象的类存在于服务器端的类路径中。骇客不可能随便地传递任意类的序列化对象,因为应用服务可能无法加载这个类。
2. 恶意对象的类要么是可序列化的,要么是可外部化的。(即,服务器端的这个类要实现java.io.Serializable或java.io.Externalizable)
另外,通过从序列化流中直接复制数据,在不调用构造器的情况下,解序列化操作就能产生对象树,所以骇客不可能执行序列化对象类的构造器中的Java代码。
但骇客还有其它途径在服务器端去执行代码。无论JVM在何时去解序列化一个对象,都将实现如下三个方法中的一个,都将调用并执行该方法中的代码:
1. 方法readObject(),当标准的序列化机制不适用时,开发者一般就会用到该方法。例如,当需要对transient成员变量进行赋值时。
2. 方法readResolve(),一般用于序列化单例对象。
3. 方法readExternal(),用于外部化对象。
所以,如果在你的类路径中存在着使用上述方法的类,你就必须意识到骇客可能会在远程调用这些方法。此类攻击在过往曾被用于破坏Applet安全沙箱;同样地,相同的攻击技术也可用于服务器端应用。
继续读下去,将会看到如何才能只允许应用服务对其期望的类的对象进行解序列化。
Java序列化二进制格式
一个对象被序列化之后,二进制数据将包含有元数据(指与数据的结构相关的信息,例如类的名称,成员的数量,以及成员的类型),及对象数据本身。我将以一个简单的Bicycle类作为例子,如清单1所示,该类包含三个成员变量(id,name和nbrWheels)以及与之对应的set与get方法。
清单1. Bicycle类
package com.ibm.ba.scg.LookAheadDeserializer;
public class Bicycle implements java.io.Serializable {
private static final long serialVersionUID = 5754104541168320730L;
private int id;
private String name;
private int nbrWheels;
public Bicycle(int id, String name, int nbrWheels) {
this.id = id;
this.name = name;
this.nbrWheels = nbrWheels;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public void setId(int id) {
this.id = id;
}
public int getId() {
return id;
}
public int getNbrWheels() {
return nbrWheels;
}
public void setNbrWheels(int nbrWheels) {
this.nbrWheels = nbrWheels;
}
}
在一个清单1所示类的实例被序列化之后,其数据流如清单2所示:
清单2. Bicycle类的序列化流
000000: AC ED 00 05 73 72 00 2C 63 6F 6D 2E 69 62 6D 2E |········com.ibm.|
000016: 62 61 2E 73 63 67 2E 4C 6F 6F 6B 41 68 65 61 64 |ba.scg.LookAhead|
000032: 44 65 73 65 72 69 61 6C 69 7A 65 72 2E 42 69 63 |Deserializer.Bic|
000048: 79 63 6C 65 4F DA AF 97 F8 CC C0 DA 02 00 03 49 |ycle···········I|
000064: 00 02 69 64 49 00 09 6E 62 72 57 68 65 65 6C 73 |··idI··nbrWheels|
000080: 4C 00 04 6E 61 6D 65 74 00 12 4C 6A 61 76 61 2F |L··name···Ljava/|
000096: 6C 61 6E 67 2F 53 74 72 69 6E 67 3B 78 70 00 00 |lang/String;····|
000112: 00 00 00 00 00 01 74 00 08 55 6E 69 63 79 63 6C |·········Unicycl|
000128: 65 |e|
对上述数据应用标准的对象序列化流协议,你将看到如清单3所示的序列化对象:
清单3. 被序列化的Bicycle对象的细节
STREAM_MAGIC (2 bytes) 0xACED
STREAM_VERSION (2 bytes) 5
newObject
TC_OBJECT (1 byte) 0x73
newClassDesc
TC_CLASSDESC (1 byte) 0x72
className
length (2 bytes) 0x2C = 44
text (59 bytes) com.ibm.ba.scg.LookAheadDeserializer.Bicycle
serialVersionUID (8 bytes) 0x4FDAAF97F8CCC0DA = 5754104541168320730
classDescInfo
classDescFlags (1 byte) 0x02 = SC_SERIALIZABLE
fields
count (2 bytes) 3
field[0]
primitiveDesc
prim_typecode (1 byte) I = integer
fieldName
length (2 bytes) 2
text (2 bytes) id
field[1]
primitiveDesc
prim_typecode (1 byte) I = integer
fieldName
length (2 bytes) 9
text (9 bytes) nbrWheels
field[2]
objectDesc
obj_typecode (1 byte) L = object
fieldName
length (2 bytes) 4
text (4 bytes) name
className1
TC_STRING (1 byte) 0x74
length (2 bytes) 0x12 = 18
text (18 bytes) Ljava/lang/String;
classAnnotation
TC_ENDBLOCKDATA (1 byte) 0x78
superClassDesc
TC_NULL (1 byte) 0x70
classdata[]
classdata[0] (4 bytes) 0 = id
classdata[1] (4 bytes) 1 = nbrWheels
classdata[2]
TC_STRING (1 byte) 0x74
length (2 bytes) 8
text (8 bytes) Unicycle
从清单3中你可以看到该序列化对象的类型为com.ibm.ba.scg.LookAheadDeserializer.Bicycle,它的ID为0,只有一个轮子,即它是一个独轮车。
重点是这个二进制格式包含一种文件头,这就允许你对输入进行校验。
类校验
如你在清单3中所看到的,在读取该二进制流时,在对象本身出现之前,首先会看到该序列化对象的类型描述。这种结构就允许实现自己的算法去读取类型描述,并依靠类的名称去决定是否继续读取该序列化流。幸运地是,通过使用Java提供的一个常用于定制类加载的"钩子",你能很容易地实现该功能--即,覆盖resolveClass()方法。这个"钩子"方法非常适合用于提供定制的校验功能,无论序列化流何时包含了不被期望的类,你都可以用这个方法去抛出一个异常。你需要继承类java.io.ObjectInputStream,并覆盖其中的resolveClass()方法。清单4中的代码就利用该项技术确保只有Bicycle类的实例才可被解序列化。
清单4. 定制校验"钩子"程序
package com.ibm.ba.scg.LookAheadDeserializer;
import java.io.IOException;
import java.io.InputStream;
import java.io.InvalidClassException;
import java.io.ObjectInputStream;
import java.io.ObjectStreamClass;
import com.ibm.ba.scg.LookAheadDeserializer.Bicycle;
public class LookAheadObjectInputStream extends ObjectInputStream {
public LookAheadObjectInputStream(InputStream inputStream)
throws IOException {
super(inputStream);
}
/**
* Only deserialize instances of our expected Bicycle class
*/
@Override
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException,
ClassNotFoundException {
if (!desc.getName().equals(Bicycle.class.getName())) {
throw new InvalidClassException(
"Unauthorized deserialization attempt",
desc.getName());
}
return super.resolveClass(desc);
}
}
通过对com.ibm.ba.scg.LookAheadDeserializer类的实例调用readObject()方法,就可以防止对不被期望的对象进行解序列化操作。
作为一个示例应用程序,清单5序列化了两个对象--一个是期望的类(com.ibm.ba.scg.LookAheadDeserializer.Bicycle)的实例,另一个是不被期望的类(java.io.File)的实例--然后使用清单4中的定制校验"钩子"程序去尝试它们进行解序列化。
清单5. 使用定制的"钩子"程序
package com.ibm.ba.scg.LookAheadDeserializer;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import com.ibm.ba.scg.LookAheadDeserializer.Bicycle;
public class LookAheadDeserializer {
private static byte[] serialize(Object obj) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(obj);
byte[] buffer = baos.toByteArray();
oos.close();
baos.close();
return buffer;
}
private static Object deserialize(byte[] buffer) throws IOException,
ClassNotFoundException {
ByteArrayInputStream bais = new ByteArrayInputStream(buffer);
// We use LookAheadObjectInputStream instead of InputStream
ObjectInputStream ois = new LookAheadObjectInputStream(bais);
Object obj = ois.readObject();
ois.close();
bais.close();
return obj;
}
public static void main(String[] args) {
try {
// Serialize a Bicycle instance
byte[] serializedBicycle = serialize(new Bicycle(0, "Unicycle", 1));
// Serialize a File instance
byte[] serializedFile = serialize(new File("Pierre Ernst"));
// Deserialize the Bicycle instance (legitimate use case)
Bicycle bicycle0 = (Bicycle) deserialize(serializedBicycle);
System.out.println(bicycle0.getName() + " has been deserialized.");
// Deserialize the File instance (error case)
Bicycle bicycle1 = (Bicycle) deserialize(serializedFile);
} catch (Exception ex) {
ex.printStackTrace(System.err);
}
}
}
当运行该应用程序时,在试图去java.io.File的对象进行解序列化之前,JVM就抛出异常,如图1所示:
图1. 应用程序输出
结论
本文向你展示了在序列化流中发现不被期望的类之后,若不使用加密,签章,或简单的成员变量校验等手段,如何能尽快地停止Java解序列化操作。
需求记住的是,整棵对象树(根对象,及其所有的成员对象)是在解序列化过程中进行组建的。在更为复杂的情况下,你可能必须允许更多的类可被解序列化。