java安全編碼指南之:序列化Serialization

flydean發表於2020-11-01

簡介

序列化是java中一個非常常用又會被人忽視的功能,我們將物件寫入檔案需要序列化,同時,物件如果想要在網路上傳輸也需要進行序列化。

序列化的目的就是保證物件可以正確的傳輸,那麼我們在序列化的過程中需要注意些什麼問題呢?

一起來看看吧。

序列化簡介

如果一個物件要想實現序列化,只需要實現Serializable介面即可。

奇怪的是Serializable是一個不需要任何實現的介面。如果我們implements Serializable但是不重寫任何方法,那麼將會使用JDK自帶的序列化格式。

但是如果class傳送變化,比如增加了欄位,那麼預設的序列化格式就滿足不了我們的需求了,這時候我們需要考慮使用自己的序列化方式。

如果類中的欄位不想被序列化,那麼可以使用transient關鍵字。

同樣的,static表示的是類變數,也不需要被序列化。

注意serialVersionUID

serialVersionUID 表示的是物件的序列ID,如果我們不指定的話,是JVM自動生成的。在反序列化的過程中,JVM會首先判斷serialVersionUID 是否一致,如果不一致,那麼JVM會認為這不是同一個物件。

如果我們的例項在後期需要被修改的話,注意一定不要使用預設的serialVersionUID,否則後期class傳送變化之後,serialVersionUID也會同樣的發生變化,最終導致和之前的序列化版本不相容。

writeObject和readObject

如果要自己實現序列化,那麼可以重寫writeObject和readObject兩個方法。

注意,這兩個方法是private的,並且是non-static的:

private void writeObject(final ObjectOutputStream stream)
    throws IOException {
  stream.defaultWriteObject();
}
 
private void readObject(final ObjectInputStream stream)
    throws IOException, ClassNotFoundException {
  stream.defaultReadObject();
}

如果不是private和non-static的,那麼JVM就不能夠發現這兩個方法,就不會使用他們來做自定義序列化。

readResolve和writeReplace

如果class中的欄位比較多,而這些欄位都可以從其中的某一個欄位中自動生成,那麼我們其實並不需要序列化所有的欄位,我們只把那一個欄位序列化就可以了,其他的欄位可以從該欄位衍生得到。

readResolve和writeReplace就是序列化物件的代理功能。

首先,序列化物件需要實現writeReplace方法,表示替換成真正想要寫入的物件:

public class CustUserV3 implements java.io.Serializable{

    private String name;
    private String address;

    private Object writeReplace()
            throws java.io.ObjectStreamException
    {
        log.info("writeReplace {}",this);
        return new CustUserV3Proxy(this);
    }
}

然後在Proxy物件中,需要實現readResolve方法,用於從系列化過的資料中重構序列化物件。如下所示:

public class CustUserV3Proxy implements java.io.Serializable{

    private String data;

    public CustUserV3Proxy(CustUserV3 custUserV3){
        data =custUserV3.getName()+ "," + custUserV3.getAddress();
    }

    private Object readResolve()
            throws java.io.ObjectStreamException
    {
        String[] pieces = data.split(",");
        CustUserV3 result = new CustUserV3(pieces[0], pieces[1]);
        log.info("readResolve {}",result);
        return result;
    }
}

我們看下怎麼使用:

public void testCusUserV3() throws IOException, ClassNotFoundException {
        CustUserV3 custUserA=new CustUserV3("jack","www.flydean.com");

        try(FileOutputStream fileOutputStream = new FileOutputStream("target/custUser.ser")){
            ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
            objectOutputStream.writeObject(custUserA);
        }

        try(FileInputStream fileInputStream = new FileInputStream("target/custUser.ser")){
            ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
            CustUserV3 custUser1 = (CustUserV3) objectInputStream.readObject();
            log.info("{}",custUser1);
        }
    }

注意,我們寫入和讀出的都是CustUserV3物件。

不要序列化內部類

