如何正確使用Java序列化?

前言

  什麼是序列化:將對象編碼成一個字節流,這樣一來就可以在通信中傳遞對象了。比如在一臺虛擬機中被傳遞到另一臺虛擬機中,或者字節流存儲到磁盤上。

  “關於Java的序列化,無非就是簡單的實現Serializable接口”這樣的說法只能說明停留在會用的階段,而我們想要走的更遠往往就需要了解更多的東西,比如:爲什麼要實現序列化?序列化對程序的安全性有啥影響?如何避免多餘的序列化?.....

  本文主要參考資料《Effective Java》,其中代碼除了只作部分說明,不能運行外,剩餘代碼都是親自實踐過的!

 


 

 

一、序列化代價

雖然實現Serializable很簡單,但是爲了序列化而付出的長期開銷往往是實實在在的。實現Serializable接口而付出的最大代價是,一旦一個類被髮布,就大大降低了“改變這個類的實現”的靈活性。

  問:這個靈活性具體是指什麼呢?

  即一旦類實現了Serializable接口,並且這個類被廣泛地使用,往往必須永遠支持這種序列化形式,如果使用默認的序列化形式,那麼這種序列化形式將永遠地束縛在該類最初的內部表示法上,換句話說,一旦接受了默認的序列化形式,這個類中私有的和包級私有的實例域都變成導出的API的一部分,這顯然是不符合的。這也就是實現序列化往往需要考慮到的幾個代價,具體請往下看!

1、可能會導致InvalidClassException異常

  如果沒有顯式聲明序列版本UID,對對象的需求進行了改動,那麼兼容性將會遭到破壞,在運行時導致InvalidClassException。比如:增加一個不是很重要的工具方法,自動產生的序列版本UID也會發生變化,則會出現序列版本UID不一致的情況。所以最好還是顯式的增加序列版本號UID。

  對User JavaBean實現Serializable接口,增加固定的序列版本號

public class User implements Serializable {

    /** 顯示增加序列版本UUID,自動生成UUID可能會導致InvalidClassException */
    private static final long serialVersionUID = 1L;

    public User(int id, String name) {
        this.id = id;
        this.name = name;
    }

    private int id;
    private String name;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", name='" + name + '\'' +
                '}';
    }
}

  使用ObjectOutputStream與ObjectInputStream流控制序列與反序列

/**
 * @author jian
 * @date 2019/4/5
 * @description 測試序列化
 */
public class SeriablizableTest {

    public static void main(String[] args) {
        User user = new User(1, "lijian");
        serializeUser(user);
        deserializeUser();

    }

