你想要多安全的記憶體安全?

Wu Yu Wei published on
11 min, 2096 words

記憶體安全通常是指針對軟體在處理記憶體存取時的措施手段,像是防止 Buffer Overflow 或 Dangling Pointer 等等。大多數擁有 Garbage Collection 的語言會在 Runtime 自動幫忙管理記憶體,而且不會暴露出太危險的抽象化型別給使用者直接使用。但在 C 和 C艹 則允許在沒有任何防護或檢查下,直接使用指標直接操控記憶體位置,我們通常就會稱這些語言是記憶體不安全的語言

為何我們需要討論記憶體安全?

我們之所以一而再再而三地討論並思考記憶體安全性的問題,無非是因為仍有非常多的軟體程式仍然是用記憶體不安全的語言寫出來的,且很多仍得繼續使用而造成維護的困難。這些很多是效能敏感的程式,無法負擔 Garbage Collector 在 Runtime 上產生的開銷。抑或是歷史的包袱,複雜的架構已經無法再改用其他語言重寫或維護。

當我們仔細分析像是 iOS/macOSAndroidWindowscURL 這些重要的專案,我們可以發現所有專案的 CVE 有 60 ~ 90% 全都是來自記憶體安全相關的錯誤。用比較負面的觀點來說的話,也就是會有這些記憶體安全的錯誤全都只是因為用了 C 和 C艹 來寫。

不過當然隨著時代在進步,C 和 C艹 的生態系也出現不少檢查工具鏈來改善,除了 Valgrind 能用來檢查記憶體洩漏,其他還有像是 AddressSanitizor、ThreadSanitizor 能來分別檢查記憶體位址與執行緒的安全。上面分析 iOS/macOS 的文章就可以看到,記憶體安全的錯誤比例有逐年遞減到約略 60% 左右。

但能視為通用程式語言並用在系統開發上的語言當然不是只有 C 和 C艹 而已。早在 1980 年就已經有 Ada 語言存在,並廣泛運用在美軍的系統程式上。而甚至有人開發過 Lisp 機器嘗試將 Lisp 作為該電腦的主要開發語言。

而近年來還有許多新興的程式語言能選擇,有些承襲了些傳統語言預設會去做的檢查並在編譯時就做好優化,有些甚至帶來了有趣的概念像是 Rust 的所有權與 Borrow Checker 來治本解決根本性的問題。許多諸如 Linux 的 Kernal Module、Amazon 的虛擬化技術 Firecracker、Google 的 AndroidChrome 甚至是 Fuchsia 以及 Windows,這些大型企業以及開源的專案都開始嘗試使用 Rust 來寫。

那該選擇哪些記憶體安全語言?

所有具有 Garbage Collection 的語言都算是記憶體安全的語言,盡管在執行時會有一定程度的開銷,但是它們可沒有想像中的那麼笨重緩慢不堪。JVM 和 .NET 都有十分強而有力的 Garbage Collector 實作,如果無法完全理解記憶體與抽象指標的概念,或是手動的記憶體管理反而是負擔的話,仍然非常推薦有 Garbage Collection 的語言像是 Elixir、F# 和 Racket 等等。

但很多時候不如在一開始就搞定問題,Garbage Collector 產生問題時要解決的時間與人力成本也不亞於非記憶體安全的語言,最常見就像 Java 的記憶體卡在老生代釋放不掉造成 Out of Memeory,且並行程式下也可能防止不了 Data Race。在 Oracle 和 Line 等企業中都得培養整個部門團隊去維護優化它們的 JVM。

回到不用 Garbage Collection 的語言,像是 C 和 C艹 也不是都沒在做檢查,除了上述提到的檢查工具,也有許多標準規範像是 MISRA C 來限制。不過這些畢竟不全是預設行為,對自己的要求標準高,其他的開發者或團隊可能並沒有跟你站在同個標準上。所以使用 Ada 和 Zig 這些預設在編譯時就會做諸多記憶體安全檢查的語言會是我推薦的選項。在先不討論 Allocator 的情況下,要在 Zig 做記憶體不安全的行為是要明確指定的,從 Build Mode 就可以略知該程式提供的保障到哪。

接下來我們還有 Rust,所有權和 Borrow Checker 能在編譯時釐清記憶體的歸屬問題,任何人都能夠寫出既迅速又安全的程式碼,讓魚與熊掌可以兼得。而所有權當然其實和 C艹 的 RAII 類似,且 Borrow Checker 在做的也是我們平時在寫程式時腦海裡就會勾勒出的藍圖。然而讓編譯器也一同檢查比你自己一個人檢查更清楚多了,有許多專案重寫後還發現原來沒找出的 bug。

這些語言有多安全?

最後我想我們可以來看看上述的語言相比到底多安全。確實使用 C 夠謹慎的話,的確能一掃所有記憶體安全問題。寫 Rust 使用 unsafe 也能把 Undefined Behaviour 玩得天花亂墜。但我們想知道在預設行為下,這些語言到底提供了怎麼樣的保障。Garbage Collection 的語言不用說,本文提到的多數問題應該都不必煩惱。至於 C、Zig 與 Rust 相比的話,我們有以下 Jamie Brandon 整理的列表:

問題CZigRust
Out-of-Bounds Heap Read/Write⭕️⭕️
Null Pointer Dereference⭕️¹⭕️¹
Type Confusion⭕️²⭕️
Integer Overflow⭕️❌³
Use After Free❌⁴⭕️
Double Free❌⁴⭕️
Invalid Stack Read/Write⭕️
Uninitialized Memory❌⁵⭕️
Data Race⭕️⁶

上面的列表我有更改一些,以確定真的都是預設的行為。在 Zig 我們討論的是 Debug / ReleaseSafe Mode,而 Rust 則是 Debug / Release Profile。而有附上數字的欄位就是接下來需要近一步解釋,或是某方面其實還是能視為安全的地方:

  1. 其實就是提供 Option 型別作為第一公民。

  2. 只有 Union 會造成型別混淆,雖然 Zig 可以用 enum 組合成 Tagged Union,但這必免不了持有指標數值的同時更改 Tag。

  3. Debug 時 Overflow 會 Panic,在 Release 則是會 Wrap。實際在做數值運算時通常都是使用型別提供的方法像是 wrapping_addoverflow_add 來確定想要的行為。

  4. Zig 的作者 Andrew Kelley 有解釋其標準函式庫提供的 Allocator 會防止這些常見的記憶體分配錯誤。C 也有像是 hardened_malloc 的概念,不過並沒有什麼標準函式庫,預設就能拿來使用。

  5. Zig 的安全檢查會檢測未初始化的記憶體,且使用的關鍵字是 undefined。在 Debug 時寫入的值是 0xaa 以方便做檢查。

  6. 所有權和 Borrow Checker 能完全杜絕 Data Race,我發現這是很少被提到的,在有些 Garbage Collection 的語言甚至沒有辦法完全防範。

最後有一些問題並沒有列上去,因為這在所有語言都存在,而且有時候是刻意為之的:

  • Race Condition:因為整個系統本質上就在互相爭奪資源的寫入與讀取,當程式需要依賴這些系統進行操作的話,難免會在非常極端的狀況出現微妙的問題,而且有時是非常難以重現的,這個我覺得值得獨立一個話題討論。

  • Memory Leak:記憶體洩漏不像其他行為會觸發到 Undefined Behaviour 進而造成程式錯誤,而且有時候是刻意洩漏的。在 Valgrind 的檢查中洩漏就被分成好幾種類別,有些其實使用者自己知道且仍在掌控中。