所謂內部類就是未顯式或隱式宣告為靜態的巢狀類,為什麼我們不要序列化內部類呢?

  • 序列化在非靜態上下文中宣告的內部類,該內部類包含對封閉類例項的隱式非瞬態引用,從而導致對其關聯的外部類例項的序列化。

  • Java編譯器對內部類的實現在不同的編譯器之間可能有所不同。從而導致不同版本的相容性問題。

  • 因為Externalizable的物件需要一個無參的建構函式。但是內部類的建構函式是和外部類的例項相關聯的,所以它們無法實現Externalizable。

所以下面的做法是正確的:

public class OuterSer implements Serializable {
  private int rank;
  class InnerSer {
    protected String name;
  }
}

如果你真的想序列化內部類,那麼把內部類置為static吧。

如果類中有自定義變數,那麼不要使用預設的序列化

如果是Serializable的序列化,在反序列化的時候是不會執行建構函式的。所以,如果我們在建構函式或者其他的方法中對類中的變數有一定的約束範圍的話,反序列化的過程中也必須要加上這些約束,否則就會導致惡意的欄位範圍。

我們舉幾個例子:

public class SingletonObject implements Serializable {
    private static final SingletonObject INSTANCE = new SingletonObject ();
    public static SingletonObject getInstance() {
        return INSTANCE;
    }
    private SingletonObject() {
    }

    public static Object deepCopy(Object obj) {
        try {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            new ObjectOutputStream(bos).writeObject(obj);
            ByteArrayInputStream bin =
                    new ByteArrayInputStream(bos.toByteArray());
            return new ObjectInputStream(bin).readObject();
        } catch (Exception e) {
            throw new IllegalArgumentException(e);
        }
    }

    public static void main(String[] args) {
        SingletonObject singletonObject= (SingletonObject) deepCopy(SingletonObject.getInstance());
        System.out.println(singletonObject == SingletonObject.getInstance());
    }
}

上面是一個singleton物件的例子,我們在其中定義了一個deepCopy的方法,通過序列化來對物件進行拷貝,但是拷貝出來的是一個新的物件,儘管我們定義的是singleton物件,最後執行的結果還是false,這就意味著我們的系統生成了一個不一樣的物件。

怎麼解決這個問題呢?

加上一個readResolve方法就可以了:

    protected final Object readResolve() throws NotSerializableException {
        return INSTANCE;
    }

在這個readResolve方法中,我們返回了INSTANCE,以確保其是同一個物件。

還有一種情況是類中欄位是有範圍的。

public class FieldRangeObject implements Serializable {

    private int age;

    public FieldRangeObject(int age){
        if(age < 0 || age > 100){
            throw new IllegalArgumentException("age範圍不對");
        }
        this.age=age;
    }
}

上面的類在反序列化中會有什麼問題呢?

因為上面的類在反序列化的過程中,並沒有對age欄位進行校驗,所以,惡意程式碼可能會生成超出範圍的age資料,當反序列化之後就溢位了。

怎麼處理呢?

很簡單,我們在readObject方法中進行範圍的判斷即可:

    private  void readObject(java.io.ObjectInputStream s)
            throws IOException, ClassNotFoundException {
        ObjectInputStream.GetField fields = s.readFields();
        int age = fields.get("age", 0);
        if (age > 100 || age < 0) {
            throw new InvalidObjectException("age範圍不對!");
        }
        this.age = age;
    }

不要在readObject中呼叫可重寫的方法

為什麼呢?readObject實際上是反序列化的建構函式,在readObject方法沒有結束之前,物件是沒有構建完成,或者說是部分構建完成。如果readObject呼叫了可重寫的方法,那麼惡意程式碼就可以在方法的重寫中獲取到還未完全例項化的物件,可能造成問題。

本文的程式碼:

learn-java-base-9-to-20/tree/master/security

本文已收錄於 http://www.flydean.com/java-security-code-line-serialization/

最通俗的解讀,最深刻的乾貨,最簡潔的教程,眾多你不知道的小技巧等你來發現!

歡迎關注我的公眾號:「程式那些事」,懂技術,更懂你!

相關文章