Java安全之反序列化(1)

2022-11-09 18:01:00

序列化與反序列化

概述

Java序列化是指把Java物件轉換為位元組序列的過程;這串字元可能被儲存/傳送到任何需要的位置,在適當的時候,再將它轉回原本的 Java 物件,而Java反序列化是指把位元組序列恢復為Java物件的過程。

為什麼需要序列化與反序列化

當兩個程序進行遠端通訊時,可以相互傳送各種型別的資料,包括文字、圖片、音訊、視訊等, 而這些資料都會以二進位制序列的形式在網路上傳送。那麼當兩個Java程序進行通訊時,能否實現程序間的物件傳送呢?答案是可以的。如何做到呢?這就需要Java序列化與反序列化了。換句話說,一方面,傳送方需要把這個Java物件轉換為位元組序列,然後在網路上傳送;另一方面,接收方需要從位元組序列中恢復出Java物件

Java 提供了兩個類 java.io.ObjectOutputStreamjava.io.ObjectInputStream 來實現序列化和反序列化的功能,其中 ObjectInputStream 用於恢復那些已經被序列化的物件,ObjectOutputStream 將 Java 物件的原始資料型別和圖形寫入 OutputStream。

在 Java 的類中,必須要實現 java.io.Serializablejava.io.Externalizable 介面才可以使用,而實際上 Externalizable 也是實現了 Serializable 介面

ObjectOutputStream

ObjectOutputStream 繼承的父類別或實現的介面如下:

  • 父類別 OutputStream:所有位元組輸出流的頂級父類別,用來接收輸出的位元組並行送到某些接收器(sink)。
  • 介面 ObjectOutput:ObjectOutput 擴充套件了 DataOutput 介面,DataOutput 介面提供了將資料從任何 Java 基本型別轉換為位元組序列並寫入二進位制流的功能,ObjectOutput 在 DataOutput 介面基礎上提供了 writeObject 方法,也就是類(Object)的寫入。
  • 介面 ObjectStreamConstants:定義了一些在物件序列化時寫入的常數。常見的一些的比如 STREAM_MAGICSTREAM_VERSION 等。

通過這個類的父類別及父介面,我們大概可以理解這個類提供的功能:能將 Java 中的類、陣列、基本資料型別等物件轉換為可輸出的位元組,也就是反序列化。接下來看一下這個類中幾個關鍵方法

writeObject

這是 ObjectOutputStream 物件的核心方法之一,用來將一個物件寫入輸出流中,任何物件,包括字串和陣列,都是用 writeObject 寫入到流中的。

之前說過,序列化的過程,就是將一個物件當前的狀態描述為位元組序列的過程,也就是 Object -> OutputStream 的過程,這個過程由 writeObject 實現。writeObject 方法負責為指定的類編寫其物件的狀態,以便在後面可以使用與之對應 readObject 方法來恢復它

writeUnshared

用於將非共用物件寫入 ObjectOutputStream,並將給定的物件作為重新整理物件寫入流中。

使用 writeUnshared 方法會使用 BlockDataOutputStream 的新範例進行序列化操作,不會使用原來 OutputStream 的參照物件。

writeObject0

writeObjectwriteUnshared 實際上呼叫 writeObject0 方法,也就是說 writeObject0是上面兩個方法的基礎實現。具體的實現流程將會在後面再進行詳細研究。

writeObjectOverride

如果 ObjectOutputStream 中的 enableOverride 屬性為 true,writeObject 方法將會呼叫 writeObjectOverride,這個方法是由 ObjectOutputStream 的子類實現的。

在由完全重新實現 ObjectOutputStream 的子類完成序列化功能時,將會呼叫實現類的 writeObjectOverride 方法進行處理。

ObjectInputStream

ObjectInputStream 繼承的父類別或實現的介面如下:

  • 父類別 InputStream:所有位元組輸入流的頂級父類別。
  • 介面 ObjectInput:ObjectInput 擴充套件了 DataInput 介面,DataInput 介面提供了從二進位制流讀取位元組並將其重新轉換為 Java 基礎型別的功能,ObjectInput 額外提供了 readObject 方法用來讀取類。
  • 介面 ObjectStreamConstants:同上。

ObjectInputStream 實現了反序列化功能,看一下其中的關鍵方法。

readObject

從 ObjectInputStream 讀取一個物件,將會讀取物件的類、類的簽名、類的非 transient 和非 static 欄位的值,以及其所有父類別型別。

我們可以使用 writeObjectreadObject 方法為一個類重寫預設的反序列化執行方,所以其中 readObject 方法會 「傳遞性」 的執行,也就是說,在反序列化過程中,會呼叫反序列化類的 readObject 方法,以完整的重新生成這個類的物件。

