22. 頁表隔離 (PTI)

22.1. 概述

頁表隔離 (pti, 之前稱為 KAISER [1]) 是一種針對共享使用者/核心地址空間攻擊的對策,例如 “Meltdown” 方法 [2]

為了緩解此類攻擊,我們建立了一組獨立的頁表,僅在執行使用者空間應用程式時使用。 當透過系統呼叫、中斷或異常進入核心時,頁表切換到完整的 “核心” 副本。 當系統切換回使用者模式時,再次使用使用者副本。

使用者空間頁表僅包含最少量的核心資料:只有進入/退出核心所需的內容,例如入口/退出函式本身和中斷描述符表 (IDT)。 有一些嚴格來說不必要的東西被映射了,例如進入中斷時的第一個 C 函式(參見 pti.c 中的註釋)。

這種方法有助於確保當啟用 PTI 時,利用分頁結構的側通道攻擊不起作用。 可以在編譯時透過設定 CONFIG_MITIGATION_PAGE_TABLE_ISOLATION=y 來啟用它。 一旦在編譯時啟用,就可以透過 ‘nopti’ 或 ‘pti=’ 核心引數在啟動時停用它(參見 kernel-parameters.txt)。

22.2. 頁表管理

啟用 PTI 後,核心管理兩組頁表。 第一組與沒有 PTI 的核心中存在的單個集合非常相似。 這包括核心可以用於 copy_to_user() 之類的操作的完整使用者空間對映。

雖然是 _complete_,但核心頁表的使用者部分透過在頂層設定 NX 位而被削弱。 這確保了任何錯過的核心 -> 使用者 CR3 切換都會在執行其第一條指令時立即導致使用者空間崩潰。

使用者空間頁表僅對映進入和退出核心所需的核心資料。 此資料完全包含在 ‘struct cpu_entry_area’ 結構中,該結構放置在 fixmap 中,這使得該區域的每個 CPU 副本都具有編譯時固定的虛擬地址。

對於新的使用者空間對映,核心像正常一樣在其頁表中建立條目。 唯一的區別是核心在頂層 (PGD) 建立條目時。 除了在主核心 PGD 中設定條目之外,還在使用者空間頁表的 PGD 中建立條目的副本。

PGD 級別的這種共享本質上也共享頁表的所有較低層。 這留下了一個單一的、共享的使用者空間頁表集來管理。 一個要鎖定的 PTE,一組訪問位,髒位等...

22.3. 開銷

防止側通道攻擊非常重要。 但是,這種保護是有代價的

  1. 增加記憶體使用量

  1. 每個程序現在需要 order-1 PGD 而不是 order-0。 (每個程序額外消耗 4k)。

  2. ‘cpu_entry_area’ 結構的大小必須為 2MB,並且與 2MB 對齊,以便可以透過設定單個 PMD 條目來對映它。 這會在核心解壓縮後消耗近 2MB 的 RAM,但在核心映像本身中不佔用空間。

  1. 執行時成本

  1. 必須在中斷、系統呼叫和異常進入和退出時完成 CR3 操作,以在頁表副本之間切換(但是,可以在核心中斷時跳過它。) 對 CR3 的移動大約是幾百個週期,並且需要在每次進入和退出時進行。

  2. Percpu TSS 被對映到使用者頁表中,以允許 SYSCALL64 路徑在 PTI 下工作。 這沒有直接的執行時成本,但可以認為它打開了某些定時攻擊場景。

  3. 全域性頁面對於未對映到核心和使用者空間頁表中的所有核心結構都被停用。 MMU 的此功能允許不同的程序共享對映核心的 TLB 條目。 失去此功能意味著在上下文切換後會發生更多 TLB 未命中。 但是,實際的效能損失非常小,永遠不會超過 1%。

  4. 程序上下文識別符號 (PCID) 是一種 CPU 功能,允許我們在透過在更改頁表時在 CR3 中設定一個特殊的位來跳過重新整理整個 TLB。 這使得切換頁表(在上下文切換或核心進入/退出時)更便宜。 但是,在支援 PCID 的系統上,上下文切換程式碼必須從 TLB 中重新整理使用者和核心條目。 使用者 PCID TLB 重新整理會延遲到退出到使用者空間,從而最大限度地降低了成本。 有關詳細的 PCID/INVPCID 資訊,請參見 intel.com/sdm。

  5. 必須為每個新程序填充使用者空間頁表。 即使沒有 PTI,共享核心對映也是透過將頂層 (PGD) 條目複製到每個新程序中來建立的。 但是,使用 PTI 後,現在有 _兩個_ 核心對映:一個在核心頁表中,對映所有內容,另一個用於入口/出口結構。 在 fork() 時,我們需要複製兩個。

  6. 除了 fork() 時的複製之外,還必須對使用者空間 PGD 進行更新,只要對用於對映使用者空間的 PGD 執行 set_pgd() 時。 這確保了核心和使用者空間副本始終對映相同的使用者空間記憶體。

  7. 在不支援 PCID 的系統上,每個 CR3 寫入都會重新整理整個 TLB。 這意味著每個系統呼叫、中斷或異常都會重新整理 TLB。

  8. INVPCID 是一條 TLB 重新整理指令,允許重新整理非當前 PCID 的 TLB 條目。 某些系統支援 PCID,但不支援 INVPCID。 在這些系統上,只能從當前 PCID 的 TLB 中重新整理地址。 重新整理核心地址時,我們需要重新整理所有 PCID,因此單個核心地址重新整理需要在每次使用每個 PCID 時執行 TLB 重新整理 CR3 寫入。