    /**
     * 使用writeObject方法序列化
     *
     * @param user
     */
    private static void serializeUser(User user) {
        ObjectOutputStream outputStream = null;
        try {
            // 創建對象輸出流, 包裝一個其它類型目標輸出流,如文件流
            outputStream = new ObjectOutputStream(new FileOutputStream("D:\\user.txt"));
            // 通過對象輸出流的writeObject方法將對象user寫入流中
            outputStream.writeObject(user);
            System.out.println("user序列化成功!");
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (outputStream != null) {
                try {
                    outputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    private static void deserializeUser() {
        User user = null;
        Employee employee = null;
        ObjectInputStream inputStream = null;
        try {
            // 創建對象輸出流, 包裝一個其它類型目標輸出流,如文件流
            inputStream = new ObjectInputStream(new FileInputStream("D:\\user.txt"));
            // 通過對象輸出流的writeObject方法將對象user寫入流中
            user = (User)inputStream.readObject();
            System.out.println("user反序列化成功:" + user);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }  catch (IOException e) {
            e.printStackTrace();
        }
        finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

    }
}

輸出結果:先看user.txt文件中二進制文件流(因爲txt打不開二進制流,所以是亂碼)

之後再看控制檯中,反序列化輸出的User{id=1, name='lijian'},說明整個過程序列化成功!

之後去掉固定的序列版本號UID,讓其自動生成,同時增加age屬性(或者手動修改UID爲2L)

 private static final long serialVersionUID = 2L;

只進行反序列化將會報錯: java.io.InvalidClassException 

 public static void main(String[] args) {
        User user = new User(1, "lijian");
//        serializeUser(user);
        deserializeUser();

    }

 

 

2、增加了出現Bug和安全漏洞的可能性

  序列化機制是一種語言之外的對象創建機制,反序列化機制都是一個“隱藏的構造器”,具備與其他構造器相同的特點,正式因爲反序列化中沒有顯式構造器,所以很容易就會忽略:不允許攻擊者訪問正在構造過程中的對象內部信息。換句話說,序列化後的字節流可以被截取進行僞造,之後利用readObject方法反序列會不符合要求甚至不安全的實例。

    

 

 

3、隨着類發行新的版本,測試負擔也會增加。

  一個可序列化的類被修訂時,需要檢查是否“在新版本中序列化一個實例,可以在舊版本中反序列化”,如果一個實現序列化的類有很多的子類或者是被修改時,就不得不加以測試。

 

二、序列化的缺陷

1、序列化是保存對象的狀態,也就是不會關心static靜態域,靜態域不會被序列化。如User中count靜態域。

public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    private static int count = 1;

    public User(int id, String name) {
        // 約束條件name不能爲null
        if (name == null || StringUtils.isEmpty(name)) {
            throw new NullPointerException("name is null");
        }
        this.id = id;
        this.name = name;
    }
    public User(){};

    private int id;
    private String name;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getCount() {
        return count;
    }

    public void setCount(int count) {
        User.count = count;
    }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", count=" + count +
                '}';
    }

    private void readObject(ObjectInputStream inputStream)
            throws IOException, ClassNotFoundException {
        inputStream.defaultReadObject();
        // 約束條件name不能爲null
        if (name == null || StringUtils.isEmpty(name)) {
            throw new NullPointerException("name is null");
        }
    }

}

賦值count爲20:

public static void main(String[] args) {
        User user = new User();
        user.setName("Lijian");
        user.setId(1);
        user.setCount(20);
        serializeUser(user);
        deserializeUser();
}

序列化-反序列化

/**
     * 使用writeObject方法序列化
     *
     * @param user
     */
    private static void serializeUser(User user) {
        ObjectOutputStream outputStream = null;
        try {
            // 創建對象輸出流, 包裝一個其它類型目標輸出流,如文件流
            outputStream = new ObjectOutputStream(new FileOutputStream("D:\\user.txt"));
            // 通過對象輸出流的writeObject方法將對象user寫入流中
            outputStream.writeObject(user);
            System.out.println("user序列化成功!");
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (outputStream != null) {
                try {
                    outputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    private static void deserializeUser() {
        User user = null;
        ObjectInputStream inputStream = null;
        try {
            // 創建對象輸出流, 包裝一個其它類型目標輸出流,如文件流
            inputStream = new ObjectInputStream(new FileInputStream("D:\\user.txt"));
            // 通過對象輸出流的writeObject方法將對象user寫入流中
            user = (User)inputStream.readObject();
            // User靜態變量初始化爲0,不會被反序列化
            System.out.println("user反序列化成功!");
            System.out.println("id:" + user.getId());
            System.out.println("name:" + user.getName());
            System.out.println("count:" + user.getCount());
        }  catch (ClassNotFoundException e) {
            e.printStackTrace();
        }  catch (IOException e) {
            e.printStackTrace();
        }
        finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

    }

控制它輸出:count明明被賦值爲20,但是反序列化後輸出爲0,說明static是不會參數序列化的,跟transient類似。最終在反序列化過程中會被初始化爲默認值(基本數據類型爲0,對象引用爲null,boolean爲false)

 

2、在序列化對象時,如果該對象中有引用對象域名,那麼也要要求該引用對象是可實例化的。如序列化User實例,其中引用了Employee實例,那麼也需要對Employee進行可序列化操作,否則會報錯: java.io.NotSerializableException 

User增加對Employee引用:

 /** 對外引用其它對象,如果序列化該實例,則該對象實例也必須能實例化(implement Serializable) */
    public Employee employee = new Employee(1, "Java programmer");

 Employee不實現序列化:

public class Employee{
private int code;
    private String position;

    public int getCode() {
        return code;
    }

    public void setCode(int code) {
        this.code = code;
    }

    public String getPosition() {
        return position;
    }

    public void setPosition(String position) {
        this.position = position;
    }

    public Employee(int code, String position) {
        this.code = code;
        this.position = position;
    }

    @Override
    public String toString() {
        return "Employee{" +
                "code=" + code +
                ", position='" + position + '\'' +
                '}';
    }
}

測試類:

/**
 * @author jian
 * @date 2019/4/5
 * @description 測試序列化
 */
public class SeriablizableTest {

    public static void main(String[] args) {
        User user = new User(1, "lijian");
        serializeUser(user);
        deserializeUser();

    }

    /**
     * 使用writeObject方法序列化
     *
     * @param user
     */
    private static void serializeUser(User user) {
        ObjectOutputStream outputStream = null;
        try {
            // 創建對象輸出流, 包裝一個其它類型目標輸出流,如文件流
            outputStream = new ObjectOutputStream(new FileOutputStream("D:\\user.txt"));
            // 通過對象輸出流的writeObject方法將對象user寫入流中
            outputStream.writeObject(user);
            System.out.println("user序列化成功!");
        } catch (NotSerializableException e) {
            System.out.println("user引用employee對象域序列化失敗");
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (outputStream != null) {
                try {
                    outputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    private static void deserializeUser() {
        User user = null;
        Employee employee = null;
        int id = 0;
        ObjectInputStream inputStream = null;
        try {
            // 創建對象輸出流, 包裝一個其它類型目標輸出流,如文件流
            inputStream = new ObjectInputStream(new FileInputStream("D:\\user.txt"));
            // 通過對象輸出流的writeObject方法將對象user寫入流中
            user = (User)inputStream.readObject();
            System.out.println("user引用employee對象域反序列化成功");
            System.out.println("user反序列化成功:" + user);
        } catch (WriteAbortedException e) {
            System.out.println("user引用employee對象域反序列化失敗");
        }  catch (ClassNotFoundException e) {
            e.printStackTrace();
        }  catch (IOException e) {
            e.printStackTrace();
        }
        finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

    }
}

 

控制檯輸出結果:

要解決這樣的問題,要麼將 Employee implement Serializable ,要麼對Employee對象實例transient修飾: public transient Employee employee = new Employee(1, "Java programmer"); 。但是需要注意的是序列化過程會對transient修飾的域初始化爲默認值(對象引用爲null,基本數據類型爲0,boolean爲false),所以執行以上代碼會出現 java.lang.NullPointerException 

 

3、默認序列化的過程可能消耗大量內存空間和時間,甚至可能會引起棧溢出:因爲第二條的原因,如果一個類中大量存在引用對象域,並且都需要實現序列化,那麼整個序列化過程可能會很消耗時間,在通信傳輸過程中更是如此,同時序列化後的字節流需要足夠大的內存。

三、提高序列化的安全性

1、編寫readObject提供安全性與約束性

  即使確定了默認的序列化形式是合適的,通常還必須提供一個readObject方法以保證約束關係和安全性。readObject方法相當於另一個共有構造器(可以認爲是用“字節流作爲唯一參數”的構造器)跟其它構造器一樣,它也要求同樣的所有主要事項:構造器必須檢查參數的有效性,必要時對參數進行保護性拷貝等。readObject如果沒有做到,那麼對於攻擊者來說違反這個類的約束條件相對就比較簡單了,如果對一個人工仿造的字節流(人工修改從實例序列後的字節流)時,readObject產生的對象會違反所屬類的約束條件。

  1)爲了解決這個問題,User中需要提供了readObject方法,該方法首先調用defalutReadObject,然後檢查被反序列化之後的對象的有效性,如果有效性檢查失敗,readObject方法就會拋出InvalidObjectException異常,使反序列過程不能成功。

private void readObject(ObjectInputStream inputStream)
            throws IOException, ClassNotFoundException {
        inputStream.defaultReadObject();
}

  User中的構造器中已對參數name約束爲不能爲null

public User(int id, String name) {
        // 約束條件name不能爲null或空
        if (name == null || StringUtils.isEmpty(name)) {
            throw new NullPointerException("name is null or empty");
        }
        this.id = id;
        this.name = name;
}

  2)那麼readObject中也應該對其name進行約束,否則人工僞造的字節流很容易通過readObject構造出沒有任何約束的對象實例,造成安全隱患。

private void readObject(ObjectInputStream inputStream)
            throws IOException, ClassNotFoundException {
        inputStream.defaultReadObject();

        // 約束條件name不能爲null或空
        if (name == null || StringUtils.isEmpty(name)) {
            throw new NullPointerException("name is null or empty"); 
     }
}

  儘管以上兩種修正已經有效地避免攻擊者創建無效的User實例,但是還有一種情況通過僞造字節流可以創建可變的User實例:比如User中增加Date對象引用birthday私有域,然後通過附加僞造字節流指向該birthday引用,攻擊者從ObjectInputStream中讀取User實例,然後讀取附加後面的惡意Date引用,通過該Date引用就可以能夠訪問User對象內部私有Date域所引用的對象,從而改變User實例。

 

代碼如下:

public class MutableUser {

    public User user;
    public Date birthday;

    public MutableUser(){
        try {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectOutputStream out = new ObjectOutputStream(bos);
            // 字節流有效的User實例開頭,然後附加額外的引用
            out.writeObject(new User(new Date()));
            // 假設這是惡意的二進制,即附加惡意對象引用Date
            byte[] ref = {0x71, 0, 0x7e, 0 ,5};
            bos.write(ref);
            // 攻擊者從ObjectInputStream中讀取User實例,然後讀取附加在後面的“惡意編制對象引用Date”
            ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
            user = (User) in.readObject();
            birthday = (Date) in.readObject();

        } catch (Exception e) {
      
        }
    }

    public static void main(String[] args) {
        MutableUser mutableUser = new MutableUser();
        User user = mutableUser.user;
        Date birthday = mutableUser.birthday;
        // 攻擊者修改User內部birthday私有域,年份更改爲2018
        birthday.setTime(2018);
        System.out.println(user);
    }
}

注:以上代碼運行不了,只會加以解釋說明而已,具體可以查看《Effective Java》中的代碼舉例

爲了解決此問題,提出第三個安全措施

 3)當一個對象被反序列化時,客戶端不應該擁有對象的引用,如果哪個域包含了這樣的對象引用,如果包含了私有的域(組件),就必須要保護性拷貝(非final域):當User對象在客戶端MutableUser反序列化時,客戶端擁有 了不該擁有的User私有域Date引用birthday,所以應該在readObject對birthday進行拷貝:

 private void readObject(ObjectInputStream inputStream)
            throws IOException, ClassNotFoundException {
        inputStream.defaultReadObject();
        // 保護性拷貝birthday
        birthday = new Date(birthday.getTime());
        // 約束條件name不能爲null
        if (name == null || StringUtils.isEmpty(name)) {
            throw new NullPointerException("name is null");
        }
    }

 

總結:

  1)使用readObject其實就跟正常無參數的構造器一樣,該滿足的約束需要滿足,同時必要時進行保護性拷貝。

  2)反序列化過程最終會調用readObject方法,如下是一個異常棧的調用關係(代碼中故意讓readObject方法拋異常):deserialize---->ObjectInputStream.readObject----->ObjectInputStream.readObject0----->......User.readObject

  

 

2、使用readResolve增強單例

  但是如果Sinleton類實現了序列化,那麼它不再是一個Singleton,無論該類使用了默認的序列化形式,還是自定義的序列化形式,還是是否提供顯式的readObject方法都沒關係。任何一個readObject方法,不管是顯式還是默認的,它都會返回一個新建的實例,這個新建的實例不同於該類初始化時創建的實例。

  簡單的Singleton:

public class Singleton {

    private static Singleton INSTANCE= new Singleton();
    private Singleton(){};
  .....
}

  readResolve特性允許使用readObject創建實例代替另一個實例,如果一個類定義了readResolve方法,並且具備正確的聲明,那麼在反序列化的之後,新建的readResolve方法就會被調用,然後返回的對象引用將被返回,取代新建的對象。

public class Singleton implements Serializable {

    private static Singleton INSTANCE= new Singleton();
    private Singleton(){};
    
    private Object readResolve(){
        return INSTANCE;
    }
}

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章