readUnshared

從 ObjectInputStream 讀取一個非共用物件。 此方法與 readObject 類似,不同點在於readUnshared 不允許後續的 readObjectreadUnshared 呼叫參照這次呼叫反序列化得到的物件。

readObject0

readObjectreadUnshared 實際上呼叫 readObject0 方法,readObject0是上面兩個方法的基礎實現。

readObjectOverride

由 ObjectInputStream 子類呼叫,與 writeObjectOverride 一致。

通過上面對 ObjectOutputStream 和 ObjectInputStream 的瞭解,兩個類的實現幾乎是一種對稱的、雙生的方式進行

反序列化漏洞

一個類想要實現序列化和反序列化,必須要實現 java.io.Serializablejava.io.Externalizable 介面。

Serializable 介面是一個標記介面,標記了這個類可以被序列化和反序列化,而 Externalizable 介面在 Serializable 介面基礎上,又提供了 writeExternalreadExternal 方法,用來序列化和反序列化一些外部元素。

其中,如果被序列化的類重寫了 writeObject 和 readObject 方法,Java 將會委託使用這兩個方法來進行序列化和反序列化的操作。

正是因為這個特性,導致反序列化漏洞的出現:在反序列化一個類時,如果其重寫了 readObject 方法,程式將會呼叫它,如果這個方法中存在一些惡意的呼叫,則會對應用程式造成危害。

在這裡我們利用寫一個簡單的測試程式,如下程式碼建立了 Person 類,實現了 Serializable 介面,並重寫了 readObject 方法,在方法中使用 Runtime 執行命令彈出計算器

public class Person implements Serializable {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
        Runtime.getRuntime().exec("calc.exe");
    }

}

然後我們將這個類序列化並寫在檔案中,隨後對其進行反序列化,就觸發了命令執行

public class SerializableTest {

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Person person = new Person("gk0d", 24);

        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("test.txt"));
        oos.writeObject(person);
        oos.close();


        FileInputStream fis = new FileInputStream("test.txt");
        ObjectInputStream ois = new ObjectInputStream(fis);
        ois.readObject();
        ois.close();
    }
}

那為什麼我們重寫了readObject就會執行呢?來看一下 java.io.ObjectInputStream#readObject() 方法的具體實現程式碼。

readObject 方法實際呼叫 readObject0 方法反序列化字串

readObject0 方法以位元組的方式去讀,如果讀到 0x73,則代表這是一個物件的序列化資料,將會呼叫 readOrdinaryObject 方法進行處理

readOrdinaryObject 方法會呼叫 readClassDesc 方法讀取類描述符,並根據其中的內容判斷類是否實現了 Externalizable 介面,如果是,則呼叫 readExternalData 方法去執行反序列化類中的 readExternal,如果不是,則呼叫 readSerialData 方法去執行類中的 readObject 方法

readSerialData 方法中,首先通過類描述符獲得了序列化物件的資料佈局。通過佈局的 hasReadObjectMethod 方法判斷物件是否有重寫 readObject 方法,如果有,則使用 invokeReadObject 方法呼叫物件中的 readObject

我們就瞭解了反序列化漏洞的觸發原因。與反序列漏洞的觸發方式相同,在序列化時,如果一個類重寫了 writeObject 方法,並且其中產生惡意呼叫,則將會導致漏洞,當然在實際環境中,序列化的資料來自不可信源的情況比較少見。

那接下來該如何利用呢?我們需要找到那些類重寫了 readObject 方法,並且找到相關的呼叫鏈,能夠觸發漏洞。

漏洞產生條件

原因在於伺服器端會反序列化使用者端傳遞的程式碼,這就會給予攻擊者在伺服器上執行程式碼的能力

入口類的readObject直接呼叫危險方法 (沒有,不可能出現)
入口類引數中包含可控類,該類有危險方法
入口類引數中包含可控類,該類又呼叫其他有危險方法的類
建構函式/靜態程式碼等類載入時隱式執行 (JAVA自身類載入也會執行一些程式碼)
入口類sink:繼承Serializable介面,重寫readobject,例如Map Hashmap中重寫readobject

gadget chain 呼叫鏈:非常繁瑣,根據相同名稱,相同型別來尋找

執行類source:(最重要)這是能夠造成危害的程式碼執行點

反序列化過程是一個正常的業務需求,將正常的位元組流還原成物件屬於正常的功能。但是當程式中的某處觸發點在還原物件的過程中,能夠成功地執行構造出來的利用鏈,則會成為反序列化漏洞的觸發點。反序列化的漏洞形成需要上述條件全部得到滿足,程式中僅有一條利用鏈或者僅有一個反序列化的觸發點都不會造成安全問題,不能被認定為漏洞