ITEM 85: PREFER ALTERNATIVES TO JAVA SERIALIZATION
1997年将序列化添加到 Java 中时,人们知道它有一定的风险。这种方法曾在研究语言(Model-3)中试用过,但从未在生产语言中试用过。虽然程序员只需付出很少的努力就能实现分布式对象的承诺很吸引人,但其代价是不可见的构造函数和API与实现之间模糊的界限,以及正确性、性能、安全性和维护方面的潜在问题。支持者认为收益大于风险,但历史证明并非如此。
在本书的前几版中描述的安全问题被证明和一些人担心的一样严重。21世纪初讨论的漏洞在接下来的10年变成了严重的漏洞,其中最著名的一次就是在2016年11月对旧金山大都会运输署市政铁路(SFMTA Muni)的勒索软件攻击,导致整个收费系统关闭了两天[Gallagher16]。
序列化的一个基本问题是,它的攻击面太大,难以保护,而且还在不断增长:对象是通过调用 ObjectInputStream 上的 readObject 方法来反序列化的。这个方法本质上是一个神奇的构造函数,可以用来实例化类路径上几乎任何类型的对象,只要该类型实现 Serializable 接口。在反序列化字节流的过程中,此方法可以执行这些类型中的任何一种代码,因此所有这些类型的代码都是攻击表面的一部分。
攻击覆盖了包括 Java 平台库、第三方库(如Apache Commons collection)和应用程序本身中的类。即使您坚持所有相关的最佳实践,并成功编写了不受攻击的可序列化类,您的应用程序仍然可能是脆弱的。引用 CERT 协调中心技术经理 Robert Seacord的话:
“Java反序列化是一个明显且存在的危险,因为应用程序和 Java 子系统(如RMI(远程方法调用)、JMX (Java管理扩展)和JMS (Java消息传递系统))都直接或间接地广泛使用 Java 反序列化。对不受信任的流进行反序列化会导致远程代码执行(RCE)、拒绝服务(DoS)和其他一系列攻击。即使应用程序没有做错任何事情,它们也容易受到这些攻击。(Seacord17)”
攻击者和安全研究人员研究 Java 库和常用的第三方库中的可序列化类型,寻找在反序列化期间调用的执行潜在危险活动的方法。这种方法被称为 gadget。多个 gadget可以协同使用,以形成 gadget 链。有时会发现一个足够强大的 gadget 链,允许攻击者在底层硬件上执行任意本地代码,只要有机会提交一个精心设计的字节流进行反序列化。这正是在 SFMTA Muni 袭击中发生的事情。这次袭击并不是孤立的。曾经有过这样的人,将来还会有更多。
在不使用任何 gadget 的情况下,您可以通过导致需要很长时间进行反序列化的短流的反序列化,轻松地发起拒绝服务攻击。这样的流被称为反序列化炸弹[Svoboda16]。这里有一个例子,Wouter Coekaerts只使用哈希集和字符串[Coekaerts15]:
// Deserialization bomb - deserializing this stream takes forever
static byte[] bomb() {
Set<Object> root = new HashSet<>();
Set<Object> s1 = root;
Set<Object> s2 = new HashSet<>();
for (int i = 0; i < 100; i++) {
Set<Object> t1 = new HashSet<>();
Set<Object> t2 = new HashSet<>();
t1.add("foo"); // Make t1 unequal to t2
s1.add(t1);
s1.add(t2);
s2.add(t1);
s2.add(t2);
s1 = t1;
s2 = t2;
}
return serialize(root); // Method omitted for brevity
}
对象由 201 个 HashSet 实例组成,每个实例包含 3 个或更少的对象引用。整个流有5,744 字节长,但是时间在你反序列化它之前就已经耗尽了。问题是反序列化一个HashSet 实例需要计算其元素的哈希码。根哈希集的两个元素本身就是哈希集,包含2 个哈希集元素,每个哈希集元素包含 2 个哈希集元素,以此类推,深度为 100 层。因此,反序列化该集合会导致 hashCode 方法被调用超过 2100 次。除了反序列化花费了很长时间这一事实外,反序列化器没有指出有什么问题。产生的对象很少,堆栈深度是有限的。
那么你能做些什么来抵御这些问题呢?当您反序列化您不相信的字节流时,您就会受到攻击。避免序列化利用的最好方法是永远不要反序列化任何东西。用1983年电影《战争游戏》(WarGames)中一台名叫约书亚(Joshua)的电脑的话来说,“要想取胜,唯一的办法就是不去玩。”没有理由在你编写的任何新系统中使用 Java 序列化。还有其他用于在对象和字节序列之间转换的机制,这些机制避免了 Java 序列化的许多危险,同时还提供了许多优势,例如跨平台支持、高性能、大型工具生态系统和广泛的专业社区。在本书中,我们将这些机制称为跨平台结构化数据表示。虽然有些人有时将其称为序列化系统,但本书避免了这种用法,以免与 Java 序列化混淆。
这些表示的共同之处在于它们比 Java 序列化要简单得多。它们不支持任意对象图的自动序列化和反序列化。相反,它们支持简单的、结构化的数据对象,这些数据对象由一组属性-值对组成。只支持少数基本和数组数据类型。事实证明,这种简单的抽象足以构建功能极其强大的分布式系统,也足以简单地避免自 Java 序列化一开始就困扰它的严重问题。
领先的跨平台结构化数据表示是 JSON [JSON]和协议缓冲区,也称为 protobuf [protobuf]。JSON 是由 Douglas Crockford 设计用于浏览器-服务器通信的,而protobuf 是由谷歌设计用于在其服务器之间存储和交换结构化数据。尽管这些表示有时被称为语言中立,但 JSON 最初是为 JavaScript 开发的,而 protobuf 则是为c++ 开发的;这两种表象都保留着其起源的痕迹。
JSON 和 protobuf 之间最大的区别是 JSON 是基于文本的,是人类可读的,而protobuf 是二进制的,效率更高;JSON 只是一种数据表示,而 protobuf 提供模式(类型)来记录和强制适当的使用。
尽管 protobuf 比 JSON 更高效,但对于基于文本的表示,JSON 是非常高效的。虽然 protobuf 是一种二进制表示,但它提供了另一种文本表示,用于需要人类可读性的地方(pbtxt)。如果无法完全避免 Java 序列化,可能是因为您所工作的遗留系统需要它,那么下一个最好的替代方案是永远不要反序列化不可信的数据。特别是,永远不要接受来自不可信来源的RMI流量。Java 的官方安全编码指南说:“不可信数据的反序列化本质上是危险的,应该避免。这个句子被设置为大、粗体、斜体和红色,并且它是整个文档中唯一得到这种处理的文本[Java-secure]。
如果不能避免序列化,并且不能绝对肯定反序列化的数据的安全性,请使用 Java 9中添加的并向后移植到早期版本的对象反序列化过滤(java.io.ObjectInputFilter)。此功能允许您指定在反序列化数据流之前应用于数据流的筛选器。它在类粒度上操作,允许您接受或拒绝某些类。默认接受类并拒绝潜在危险的类列表称为黑名单;默认情况下拒绝类并接受一个假定安全的类列表称为白名单。选择白名单而不是黑名单,因为黑名单只能保护您免受已知的威胁。一个叫做“连续白名单应用培训器”(SWAT)的工具可以用来为你的应用自动准备一个白名单[Schneider16]。过滤功能还可以保护您不受过度内存使用和对象图过于深的影响,但它不能保护您不受如上所示的序列化炸弹的影响。
不幸的是,序列化在 Java 生态系统中仍然很普遍。如果您正在维护一个基于 Java 序列化的系统,请认真考虑迁移到跨平台的结构化数据表示,尽管这可能是一项耗时的工作。实际上,您可能仍然发现自己必须编写或维护一个可序列化的类。编写正确、安全、有效的可序列化类需要非常小心。本章的其余部分将提供何时以及如何进行此操作的建议。
总之,序列化是危险的,应该避免。如果您从头开始设计系统,请使用跨平台的结构化数据表示,如 JSON 或 protobuf。不要反序列化不受信任的数据。如果必须这样做,请使用对象反序列化过滤,但请注意,它不能保证阻止所有攻击。避免编写可序列化的类。如果你必须这样做,要非常谨慎。