Tips
書中的源代碼地址:https://github.com/jbloch/effective-java-3e-source-code
注意,書中的有些代碼里方法是基於Java 9 API中的,所以JDK 最好下載 JDK 9以上的版本。
序列化
本章涉及對象序列化(object serialization),它是Java的框架,用於將對象編碼為字節流(序列化)並從其編碼中重構對象(反序列化)。 一旦對象被序列化,其編碼可以從一個虛擬機發送到另一個虛擬機或存儲在磁盤上以便以后反序列化。 本章重點介紹序列化的風險以及如何將序列化的風險最小化。
85. 其他替代方式優於Java本身序列化
當序列化在1997年添加到Java中時,它被認為有一定的風險。這種方法曾在研究語言(模塊3)中嘗試過,但從未在生產語言中使用過。雖然程序員不費什么力氣就能實現分布式對象的承諾很吸引人,但代價是不可見的構造方法和API與實現之間模糊的界線,可能會出現正確性、性能、安全性和維護方面的問題。支持者認為收益大於風險,但歷史證明並非如此。
本書前幾版中描述的安全問題與一些人擔心的一樣嚴重。 2000年之前中討論的漏洞在未來十年被轉化為嚴重漏洞,其中最著名的包括2016年11月對舊金山大都會運輸署(San Francisco Metropolitan Transit Agency)市政鐵路(SFMTA Muni)的勒索軟件攻擊,導致整個收費系統關閉了兩天[Gallagher16]。
序列化的一個基本問題是它的攻擊面太大而無法保護,而且還在不斷增長:通過調用ObjectInputStream
類上的readObject
方法反序列化對象圖。這個方法本質上是一個神奇的構造方法,可以用來實例化類路徑上幾乎任何類型的對象,只要該類型實現Serializable接口。在反序列化字節流的過程中,此方法可以執行來自任何這些類型的代碼,因此所有這些類型的代碼都是攻擊面的一部分。
攻擊面包括Java平台類庫中的類,第二方類庫(如Apache Commons Collections)和應用程序本身。 即使你遵守所有相關的最佳實踐並成功編寫無法攻擊的可序列化類,你的應用程序仍可能容易受到攻擊。 引用CERT協調中心技術經理Robert Seacord的話:
Java反序列化是一個明顯且存在的危險,因為它直接被應用程序廣泛使用,並間接地由Java子系統(如RMI(遠程方法調用),JMX(Java管理擴展)和JMS(Java消息系統))廣泛使用。 不受信任的流的反序列化可能導致遠程代碼執行(RCE),拒絕服務(DoS)以及一系列其他漏洞利用。 應用程序即使沒有做錯也容易受到這些攻擊。[Seacord17]
攻擊者和安全研究人員研究Java類庫和常用的第三方類庫中的可序列化類型,尋找在反序列化過程中調用的執行潛在危險活動的方法。這種方法稱為gadget。多個gadget可以同時使用,形成一個gadget鏈(chain)。偶爾會發現gadget鏈,它的功能足夠強大,允許攻擊者在底層硬件上執行任意的本機代碼,只要有機會提交精心設計的字節流進行反序列化。這正是SFMTA Muni襲擊中發生的事情。這次襲擊並不是孤立事件。已經發生過,而且還會有更多。
不使用任何gadget,就可以通過導致需要很長時間反序列化的短字節流,進行反序列化操作,輕松地發起拒絕服務攻擊。這種流被稱為反序列化炸彈(deserialization bombs)[Svoboda16]。下面是Wouter Coekaerts的一個例子,它只使用HashSet和字符串[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個或更少的對象引用。整個流的長度為5744字節,但是在完成反序列化之前,太陽都已經耗盡了。問題是反序列化HashSet實例需要計算其元素的哈希碼。root
實例的2個元素本身就是包含2個HashSet元素的HashSet,每個HashSet元素包含2個HashSet元素,以此類推,深度為100。因此,反序列化set會導致hashCode方法被調用超過2100次。除了反序列化會持續很長時間之外,反序列化器沒有任何錯誤的跡象。生成的對象很少,並且堆棧深度是有界的。
那么你能做些什么來抵御這些問題呢? 每當反序列化你不信任的字節流時,就會打開攻擊。 避免序列化漏洞利用的最佳方法是永遠不要反序列化任何東西。用1983年電影《戰爭游戲》(WarGames)中名為約書亞(Joshua)的電腦的話來說,“唯一的制勝的招式就是不玩”。沒有理由在你編寫的任何新系統中使用Java序列化。 還有其他在對象和字節序列之間進行轉換的機制,可以避免Java序列化的許多危險,同時提供許多優勢,例如跨平台支持,高性能,大型工具生態系統以及廣泛的專業知識社區。 在本書中,我們將這些機制稱為跨平台結構化數據表示( cross-platform structured-data representations)。 雖然其他人有時將它們稱為序列化系統,但本書避免了這種用法,以防止與Java序列化混淆。
這些表示的共同點是它們比Java序列化簡單得多。它們不支持任意對象圖的自動序列化和反序列化。相反,它們支持由一組屬性值對(attribute-value pairs)組成的簡單結構化數據對象。只支持少數基本數據類型和數組數據類型。事實證明,這個簡單的抽象足以構建功能極其強大的分布式系統,而且足夠簡單,可以避免Java序列化從一開始就存在的嚴重問題。
領先的跨平台結構化數據表示是JSON [JSON]和Protocol Buffers,也稱為protobuf [Protobuf]。 JSON由Douglas Crockford設計用於瀏覽器——服務器通信,並且Protocol Buffers由Google設計用於在其服務器之間存儲和交換結構化數據。 即使這些表示有時被稱為中立語言(language-neutral),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)。該工具允許指定一個過濾器,該過濾器在反序列化之前應用於數據流。它在類的粒度上運行,允許接受或拒絕某些類。默認接受類並拒絕潛在危險類的列表稱為黑名單;在默認情況下拒絕類並接受假定安全的類的列表稱為白名單。比起黑名單,更喜歡白名單,因為黑名單只保護你免受已知的威脅。一個名為Serial Whitelist Application Trainer (SWAT)的工具可用於應用程序自動准備一個白名單[Schneider16]。過濾工具還將保護免受過度使用內存和過深的對象圖的影響,但它不能保護免受如上面所示的序列化炸彈的影響。
不幸的是,序列化在Java生態系統中仍然普遍存在。 如果要維護基於Java序列化的系統,請認真考慮遷移到跨平台的結構化數據表示,即使這可能是一項耗時的工作。 實際上,可能仍然發現自己必須編寫或維護可序列化的類。 編寫一個正確,安全,高效的可序列化類需要非常小心。 本章的其余部分提供了有關何時以及如何執行此操作的建議。
總之,序列化是危險的,應該避免。如果從頭開始設計一個系統,可以使用跨平台的結構化數據表示,如JSON或protobuf。不要反序列化不受信任的數據。如果必須這樣做,請使用對象反序列化過濾器,但要注意,它不能保證阻止所有攻擊。避免編寫可序列化的類。如果你必須這樣做,一定要非常小心。