22.4. 可能的未來工作

  1. 我們可以更加小心,除非 CR3 的值實際發生變化,否則實際上不寫入 CR3。

  2. 除了啟動時切換之外,還允許在執行時啟用/停用 PTI。

22.5. 測試

為了測試 PTI 的穩定性,建議使用以下測試程式,理想情況下並行執行所有這些操作

  1. 設定 CONFIG_DEBUG_ENTRY=y

  2. 在多個 CPU 上迴圈執行 tools/testing/selftests/x86/ 中的多個副本(排除 MPX 和 protection_keys),持續數分鐘。 這些測試經常發現核心入口程式碼中的角落情況。 通常,舊核心可能會導致這些測試本身崩潰,但它們永遠不應使核心崩潰。

  3. 在生成許多頻繁的效能監控非遮蔽中斷的模式下(頂部或記錄)執行 ‘perf’ 工具(參見 /proc/interrupts 中的 “NMI”)。 這會練習 NMI 進入/退出程式碼,已知該程式碼會觸發程式碼路徑中的錯誤,這些程式碼路徑不希望被中斷,包括巢狀的 NMI。 使用 “-c” 提高 NMI 的速率,使用兩個 -c 與單獨的計數器鼓勵巢狀 NMI 和較少確定性的行為。

    while true; do perf record -c 10000 -e instructions,cycles -a sleep 10; done
    
  4. 啟動 KVM 虛擬機器。

  5. 在支援 SYSCALL 指令的系統上執行 32 位二進位制檔案。 這是一條測試不足的程式碼路徑,需要額外的審查。

22.6. 除錯

PTI 中的錯誤會導致一些不同的崩潰簽名,值得在此處注意。

  • selftests/x86 程式碼的失敗。 通常是 entry_64.S 中更晦澀的角落之一的錯誤

  • 早期啟動中的崩潰,尤其是在 CPU 啟動時。 對映中的錯誤會導致這些。

  • 第一次中斷時的崩潰。 由 entry_64.S 中的錯誤引起,例如搞砸頁表切換。 也是由錯誤對映 IRQ 處理程式入口程式碼引起的。

  • 第一次 NMI 時的崩潰。 NMI 程式碼與主中斷處理程式分開,可能存在不影響正常中斷的錯誤。 也是由錯誤對映 NMI 程式碼引起的。 中斷入口程式碼的 NMI 必須非常小心,並且可能是執行 perf 時出現的崩潰的原因。

  • 第一次退出到使用者空間時核心崩潰。 entry_64.S 錯誤,或未能對映某些退出程式碼。

  • 中斷使用者空間的第一次中斷時崩潰。 entry_64.S 中返回到使用者空間的路徑有時與返回到核心的路徑分開。

  • 雙重故障:由於頁面錯誤導致的頁面錯誤導致核心堆疊溢位。 由在入口程式碼中接觸未 pti 對映的資料,或在呼叫未 pti 對映的 C 函式之前忘記切換到核心 CR3 引起。

  • 使用者空間在啟動早期發生段錯誤,有時表現為 mount(8) 無法掛載 rootfs。 這些往往是 TLB 失效問題。 通常是使錯誤的 PCID 失效,或者遺漏失效。