在 C++ 上改裝時間記憶體安全性
注意: 本文最初發表於 Google 安全部落格。
Chrome 中的記憶體安全性 是為了保護使用者而持續進行的努力。我們不斷地實驗不同的技術,以搶先惡意攻擊者一步。在這種精神下,本文將介紹我們使用堆積掃描技術改進 C++ 記憶體安全性的旅程。
讓我們從頭開始說起。在應用程式的生命週期中,其狀態通常以記憶體來表示。時間記憶體安全性指的是保證記憶體總是以其結構和型別的最新資訊來訪問的問題。不幸的是,C++ 無法提供此類保證。雖然現在對於相較 C++ 提供更強記憶體安全保證的其他語言有所需求,但像 Chromium 這樣的大型代碼庫在可預見的未來仍會使用 C++。
auto* foo = new Foo();
delete foo;
// foo 所指向的記憶體位置已不再表示 Foo 物件,因為該物件已被刪除(釋放)。
//foo->Process() 是一個釋放後使用的(UAF)操作。
foo->Process();
如上例中所示,foo
的記憶體被回收到底層系統後仍然被使用。過期指標被稱為 懸空指標,任何透過它的訪問都會導致釋放後使用(UAF)存取。在最理想的情況下,這類錯誤會導致明確定義的程式崩潰;在最壞的情況下,它們會引發微妙的破壞,並可能被惡意攻擊者利用。
在大規模代碼庫中,UAF 錯誤經常難以發現,因為物件的所有權會在多個元件間傳遞開來。這個普遍的問題如此廣泛,以至於直到今天,業界和學術界仍定期提出緩解策略。例子不勝枚舉:C++ 各種形式的智慧指標被用來更好地定義及管理應用層級的所有權;編譯器中的靜態分析用於防止問題代碼被編譯;當靜態分析無法生效時,類似於 C++ 記憶體檢測工具 的動態工具會攔截存取並在特定執行中捕捉問題。
不幸的是,Chrome 中使用 C++ 亦無法倖免,絕大多數的 高嚴重性安全漏洞都是 UAF 問題。為了在問題到達生產環境之前捕捉這些問題,採用了上述所有技術。除了常規測試,模糊測試器確保動態工具始終有新輸入可以處理。Chrome 甚至更進一步,採用了一種名為 Oilpan 的 C++ 垃圾回收器,雖然背離了 C++ 的常規語義,但在使用時提供了時間記憶體安全性。如果這種背離不合理,可以使用最近引入的一種新型智慧指標 MiraclePtr,它可以在訪問懸空指標時確定性崩潰。Oilpan、MiraclePtr 及基於智慧指標的解決方案需要對應用代碼作出重大修改。
在過去的十年間,另一種方法取得了一些成功:記憶體隔離。其基本思想是將顯式釋放的記憶體隔離起來,直到達到某種安全條件時才再次使用它。微軟已在其瀏覽器中部署了此項緩解技術的版本:2014 年 Internet Explorer 的 MemoryProtector 以及 2015 年(Chromium 之前的)Edge 的後繼者 MemGC。在 Linux 核心 中,採用了一種概率性方法,即最終回收這些記憶體。而近年來,學術界對此方法也有一些研究,例如 MarkUs 論文。本文其餘部分將總結我們在 Chrome 中試驗隔離和堆積掃描的旅程。
(此時,有人可能會問記憶標記在此情景中扮演什麼角色——請繼續閱讀!)
隔離和堆掃描的基本概念
通過隔離和堆掃描來確保時態安全的主要想法是,在證明不再有指向該記憶體的(懸垂)指標之前,不重複使用記憶體。為了避免更改 C++ 使用者代碼或其語義,提供 new
和 delete
的記憶體分配器會被攔截。
在調用 delete
時,記憶體實際上會被放入隔離區,此時應用程式無法重複使用隔離記憶體進行後續的 new
調用。在某個時刻會觸發堆掃描,該掃描會像垃圾收集器一樣掃描整個堆,以找到指向隔離記憶體塊的引用。那些沒有來自常規應用程式記憶體的引用的記憶體塊會被轉回分配器,用於後續的分配。
有多種加固選項,但都伴隨著性能成本:
- 使用特殊值(例如零)覆蓋隔離記憶體;
- 在掃描運行時暫停所有應用程式執行緒或並行掃描堆;
- 攔截記憶體寫入(例如通過頁面保護)來捕捉指標更新;
- 按字逐字掃描記憶體以查找可能的指標(保守處理)或為物件提供描述符(精確處理);
- 將應用程式記憶體分隔為安全和不安全分區,以排除某些物件,這些物件要麼性能敏感,要麼可以靜態證明可安全跳過;
- 除掃描堆記憶體外,還掃描執行堆棧;
我們將這些不同版本的算法集合稱為 StarScan [stɑː skæn],或簡稱 *Scan。
現實檢驗
我們將 *Scan 應用於渲染器進程中的非托管部分,並使用 Speedometer2 來評估性能影響。
我們嘗試了不同版本的 *Scan。為了儘可能減少性能開銷,我們評估了一種配置,它使用單獨的執行緒掃描堆,並避免在 delete
時及時清除隔離記憶體,而是在運行 *Scan 時清除隔離記憶體。我們選擇所有用 new
分配的記憶體進行測試,並且在第一個實現中不區分分配位置和類型,保持簡單。
請注意,所建議的 *Scan 版本並不完整。具體而言,惡意行為者可能通過將懸垂指標從未掃描記憶區域移到已掃描記憶區域,來利用掃描執行緒的競爭條件。修復此競爭條件需要跟踪對已掃描記憶體塊的寫入,例如通過使用記憶體保護機制攔截這些訪問,或在安全點停止所有應用程式執行緒完全禁止對物件圖的更改。無論哪種方式,解決此問題都伴隨著性能成本,並呈現出有趣的性能與安全權衡。請注意,這種攻擊並非通用性,對於所有的 UAF 都不起作用。像引言中所展示的問題不容易受到這類攻擊,因為懸垂指標並沒有被複製。
由於安全效益實際上取決於這些安全點的粒度,我們希望實驗最快版本,因此完全禁用了安全點。
在 Speedometer2 上運行我們的基本版本使總分下降了 8%。真糟糕……
這些開銷的原因是什麼呢?不出所料,堆掃描受記憶體限制並且相當昂貴,因為整個用戶記憶體必須由掃描執行緒遍歷並檢查引用。
為了減少性能降低,我們實現了各種優化以提高掃描速度。本質上,掃描記憶體最快的方法是不掃描,因此我們將堆分為兩類:可以包含指標的記憶體和可以靜態證明不包含指標的記憶體,例如字串。我們避免掃描不能包含任何指標的記憶體。需要注意的是,這類記憶體仍屬於隔離區,只是不被掃描。
我們擴展了此機制以覆蓋作為其他分配器後援記憶體的分配,例如由 V8 為優化 JavaScript 編譯器管理的區域記憶體。這類區域通常一次性廢棄(參見基於區域的記憶體管理),並且時態安全通過 V8 中的其他手段得以建立。
此外,我們應用了幾個微型優化來加速並消除計算:我們使用輔助表進行指標過濾;依賴 SIMD 加速記憶體受限的掃描迴圈;並最小化抓取和帶鎖定前綴指令的數量。
我們也改善了最初的排程算法,這個算法是在達到某個限制時就啟動堆掃描,我們通過調整掃描所花費的時間與實際執行應用程式代碼的時間進行了改良(參見 垃圾回收文獻 中的 mutator utilization)。
最終,該算法仍然受到記憶體的限制,而掃描仍是一個明顯昂貴的過程。這些優化將 Speedometer2 的回歸從 8% 減少到 2%。
儘管我們改善了原始的掃描時間,記憶體進入隔離區的事實增加了一個進程的總體工作集。為了進一步量化這種開銷,我們使用了一組選定的 Chrome 真實世界瀏覽基準來測量記憶體消耗。*Scan 在 renderer 進程中使記憶體消耗回歸了大約 12%。正是這種工作集的增加導致更多的記憶體被調入,這在應用程式的快速路徑中是顯而易見的。
硬體記憶體標記功能前來救援
MTE(記憶體標記擴展)是一種在 ARM v8.5A 架構上新增的擴展功能,用於幫助檢測軟體記憶體使用中的錯誤。這些錯誤可能是空間錯誤(例如,超出邊界的存取)或時間錯誤(使用過期記憶體)。其工作原理如下:每 16 字節的記憶體都被分配一個 4 位標記,指針也被分配一個 4 位標記。分配器負責返回一個標記與已分配記憶體標記相同的指針。載入和存儲指令會驗證指針與記憶體標記是否匹配。如果記憶體位置和指針的標記不匹配,則觸發硬體異常。
MTE 並不能提供對使用過期記憶體的確定性保護。由於標記位數有限,記憶體和指針的標記可能由於溢出而匹配。在只有 4 位標記的情況下,只需進行 16 次重新分配,標記就可能匹配。一個惡意行為者可能通過等待指針的標記重新與其指向的記憶體匹配來利用該溢出。
*Scan 可以用於解決這個棘手的邊界情況。在每次 delete
調用中,基礎記憶體塊的標記會由 MTE 機制進行遞增。大多數情況下,該塊的標記會在 4 位範圍內被遞增,並可重新分配。過期的指針將參考舊的標記,從而在解引用時可靠地崩潰。當標記溢出時,該物件會被放入隔離區並由 *Scan 處理。一旦掃描確認沒有更多指向該記憶體塊的過期指針,它將被返回給分配器。這可將掃描次數及其伴隨的成本減少約 16 倍。
下圖描述了這一機制。指向 foo
的指針最初具有 0x0E
的標記,這允許它再次遞增以分配 bar
。當調用 delete
針對 bar
時標記溢出,記憶體實際上進入了 *Scan 的隔離區。
我們拿到了一些支持 MTE 的實際硬體,並在 renderer 進程中重新進行了實驗。結果令人鼓舞,因為 Speedometer 上的回歸處於噪聲範圍內,我們僅在 Chrome 真實世界的瀏覽故事中增加了大約 1% 的記憶體佔用。
這是不是某種實際的 免費午餐?結果顯示,MTE 是有一定成本的,這些成本已經預先支付了。具體而言,PartitionAlloc(Chrome 的底層分配器)已經預設為所有啟用 MTE 的設備執行標記管理操作。此外,出於安全性原因,記憶體確實應該被零化以清除殘留數據。為了量化這些成本,我們在一些支持 MTE 的早期硬體原型上運行了實驗並進行了多種配置測試:
A. 禁用 MTE 且不進行記憶體清零; B. 禁用 MTE 但進行記憶體清零; C. 啟用 MTE 且不使用 *Scan; D. 啟用 MTE 並使用 *Scan;
(我們也意識到,MTE 具有同步和異步模式,這也會影響確定性和性能。為了本實驗,我們一直使用的是異步模式。)
結果表明,MTE 和記憶體清零有一定的成本,大約在 Speedometer2 上是 2%。需要注意的是,PartitionAlloc 和硬體尚未針對這些場景進行優化。實驗還表明,在 MTE 的基礎上添加 *Scan 並未帶來可測量的成本。