Java安全——語言本身的設計

尊淵發表於2016-06-14

內在安全機制

Java語言本身的安全機制是要保護記憶體資源——保證記憶體完整性,核心的安全特性要確保程式不能非法解析或修改駐留在記憶體中的機密資訊。從語言本身的設計角度考慮,就是要設計一組規則,在所構建的執行環境中,程式物件對記憶體的操作是經過定義的而不是任意的。

Java的強制約束

  1. 必須嚴格遵循訪問方法的要求。必須依照程式設計師制定的訪問級別進行相關方法的操作。如果不遵守則會產生異常。
  2. 不能訪問任意的記憶體地址。 Java沒有指標的概念,因此不會像C++一樣拿到一個指標強制轉換成記憶體指標,再利用查詢記憶體的方法得到本不該被獲取的資訊。
  3. 不能對final實體再做改動。
  4. 變數在初始化前不能使用。如果能讀取未初始化的變數,就等同於可以讀取任意記憶體地址。可以通過宣告一個大的物件而盜取主機記憶體中的資訊。
  5. 對於所有陣列訪問進行越界檢查。越界檢查除了可以減少程式錯誤意外,另一大貢獻就是安全保障。如果整數陣列後緊接著存放一個字元陣列,那麼通過整數陣列的越界寫,可以改變字元陣列的內容。
  6. 物件不能任意強制轉換為其他型別的物件。這種型別轉換的限制不僅在編譯器層面,在JVM裡也做了強限制,在繞過編譯器的轉換(比如把被轉換的物件標記為Object),JVM在執行時檢查也會丟擲ClassCastException

Java語言通過上面幾條約束,從語言層面保護了記憶體,不會允許程式在沒有獲得正確的訪問許可權時讀取到本不該訪問的記憶體。

序列化怎麼保障安全

這個題目不太好,因為序列化無法保證安全。Java允許通過實現java.io.Serializable介面來使記憶體物件序列化為一組位元組碼。這組位元組碼通過網路或者檔案等方式被其他地方的程式碼讀取並重建一個相同結構和內容的記憶體物件。這是Java的序列化和反序列化過程。作為一組位元組碼儲存在磁碟檔案或者資料流裡,原則上是允許被修改的。那說白了,序列化無法保障安全。但是考慮到Java語言要設計支援一組規則,至於序列化的安全,就交給使用者自己保障。

序列化的安全設計規則:

  1. 可序列化的物件必須實現java.io.Serializable介面,這相當於給使用者打預防針,告知其要考慮這個物件的安全問題。
  2. 序列化物件宣告transient變數,那麼該變數不被序列化。這相當於提供了保護資料的機制。

單單從這兩個層面看,已經是Java語言能做到的最大化問題了。再多,則影響到了序列化本身要實現的功能。那麼具體怎麼做呢?就像我們常規傳輸資料一樣——加密,你可以選擇對要序列化的變數和屬性加密,在反序列化時解密來增強安全。

安全規則實施

當然這裡講到的實施其實不僅僅針對安全,但是這些實施階段確實增強了安全性。換個角度,我們其實是看Java程式的執行過程如何對應這些語言設計規則。

編譯

編譯階段,可以避免“約束”中提到的前4條規則。陣列越界是執行時問題,而型別轉換,在編譯階段只能做到無關型別的轉換,比如下例:

    static class Foo {
        int x;
    }

    static class Bar {
        int x;
    }

    public static void main(String[] args) {
        Foo foo = new Foo();
        Bar bar = (Bar) foo;
    }

這時編譯器會提示“Cannot cast from Foo to Bar”。但是如果稍作修改,將Foo替換為Object,則編譯器無能為力。

    static class Foo {
        int x;
    }

    static class Bar {
        int x;
    }

    public static void main(String[] args) {
        Object foo = new Foo();
        Bar bar = (Bar) foo;
    }

連結

我們都知道,編譯只是做Class檔案,JVM的介入是要從load class開始的。而載入完class檔案後的第一件事就是連結。連結包括驗證、準備和解析這幾個步驟,而驗證階段,就是安全規則介入的一個階段。驗證階段就引入了JVM的位元組碼校驗器。

這個階段主要是來防禦惡意編譯器的攻擊,或者是一些無意的程式錯誤。比如一個類FooBar設計如下:

public class FooBar {

    public String val = "abcd";
}

類FooBarTest引用了這個類並更改了val變數:

public class FooBarTest {

    public static void main(String[] args) {
        FooBar foobar = new FooBar();
        foobar.val = "abcde";
        System.out.println(foobar.val);
    }

}

這時我們編譯並執行FooBarTest,會列印abcde。而現在如果去修復FooBar,將public改為private,然後只編譯FooBar,則不會發生錯誤。然而本來這時也編譯FooBarTest會導致編譯錯的。但是因為疏忽導致沒有這麼做,那麼如果沒有連結校驗,FooBarTest就是錯誤的執行了。然而因為位元組碼校驗器的存在,執行FooBarTest會丟擲

Exception in thread "main" java.lang.IllegalAccessError: tried to access field FooBar.val from class FooBarTest
    at FooBarTest.main(FooBarTest.java:5)

優雅的解決了這個問題。

位元組碼校驗器通過兩部分來實現這種校驗。首先,其作為一個微型的定理證明機,會證明class滿足下列條件(只做檢查):

  1. 類檔案格式正確;
  2. 不會基於final派生子類,也不會覆蓋final方法;
  3. 只有一個父類;
  4. 沒有對primitive型別資料進行非法轉型(int->Object);
  5. 物件之間沒有進行型別轉換;
  6. 運算元棧不會出現溢位。

接下來,在程式碼真正執行前進行校驗(稱為延遲校驗)。比如剛才舉例中提到的異常,就是校驗器在校驗欄位的訪問合法性時丟擲的。

執行

上面兩個階段檢查不了的規則,放到執行時檢查:陣列越界和型別轉換。執行時丟擲ArrayIndexOutOfBoundExceptionClassCastException

結語

Java語言層面的安全設計,本身也可以看出設計思路是彌補原有C和C++的部分短板而設計的。主要目標還是防止非法記憶體訪問。但是加入了這些限制也帶來了效能的缺失,這本身就是一個trade off。


相關文章