在 V8 中實現控制流完整性
控制流完整性(CFI)是一種旨在防止利用漏洞劫持控制流程的安全功能。其核心理念是,即使攻擊者成功破壞了進程的記憶體,額外的完整性檢查也可以阻止他們執行任意代碼。在這篇部落格中,我們將討論我們在 V8 中啟用 CFI 的工作。
Chrome 的高人氣使得其成為 0-Day 攻擊的重要目標,而我們發現大多數在野外的漏洞利用技術都針對 V8,以實現初始代碼執行。V8 的漏洞利用通常遵循類似的模式:初始漏洞導致記憶體損壞,但初始損壞通常是有限的,攻擊者需要找到方法在整個地址空間中任意讀取/寫入。這使得他們可以劫持控制流並運行 shellcode,執行漏洞利用鏈的下一步,試圖突破 Chrome 的沙盒保護。
為了防止攻擊者將記憶體損壞轉化為 shellcode 執行,我們正在 V8 中實現控制流完整性。在存在 JIT 編譯器的情況下,這尤其具有挑戰性。如果在運行時將數據轉化為機器碼,那麼必須確保被損壞的數據無法轉化為惡意代碼。幸運的是,現代硬體特性為我們提供了構建塊,使我們能夠設計出即使在處理損壞的記憶體時仍然堅固的 JIT 編譯器。
接下來,我們將把問題分為三個部分進行探討:
- 正向邊檢查(Forward-Edge CFI) 驗證間接控制流轉移的完整性,例如函數指針或虛表調用。
- 反向邊檢查(Backward-Edge CFI) 必須確保從堆棧中讀取的返回地址是有效的。
- JIT 記憶體完整性 驗證所有在運行時寫入可執行記憶體的數據。
正向邊檢查(Forward-Edge CFI)
我們希望利用兩種硬體特性來保護間接調用和跳轉:著陸點(landing pads)和指針認證(pointer authentication)。
著陸點(Landing Pads)
著陸點是可以用來標記有效分支目標的特殊指令。如果啟用,間接分支只能跳轉到著陸點指令,否則將引發異常。
例如,在 ARM64 上,著陸點可以通過在 Armv8.5-A 中引入的分支目標識別(BTI)功能實現。V8 已啟用了 BTI 支援。
而在 x64 上,著陸點是通過控制流執行技術(CET)功能的一部分間接分支跟踪(IBT)引入的。
然而,只是在所有間接分支的潛在目標上添加著陸點只為我們提供了粗粒度的控制流完整性,仍然給了攻擊者相當大的空間。我們可以通過添加函數簽名檢查(調用點的參數類型和返回類型必須與被調用函數匹配),以及在運行時動態移除不需要的著陸點指令進一步收緊限制。 這些功能是最近 FineIBT 提議的一部分,我們希望它能獲得操作系統的採用。
指針認證(Pointer Authentication)
Armv8.3-A 引入了指針認證 (PAC),可以將簽名嵌入指針上未使用的高位中。由於簽名會在指針使用前進行驗證,因此攻擊者無法提供任意偽造的指針用於間接分支。
反向邊檢查(Backward-Edge CFI)
為了保護返回地址,我們同樣希望利用兩種單獨的硬體特性:影子堆棧(shadow stacks)和 PAC。
影子堆棧(Shadow Stacks)
有了 Intel CET 的影子堆棧以及 Armv9.4-A 的守護控制堆棧(GCS),我們可以擁有一個專門存放返回地址的分離堆棧,該堆棧受到硬體防止惡意寫入的保護。這些功能對防止返回地址覆寫提供了非常強大的保護,但我們需要處理在優化/去優化和異常處理期間合法修改返回堆棧的情況。
指針認證(PAC-RET)
與間接分支類似,可以使用指針認證在返回地址被推入堆棧之前對其簽名。在 ARM64 CPU 上,這已經在 V8 中啟用。
使用硬體支持正向邊和反向邊 CFI 的一個副作用是可以將性能影響降到最低。
JIT 記憶體完整性
CFI 在 JIT 編譯器中的一個獨特挑戰是,我們需要在執行期間將機器碼寫入可執行記憶體。我們需要以一種方式保護記憶體,使得 JIT 編譯器被允許寫入,但攻擊者的記憶體寫入能力無法使用。一個天真的方法是臨時更改頁面權限以添加/移除寫入訪問權限。但這本質上是競爭條件,因為我們需要假設攻擊者可以從第二個執行緒並發觸發任意寫入。
每個執行緒的記憶體權限
在現代 CPU 上,我們可以擁有不同的記憶體權限視圖,這些視圖僅適用於當前執行緒,並且可以在用戶態中快速更改。 在 x64 CPU 上,這可以透過記憶體保護鍵(pkeys)實現,而 ARM 在 Armv8.9-A 中宣布了權限覆蓋擴展。 這使我們能夠細粒度地切換可執行記憶體的寫入訪問,例如透過為其標記一個單獨的 pkey。
現在,JIT 頁面不再對攻擊者可寫,但 JIT 編譯器仍需要將生成的程式碼寫入其中。在 V8 中,生成的程式碼存在於堆上的 AssemblerBuffers 中,這可能被攻擊者破壞。我們也可以以相同方式保護 AssemblerBuffers,但這只是在轉移問題。例如,我們還需要保護存放 AssemblerBuffer 指針的記憶體。 事實上,任何啟用這類受保護記憶體寫入訪問的程式碼都構成了 CFI 攻擊表面,因此需要非常防禦性地編寫。舉例來說,任何來自未受保護記憶體的指針寫入都會導致失敗,因為攻擊者可以使用它來破壞可執行記憶體。因此,我們的設計目標是將這些關鍵部分盡可能少,並保持其中的程式碼簡短且封閉。
控制流驗證
如果我們不想保護所有編譯器數據,可以假設它從 CFI 的角度來看是不可信的。在寫入可執行記憶體之前,我們需要驗證這不會導致任意控制流。這包括例如寫入的程式碼不執行任何系統呼叫指令或不跳轉到任意程式碼當中。當然,我們還需要檢查它不會更改當前執行緒的 pkey 權限。請注意,我們並沒有試圖阻止程式碼破壞任意記憶體,因為如果程式碼已被破壞,我們可以假設攻擊者已具備此能力。 為了安全地執行此類驗證,我們還需要在受保護記憶體中保存必要的元數據,並保護堆疊上的本地變數。 我們進行了一些初步測試,以評估此類驗證對性能的影響。幸運的是,驗證沒有出現在性能關鍵的程式碼路徑中,我們在 jetstream 或 speedometer 基準測試中未觀察到任何性能下降。
評估
攻擊性安全研究是任何緩解設計的重要組成部分,我們一直在嘗試發現繞過我們保護措施的新方法。以下是一些我們認為可能的攻擊範例以及解決它們的想法。
損壞的系統呼叫參數
如前所述,我們假設攻擊者可以與其他執行緒並行觸發一個記憶體寫入原語。如果另一個執行緒執行系統呼叫,部分參數可能是來自記憶體的攻擊者控制的值。雖然 Chrome 運行時具有限制性的系統呼叫過濾器,但仍然有一些系統呼叫可以被用來繞過 CFI 保護措施。
例如,Sigaction 是一個用於註冊信號處理程式的系統呼叫。在我們的研究中發現,在符合 CFI 的方式下,可以到達 Chrome 中的一個 sigaction 呼叫。由於參數是從記憶體中傳遞的,攻擊者可以觸發這個程式碼路徑,並將信號處理函數指向任意程式碼。幸運的是,我們可以輕鬆解決這個問題:要麼封鎖到 sigaction 呼叫的路徑,要麼在初始化後用系統呼叫過濾器封鎖它。
其他有趣的例子還包括記憶體管理的系統呼叫。例如,如果一個執行緒對損壞的指針調用 munmap,攻擊者可以解除只讀頁面的映射,隨後的 mmap 呼叫可以重新使用該地址,實際上為頁面添加了寫入權限。 一些操作系統已經提供了針對這一攻擊的保護措施,使用記憶體封印功能:Apple 平台提供了 VM_FLAGS_PERMANENT 標誌,而 OpenBSD 擁有一個 mimmutable 系統呼叫。
信號框架損壞
當核心執行信號處理程式時,它會將當前的 CPU 狀態保存到用戶態的堆疊上。一個第二個執行緒可能會破壞已保存的狀態,核心隨後將還原這個狀態。 如果信號框架資料是不可信的,似乎難以在用戶空間進行保護。在這種情況下,可能需要始終退出或使用已知的安全狀態覆蓋信號框架以返回。 更有前景的方法是通過每個執行緒的記憶體權限來保護信號堆疊。例如,使用 pkey 標記的 sigaltstack 可以防止惡意覆寫,但這需要內核在將 CPU 狀態保存到其上時暫時允許寫入權限。
v8CTF
以上只是我們正在解決的潛在攻擊的一些例子,我們也希望從安全社群中學到更多。如果您對此感興趣,試試您的運氣,參加最近推出的 v8CTF!利用 V8 並獲取獎金,專門針對 n-day 漏洞的漏洞利用明確列入範圍!