Java安全之反序列化(1)

序列化與反序列化

概述

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();
    }
}

image-20221109153509035

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

readObject 方法實際調用 readObject0 方法反序列化字元串

image-20221109153846935

readObject0 方法以位元組的方式去讀,如果讀到 0x73,則代表這是一個對象的序列化數據,將會調用 readOrdinaryObject 方法進行處理

image-20221109154033957

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

img

readSerialData 方法中,首先通過類描述符獲得了序列化對象的數據布局。通過布局的 hasReadObjectMethod 方法判斷對象是否有重寫 readObject 方法,如果有,則使用 invokeReadObject 方法調用對象中的 readObject

image-20221109154606429

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

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