推測性載入強化

Spectre 變種 #1 緩解技術

作者:Chandler Carruth - chandlerc@google.com

問題陳述

最近,Google Project Zero 和其他研究人員發現,透過利用現代 CPU 中的推測執行,可以造成資訊洩露漏洞。這些攻擊目前分為三種變種

  • GPZ 變種 #1(又稱 Spectre 變種 #1):邊界檢查(或謂詞)繞過

  • GPZ 變種 #2(又稱 Spectre 變種 #2):分支目標注入

  • GPZ 變種 #3(又稱 Meltdown):惡意資料快取載入

如需更多詳細資訊,請參閱 Google Project Zero 部落格文章和 Spectre 研究論文

  • https://googleprojectzero.blogspot.com/2018/01/reading-privileged-memory-with-side.html

  • https://spectreattack.com/spectre.pdf

GPZ 變種 #1 的核心問題在於,推測執行使用分支預測來選擇推測性執行的指令路徑。此路徑使用可用資料進行推測性執行,並可能從記憶體載入資料,並透過各種側通道洩漏載入的值,即使推測執行因不正確而被撤消,這些側通道仍然存在。錯誤預測的路徑可能導致程式碼使用在正確執行中永遠不會出現的資料輸入執行,從而使針對惡意輸入的檢查失效,並允許攻擊者使用惡意資料輸入洩漏秘密資料。以下是一個範例,摘錄並簡化自 Project Zero 論文

struct array {
  unsigned long length;
  unsigned char data[];
};
struct array *arr1 = ...; // small array
struct array *arr2 = ...; // array of size 0x400
unsigned long untrusted_offset_from_caller = ...;
if (untrusted_offset_from_caller < arr1->length) {
  unsigned char value = arr1->data[untrusted_offset_from_caller];
  unsigned long index2 = ((value&1)*0x100)+0x200;
  unsigned char value2 = arr2->data[index2];
}

攻擊的關鍵是使用 untrusted_offset_from_caller 呼叫此函數,當分支預測器預測它在邊界內時,該值將遠遠超出邊界。在這種情況下,if 的主體將被推測性地執行,並且可能將秘密資料讀取到 value 中,並在對應存取被用於填充 value2 時,透過快取時序側通道洩漏該資料。

高階緩解方法

雖然目前正在積極尋求幾種方法來緩解特別危險的軟體(最值得注意的是各種作業系統核心)中的特定分支和/或載入,但這些方法需要手動和/或靜態分析輔助審查程式碼,並明確修改原始碼以應用緩解措施。它們不太可能很好地擴展到大型應用程式。我們提出了一種全面的緩解方法,可以自動應用於整個程式,而不是透過手動修改程式碼。雖然這可能會導致很高的效能成本,但某些應用程式可能很適合採用這種效能/安全性的權衡。

我們提出的具體技術是使用無分支程式碼檢查載入,以確保它們沿著有效的控制流程路徑執行。請考慮以下表示使用謂詞保護潛在無效載入的核心思想的 C 語言偽程式碼

void leak(int data);
void example(int* pointer1, int* pointer2) {
  if (condition) {
    // ... lots of code ...
    leak(*pointer1);
  } else {
    // ... more code ...
    leak(*pointer2);
  }
}

這將轉換為類似於以下內容的程式碼

uintptr_t all_ones_mask = std::numerical_limits<uintptr_t>::max();
uintptr_t all_zeros_mask = 0;
void leak(int data);
void example(int* pointer1, int* pointer2) {
  uintptr_t predicate_state = all_ones_mask;
  if (condition) {
    // Assuming ?: is implemented using branchless logic...
    predicate_state = !condition ? all_zeros_mask : predicate_state;
    // ... lots of code ...
    //
    // Harden the pointer so it can't be loaded
    pointer1 &= predicate_state;
    leak(*pointer1);
  } else {
    predicate_state = condition ? all_zeros_mask : predicate_state;
    // ... more code ...
    //
    // Alternative: Harden the loaded value
    int value2 = *pointer2 & predicate_state;
    leak(value2);
  }
}

其結果應該是,如果 `if (condition) {` 分支預測錯誤,則存在一個依賴於條件的 *資料* 相依性,用於在透過指標載入之前將任何指標歸零,或將所有載入的位元歸零。儘管這種程式碼模式可能仍然會被推測性地執行,但可以防止 *無效的* 推測性執行洩漏記憶體中的秘密資料(但請注意,這些資料可能仍然以安全的方式被載入,並且記憶體的某些區域被要求不能存放秘密,詳情請參閱下面的限制)。這種方法只要求底層硬體能夠實現暫存器值的無分支且不可預測的條件更新。所有現代架構都支援這一點,事實上,這種支援對於正確實現固定時間加密原語是必要的。

這種方法的關鍵特性

  • 它並非阻止任何特定的旁路攻擊管道運作。這一點很重要,因為潛在的旁路攻擊管道數量未知,而且我們預計會繼續發現更多。相反,它從一開始就防止了對秘密資料的觀察。

  • 它會累積謂詞狀態,即使面對巢狀的 *正確* 預測控制流程也能提供保護。

  • 它會跨函式邊界傳遞此謂詞狀態,以提供 程序間保護

  • 在強化載入地址時,它會使用 *破壞性* 或 *不可逆* 的地址修改方式,以防止攻擊者使用攻擊者控制的輸入來反轉檢查。

  • 它不會完全阻止推測性執行,而只是防止 *錯誤* 推測的路徑從記憶體中洩漏秘密(並會暫停推測,直到可以確定為止)。

  • 它是完全通用的,除了能夠進行無分支的條件資料更新和缺乏值預測之外,它沒有對底層架構做出任何基本假設。

  • 它不要求程式設計師使用靜態原始碼註解或容易受到變種 #1 風格攻擊的程式碼來識別所有可能的秘密資料。

這種方法的限制

  • 它需要重新編譯原始碼以插入強化指令序列。只有以這種模式編譯的軟體才能受到保護。

  • 效能在很大程度上取決於特定架構的實作策略。我們在下面概述了一個潛在的 x86 實作,並描述了它的效能。

  • 它無法防禦已經從記憶體載入並駐留在暫存器中,或透過非推測性執行中的其他旁路攻擊管道洩漏的秘密資料。處理此類資料的程式碼(例如,加密例程)已經使用固定時間演算法和程式碼來防止旁路攻擊。此類程式碼還應遵循 這些準則 清除暫存器中的秘密資料。

  • 為了獲得合理的效能,許多載入可能不會被檢查,例如具有編譯時期固定地址的載入。這主要包括在編譯時期常數偏移量上的全域和區域變數存取。需要這種保護並有意儲存秘密資料的程式碼必須確保用於秘密資料的記憶體區域一定是動態映射或堆積配置。這是一個可以調整的領域,可以以效能為代價提供更全面的保護。

  • 強化載入 仍然可能從 *有效的* 地址載入資料,如果不是 *攻擊者控制的* 地址。為了防止這些載入讀取秘密資料,地址空間的低 2GB 以及任何可執行頁面上下 2GB 的區域都應該受到保護。

致謝

  • 透過數據追蹤推測錯誤並標記指標以阻止推測性載入的核心概念,是在 HACS 2018 年 Chandler Carruth、Paul Kocher、Thomas Pornin 和其他幾個人的討論中發展出來的。

  • 遮蔽已載入位元的核心概念,是 Jann Horn 在報告這些攻擊時所建議的原始緩解措施的一部分。

間接分支、呼叫和返回

可以使用變種 #1 類型的錯誤預測來攻擊條件分支以外的控制流程。

  • 對虛擬方法的熱門呼叫目標的預測可能會導致在使用預期類型時推測性地執行它(通常稱為「類型混淆」)。

  • 由於預測,可能會推測性地執行熱門案例,而不是針對以跳轉表實作的 switch 語句執行正確的案例。

  • 從函數返回時,可能會錯誤地預測熱門的通用返回地址。

這些程式碼模式也容易受到 Spectre 變種 #2 的攻擊,因此最好在 x86 平台上使用 retpoline 來減輕影響。當使用像 retpoline 這樣的緩解技術時,推測根本無法透過間接控制流程邊緣進行(或者在已填入 RSB 的情況下無法錯誤預測),因此它也受到保護,免受變種 #1 類型的攻擊。但是,某些架構、微架構或供應商並未使用 retpoline 緩解措施,並且在未來的 x86 硬體(Intel 和 AMD)上,預計由於基於硬體的緩解措施而變得不必要。

當不使用 retpoline 時,這些邊緣將需要獨立的保護,以防範變種 #1 類型的攻擊。用於條件控制流程的類似方法應該可以運作

uintptr_t all_ones_mask = std::numerical_limits<uintptr_t>::max();
uintptr_t all_zeros_mask = 0;
void leak(int data);
void example(int* pointer1, int* pointer2) {
  uintptr_t predicate_state = all_ones_mask;
  switch (condition) {
  case 0:
    // Assuming ?: is implemented using branchless logic...
    predicate_state = (condition != 0) ? all_zeros_mask : predicate_state;
    // ... lots of code ...
    //
    // Harden the pointer so it can't be loaded
    pointer1 &= predicate_state;
    leak(*pointer1);
    break;

  case 1:
    predicate_state = (condition != 1) ? all_zeros_mask : predicate_state;
    // ... more code ...
    //
    // Alternative: Harden the loaded value
    int value2 = *pointer2 & predicate_state;
    leak(value2);
    break;

    // ...
  }
}

核心概念保持不變:使用數據流驗證控制流程,並使用該驗證來檢查載入是否無法沿著錯誤推測的路徑洩漏信息。通常,這涉及跨邊緣傳遞此類控制流程的所需目標,並在之後檢查它是否正確。請注意,雖然很容易認為這減輕了變種 #2 的攻擊,但事實並非如此。這些攻擊針對的是不包含檢查的任意小工具。

變種 #1.1 和 #1.2 攻擊:「邊界檢查繞過存儲」

除了核心變種 #1 攻擊之外,還有一些技術可以擴展此攻擊。主要技術稱為「邊界檢查繞過存儲」,並在這篇研究論文中進行了討論:https://people.csail.mit.edu/vlk/spectre11.pdf

我們將分別分析這兩種變體。首先,變種 #1.1 的工作原理是在邊界檢查繞過後推測性地存儲在返回地址上。然後,CPU 在返回的推測執行期間最終使用此推測存儲,從而有可能將推測執行引導至二進制文件中的任意小工具。讓我們看一個例子。

unsigned char local_buffer[4];
unsigned char *untrusted_data_from_caller = ...;
unsigned long untrusted_size_from_caller = ...;
if (untrusted_size_from_caller < sizeof(local_buffer)) {
  // Speculative execution enters here with a too-large size.
  memcpy(local_buffer, untrusted_data_from_caller,
         untrusted_size_from_caller);
  // The stack has now been smashed, writing an attacker-controlled
  // address over the return address.
  minor_processing(local_buffer);
  return;
  // Control will speculate to the attacker-written address.
}

但是,可以通過像加固任何其他加載一樣加固返回地址的加載來減輕這種情況。這有時很複雜,因為例如 x86 會_隱式地_從堆棧中加載返回地址。但是,下面的實現技術專門用於通過使用堆棧指針在函數之間傳遞錯誤推測來減輕這種隱式加載。這還會導致錯誤推測具有無效的堆棧指針,並且永遠無法讀取推測性存儲的返回地址。請參閱下面的詳細討論。

對於變種 #1.2,攻擊者會推測性地存儲到用於實現間接呼叫或間接跳轉的 vtable 或跳轉表中。因為這是推測性的,所以即使將它們存儲在只讀頁面中,這通常也是可能的。例如

class FancyObject : public BaseObject {
public:
  void DoSomething() override;
};
void f(unsigned long attacker_offset, unsigned long attacker_data) {
  FancyObject object = getMyObject();
  unsigned long *arr[4] = getFourDataPointers();
  if (attacker_offset < 4) {
    // We have bypassed the bounds check speculatively.
    unsigned long *data = arr[attacker_offset];
    // Now we have computed a pointer inside of `object`, the vptr.
    *data = attacker_data;
    // The vptr points to the virtual table and we speculatively clobber that.
    g(object); // Hand the object to some other routine.
  }
}
// In another file, we call a method on the object.
void g(BaseObject &object) {
  object.DoSomething();
  // This speculatively calls the address stored over the vtable.
}

要減輕這種情況,需要強化這些位置的負載,或減輕間接調用或間接跳轉。這些方法中的任何一種都足以阻止調用或跳轉使用已被讀回的推測性存儲值。

對於這兩種情況,使用 retpoline 同樣足夠。一種可能的混合方法是對間接調用和跳轉使用 retpoline,同時依靠 SLH 來減輕返回。

另一種對這兩種情況都足夠的方法是強化所有推測性存儲。然而,由於大多數存儲並不重要,並且不會固有地洩漏數據,因此考慮到它所防禦的攻擊,預計這將非常昂貴。

實作細節

在特定架構和特定編譯器中,有許多複雜的細節會影響此技術的實作。我們討論了針對 x86 架構和 LLVM 編譯器的擬議實作技術。這些主要作為示例,因為其他實作技術也非常可行。

x86 實作細節

在 x86 平台上,我們將實作分解為三個核心組件:通過控制流程圖累積謂詞狀態、檢查負載以及檢查程序之間的控制轉移。

累積謂詞狀態

考慮以下基準 x86 指令,它測試三個條件,如果全部通過,則從內存加載數據並可能通過某些側通道洩漏數據

# %bb.0:                                # %entry
        pushq   %rax
        testl   %edi, %edi
        jne     .LBB0_4
# %bb.1:                                # %then1
        testl   %esi, %esi
        jne     .LBB0_4
# %bb.2:                                # %then2
        testl   %edx, %edx
        je      .LBB0_3
.LBB0_4:                                # %exit
        popq    %rax
        retq
.LBB0_3:                                # %danger
        movl    (%rcx), %edi
        callq   leak
        popq    %rax
        retq

當我們推測性地執行加載時,我們想知道是否有任何動態執行的謂詞被錯誤地推測。為了追蹤這一點,沿著每個條件邊緣,我們需要追蹤允許採用該邊緣的數據。在 x86 上,此數據存儲在條件跳轉指令使用的標誌寄存器中。在此控制流分支之後的兩個邊緣上,標誌寄存器保持活動狀態,並包含我們可以用來構建累積謂詞狀態的數據。我們使用 x86 條件移動指令來累積它,該指令也讀取狀態所在的標誌寄存器。眾所周知,這些條件移動指令在任何 x86 處理器上都不會被預測,這使得它們不易受到可能重新引入漏洞的錯誤預測的影響。當我們插入條件移動時,代碼最終看起來像這樣

# %bb.0:                                # %entry
        pushq   %rax
        xorl    %eax, %eax              # Zero out initial predicate state.
        movq    $-1, %r8                # Put all-ones mask into a register.
        testl   %edi, %edi
        jne     .LBB0_1
# %bb.2:                                # %then1
        cmovneq %r8, %rax               # Conditionally update predicate state.
        testl   %esi, %esi
        jne     .LBB0_1
# %bb.3:                                # %then2
        cmovneq %r8, %rax               # Conditionally update predicate state.
        testl   %edx, %edx
        je      .LBB0_4
.LBB0_1:
        cmoveq  %r8, %rax               # Conditionally update predicate state.
        popq    %rax
        retq
.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        ...

在這裡,我們通過將 %rax 清零來創建「空」或「正確執行」謂詞狀態,並通過將 -1 放入 %r8 來創建常量「錯誤執行」謂詞值。然後,沿著從條件分支出來的每個邊緣,我們執行一個條件移動,在正確執行時這將是一個空操作,但如果錯誤地推測,將用 %r8 的值替換 %rax。錯誤地推測三個謂詞中的任何一個都將導致 %rax 保留 %r8 中的「錯誤執行」值,因為我們在執行正確時保留輸入值,而不是覆蓋它。

現在,我們在每個基本塊的 %rax 中都有一個值,該值指示之前某個時候是否錯誤地預測了謂詞。我們已經安排好該值在下面用於強化負載時特別有效。

間接調用、分支和返回謂詞

在追蹤間接呼叫、分支和返回時,沒有類似的旗標可以使用。謂詞狀態必須透過其他方式累積。從根本上來說,這與 CFI 中提出的問題相反:我們需要檢查我們來自哪裡,而不是我們要去哪裡。對於函式區域性跳轉表,這可以透過在每個目的地(尚未實作,使用 retpolines)內測試跳轉表的輸入來輕鬆安排。

        pushq   %rax
        xorl    %eax, %eax              # Zero out initial predicate state.
        movq    $-1, %r8                # Put all-ones mask into a register.
        jmpq    *.LJTI0_0(,%rdi,8)      # Indirect jump through table.
.LBB0_2:                                # %sw.bb
        testq   $0, %rdi                # Validate index used for jump table.
        cmovneq %r8, %rax               # Conditionally update predicate state.
        ...
        jmp     _Z4leaki                # TAILCALL

.LBB0_3:                                # %sw.bb1
        testq   $1, %rdi                # Validate index used for jump table.
        cmovneq %r8, %rax               # Conditionally update predicate state.
        ...
        jmp     _Z4leaki                # TAILCALL

.LBB0_5:                                # %sw.bb10
        testq   $2, %rdi                # Validate index used for jump table.
        cmovneq %r8, %rax               # Conditionally update predicate state.
        ...
        jmp     _Z4leaki                # TAILCALL
        ...

        .section        .rodata,"a",@progbits
        .p2align        3
.LJTI0_0:
        .quad   .LBB0_2
        .quad   .LBB0_3
        .quad   .LBB0_5
        ...

在 x86-64(或其他具有所謂“紅色區域”的 ABI,該區域位於堆疊末端之外)上,返回具有一個簡單的緩解技術。這個區域在中斷和上下文切換時保證被保留,使得返回到當前程式碼所使用的返回地址保留在堆疊上並且可以讀取。我們可以在呼叫者中發出程式碼,以驗證返回邊緣沒有被錯誤預測。

        callq   other_function
return_addr:
        testq   -8(%rsp), return_addr   # Validate return address.
        cmovneq %r8, %rax               # Update predicate state.

對於沒有“紅色區域”的 ABI(因此無法從堆疊讀取返回地址),我們可以在呼叫之前將預期的返回地址計算到一個在呼叫中保留的暫存器中,並類似於上述方式使用它。

間接呼叫(以及在沒有紅色區域 ABI 的情況下返回)對傳播構成了最重大的挑戰。最簡單的技術是定義一個新的 ABI,以便將預期的呼叫目標傳遞給被呼叫的函式並在入口處檢查。不幸的是,新的 ABI 在 C 和 C++ 中部署起來非常昂貴。雖然目標函式可以透過 TLS 傳遞,但我們仍然需要複雜的邏輯來處理使用和不使用此額外邏輯編譯的函式混合(基本上,使 ABI 向後相容)。目前,我們建議在此處使用 retpolines,並將繼續研究緩解此問題的方法。

優化、替代方案和權衡

僅僅累積謂詞狀態就會產生巨大的成本。我們採用了幾項關鍵的優化措施來將其降至最低,並提供了各種替代方案,這些方案在生成的程式碼中呈現出不同的權衡。

首先,我們努力減少用於追蹤狀態的指令數量。

  • 我們不會在原始程式碼中的每個條件邊緣插入 cmovCC 指令,而是追蹤我們在進入每個基本區塊之前需要捕獲的每組條件旗標,並為這些旗標重用一個通用的 cmovCC 序列。

    • 當需要多個 cmovCC 指令來捕獲一組旗標時,我們可以進一步重用後綴。目前認為這不值得,因為成對的旗標相對較少,而它們的後綴則更少。

  • x86 中的一個常見模式是具有多個使用相同旗標但處理不同條件的條件跳轉指令。我們可以天真地將它們之間的每個分支視為一個“邊緣”,但这会导致更复杂的控制流程图。相反,我们累积分支所需的一组条件,并在单个分支边缘使用一系列 cmovCC 指令来跟踪它。

其次,我們透過為“壞”狀態分配一個暫存器來交換暫存器壓力以獲得更簡單的 cmovCC 指令。我們可以在條件移動指令中從記憶體中讀取該值,但是,這會產生更多的微操作,並且需要涉及載入-儲存單元。目前,我們將該值放入一個虛擬暫存器中,並允許暫存器分配器決定何時暫存器壓力足以將其溢出到記憶體並重新載入。

強化載入

當我們將謂詞累加成一個針對正確與錯誤推測的特殊值後,我們需要將其應用於加載,以確保它們不會洩漏秘密資料。主要有兩種技術可以做到這一點:我們可以強化加載的值以防止觀察,或者我們可以強化地址本身以防止加載發生。這些在效能上有顯著不同的取捨。

強化加載值

強化加載最吸引人的方法是遮罩掉所有加載的位元。關鍵的要求是,對於每個加載的位元,沿著錯誤推測的路徑,該位元始終固定為 0 或 1,而與加載的位元值無關。最明顯的實作方法是使用 and 指令,在錯誤推測路徑上使用全零遮罩,在正確路徑上使用全一遮罩,或者使用 or 指令,在錯誤推測路徑上使用全一遮罩,在正確路徑上使用全零遮罩。其他選項則不太吸引人,例如乘以零或多個移位指令。由於我們在下面將詳細說明的原因,我們最終建議您使用 or 和全一遮罩,使 x86 指令序列如下所示

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        movl    (%rsi), %edi            # Load potentially secret data from %rsi.
        orl     %eax, %edi

其他有用的模式可能是將加載摺疊到 or 指令本身中,但代價是需要進行暫存器到暫存器的複製。

部署這種方法存在一些挑戰

  1. x86 上的許多加載都被摺疊到其他指令中。將它們分開會增加非常顯著且昂貴的暫存器壓力,並導致效能成本過高。

  2. 加載可能不以通用暫存器為目標,需要額外的指令才能將狀態值映射到正確的暫存器類別,並且可能需要更昂貴的指令才能以某種方式遮罩該值。

  3. x86 上的旗標暫存器很可能處於活動狀態,並且難以廉價地保存。

  4. 加載的值比用於加載的指標和索引多得多。因此,強化加載結果所需的指令數量遠多於強化加載地址所需的指令數量(見下文)。

儘管存在這些挑戰,但強化加載結果允許加載繼續進行,因此對執行的總體推測/無序潛力影響極小。還有一些有趣的技術可以嘗試減輕這些挑戰,並至少在某些情況下使強化加載結果變得可行。但是,我們通常期望在強化加載值無利可圖的情況下,回退到下一種方法,即強化地址本身。

摺疊到資料不變操作中的加載可以在操作後進行強化

使這一點可行的第一個關鍵是認識到 x86 上的許多操作都是“資料不變的”。也就是說,由於特定的輸入資料,它們沒有(已知的)可觀察到的行為差異。這些指令在實作處理私鑰資料的密碼原語時經常使用,因為它們被認為不提供任何旁路通道。類似地,我們可以將強化推遲到它們之後,因為它們本身不會引入推測執行旁路通道。這會導致程式碼序列如下所示

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        addl    (%rsi), %edi            # Load and accumulate without leaking.
        orl     %eax, %edi

雖然加法發生在加載的(可能是秘密的)值上,但这不會洩漏任何資料,然後我們會立即對其進行強化。

加載值的強化延遲到資料不變運算式圖的下方

我們可以概括前面的想法,並將強化沿著運算式圖向下沉到盡可能多的資料不變操作中。這可以使用非常保守的規則來判斷某個東西是否是資料不變的。主要目標應該是使用單個強化指令來處理多個加載

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        addl    (%rsi), %edi            # Load and accumulate without leaking.
        addl    4(%rsi), %edi           # Continue without leaking.
        addl    8(%rsi), %edi
        orl     %eax, %edi              # Mask out bits from all three loads.
在 Haswell、Zen 和更新的處理器上強化載入值時保留旗標

遺憾的是,x86 上沒有任何有用的指令可以在不觸及旗標暫存器的情況下,將遮罩套用至所有 64 位元。但是,我們可以透過將值零擴展到完整的字組大小,然後使用 BMI2 shrx 指令向右移位至少原始位元數,來強化小於一個字組(32 位元系統上小於 32 位元,64 位元系統上小於 64 位元)的載入值。

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        addl    (%rsi), %edi            # Load and accumulate 32 bits of data.
        shrxq   %rax, %rdi, %rdi        # Shift out all 32 bits loaded.

因為在 x86 上,零擴展是免費的,所以這可以有效地強化載入值。

強化載入的位址

當強化載入值不適用時,通常是因為指令直接洩漏資訊(例如 cmpjmpq),我們會改為強化載入的「位址」,而不是載入值。這避免了透過展開載入或支付其他高昂成本來增加暫存器壓力。

要了解這在實務中的運作方式,我們需要檢查 x86 位址模式的確切語義,其一般形式如下:(%base,%index,scale)offset。這裡的 %base%index 是 64 位元暫存器,可以是任何值,並且可能由攻擊者控制,而 scaleoffset 是固定的立即值。scale 必須是 1248,而 offset 可以是任何 32 位元符號擴展值。然後,用於查找位址的確切計算方式為:%base + (scale * %index) + offset,採用 64 位元 2 補數模組化算術。

這種方法的一個問題是,在強化之後,`%base + (scale *

則一個大的正 offset 將索引到位址空間前 2 GB 的記憶體中。雖然這些偏移量不受攻擊者控制,但攻擊者可以選擇攻擊恰好具有所需偏移量的載入,然後成功讀取該區域中的記憶體。這顯著增加了攻擊者的負擔,並限制了攻擊範圍,但並未完全消除攻擊。為了完全阻止攻擊,我們必須與作業系統合作,以防止在位址空間的前 2 GB 中映射記憶體。

64 位元載入檢查指令

我們可以使用以下指令序列來檢查載入。在這些範例中,我們將 %r8 設定為特殊值 -1,該值將在推測錯誤的路徑中透過 cmov 指令覆蓋 %rax

單一暫存器位址模式

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        orq     %rax, %rsi              # Mask the pointer if misspeculating.
        movl    (%rsi), %edi

雙暫存器位址模式

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        orq     %rax, %rsi              # Mask the pointer if misspeculating.
        orq     %rax, %rcx              # Mask the index if misspeculating.
        movl    (%rsi,%rcx), %edi

這將導致接近零的負地址,或者導致 offset 將地址空間包裝回一個小的正地址。對於大多數作業系統來說,小的負地址在使用者模式下會出錯,但需要將高地址空間設定為使用者可存取的目標可能需要調整上述的確切順序。此外,低地址需要由作業系統標記為不可讀取,才能完全強化載入。

RIP 相對定址更容易被破解

有一種常用的定址模式語法難以檢查:相對於指令指標的定址。我們無法改變指令指標暫存器的值,因此我們面臨著一個更困難的問題,即強制 %base + scale * %index + offset 成為無效地址,而只能透過更改 %index 來實現。我們唯一的優勢是攻擊者也無法修改 %base。如果我們使用上述的快速指令序列,但只將其應用於索引,我們將始終存取 %rip + (scale * -1) + offset。如果攻擊者可以找到一個載入,而該載入的地址恰好指向機密數據,則他們可以存取它。但是,載入器和基礎程式庫也可以簡單地拒絕在程式中任何文字的 2GB 範圍內映射堆、數據段或堆疊,就像它可以保留地址空間的低 2GB 一樣。

旗標暫存器再次使一切變得困難

不幸的是,在 x86 上使用 orq 指令的技術存在一個嚴重的缺陷。使累積狀態變得容易的事情,即包含謂詞的旗標暫存器,在這裡會造成嚴重的問題,因為它們可能處於活動狀態並被載入指令或後續指令使用。在 x86 上,orq 指令會**設定**旗標,並會覆蓋那裡已有的任何內容。這使得將它們插入指令流變得非常危險。不幸的是,與強化載入值時不同,我們這裡沒有後備方案,因此我們必須有一個完全通用的方法。

生成這些序列時,我們必須做的第一件事是嘗試分析周圍的程式碼,以證明旗標實際上並未處於活動狀態或被使用。通常,它是由其他一些恰好設定旗標暫存器(就像我們的指令一樣!)的指令設定的,而沒有實際的依賴關係。在這些情況下,可以直接插入這些指令是安全的。或者,我們可以將它們移到前面,以避免覆蓋使用的值。

但是,這最終可能是不可能的。在這種情況下,我們需要在這些指令周圍保留旗標

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        pushfq
        orq     %rax, %rcx              # Mask the pointer if misspeculating.
        orq     %rax, %rdx              # Mask the index if misspeculating.
        popfq
        movl    (%rcx,%rdx), %edi

使用 pushfpopf 指令可以在我們插入的程式碼周圍儲存旗標暫存器,但代價很高。首先,我們必須將旗標儲存到堆疊中並重新載入它們。其次,這會導致堆疊指標被動態調整,需要使用框架指標來引用溢出到堆疊中的臨時變數等。

在新款 x86 處理器上,我們可以使用 lahfsahf 指令將溢位旗標以外的所有旗標儲存在暫存器中,而不是堆疊中。然後,我們可以使用 setoadd 將溢位旗標儲存和還原至暫存器中。結合起來,這將以與上述相同的方式儲存和還原旗標,但使用兩個暫存器而不是堆疊。在多數情況下,這仍然非常昂貴,儘管比 pushfpopf 稍微便宜一些。

Haswell、Zen 和更新版本處理器上的無旗標替代方案

從 Haswell 和 Zen 處理器上可用的 BMI2 x86 指令集擴展開始,就有一條不設定任何旗標的移位指令:shrx。我們可以使用它和 lea 指令來實現與上述程式碼序列類似的功能。但是,這些仍然稍微慢一些,因為在大多數現代 x86 處理器中,能夠發送移位指令的端口比 or 指令的端口要少。

快速、單一暫存器定址模式

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        shrxq   %rax, %rsi, %rsi        # Shift away bits if misspeculating.
        movl    (%rsi), %edi

這會將暫存器折疊為零或一,並且定址模式中除偏移量以外的所有內容都小於或等於 9。這意味著無法保證完整地址小於 (1 << 31) + 9。作業系統可能希望保護低地址空間的額外頁面來解決這個問題。

優化

這種方法的很大一部分成本來自於以這種方式檢查載入,因此努力優化這一點非常重要。但是,除了使_應用_檢查的指令序列變得高效(例如,通過避免 pushfqpopfq 序列)之外,唯一顯著的優化是在不引入漏洞的情況下檢查更少的載入。我們應用多種技術來實現這一目標。

不要檢查從編譯時常數堆疊偏移量載入的資料

我們在 x86 上通過跳過使用固定框架指標偏移量的載入檢查來實現此優化。

此優化的結果是,像重新載入溢出暫存器或存取全域欄位這樣的模式不會被檢查。這是一個非常顯著的效能提升。

不要檢查相依載入

此緩解策略有效的一個核心原因是它在載入的地址上建立了資料流檢查。但是,這意味著如果地址本身已經使用經過檢查的載入載入,則無需檢查相依載入,前提是它與經過檢查的載入位於同一個基本塊中,因此沒有其他謂詞保護它。請考慮以下程式碼

        ...

.LBB0_4:                                # %danger
        movq    (%rcx), %rdi
        movl    (%rdi), %edx

這將轉換為

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        orq     %rax, %rcx              # Mask the pointer if misspeculating.
        movq    (%rcx), %rdi            # Hardened load.
        movl    (%rdi), %edx            # Unhardened load due to dependent addr.

這不會檢查通過 %rdi 載入的資料,因為該指標依賴於已經檢查過的載入。

使用單一 lfence 保護大型、負載重的區塊

在區塊開始處使用單一 lfence 指令可能是有價值的,該區塊以大量需要獨立保護*並*需要強化載入地址的載入開始。然而,這在實務上不太可能獲利。強化造成的延遲需要超過在*正確*推測執行時 lfence 的延遲。但在這種情況下,lfence 的成本是完全損失推測執行(至少)。到目前為止,我們擁有的關於使用 lfence 的效能成本證據表明,很少(如果有)熱門程式碼模式會讓這種權衡變得合理。

破壞安全模型的誘人優化

我們考慮了幾種優化方案,但由於未能維護安全模型而沒有成功。其中一個特別值得討論,因為許多其他方案都將簡化為它。

我們想知道是否只有基本區塊中的*第一個*載入需要檢查。如果檢查按預期工作,它會形成一個無效的指標,甚至在硬體中不會進行虛擬地址轉換。它應該在處理的早期階段就會出錯。也許這會及時阻止事情發生,使推測錯誤的路徑無法洩漏任何秘密。這最終行不通,因為處理器基本上是無序的,即使在其推測域中也是如此。因此,攻擊者可能會導致初始地址計算本身停滯,並允許任意數量的無關載入(包括對秘密資料的攻擊性載入)通過。

程序間檢查

現代 x86 處理器可能會推測到被呼叫的函數中,並從函數推測到其返回地址。因此,我們需要一種方法來檢查在推測錯誤的謂詞之後發生的載入,其中載入和推測錯誤的謂詞位於不同的函數中。從本質上講,我們需要對謂詞狀態追蹤進行一些程序間概括。在函數之間傳遞謂詞狀態的一個主要挑戰是,我們希望不需要更改 ABI 或呼叫約定,以便使這種緩解措施更易於部署,並且進一步希望以這種方式緩解的程式碼可以輕鬆地與未以這種方式緩解的程式碼混合,並且不會完全失去緩解措施的價值。

將謂詞狀態嵌入到堆疊指標的高位元(們)中

我們可以使用允許強化指標的相同技術將謂詞狀態傳入和傳出函數。堆疊指標在函數之間很容易傳遞,我們可以測試其高位元是否已設定來檢測它是否由於推測錯誤而被標記。呼叫站點指令序列如下所示(假設推測錯誤的狀態值為 -1

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        shlq    $47, %rax
        orq     %rax, %rsp
        callq   other_function
        movq    %rsp, %rax
        sarq    63, %rax                # Sign extend the high bit to all bits.

這首先在呼叫函數之前將謂詞狀態放入 %rsp 的高位元中,然後在之後從 %rsp 的高位元中讀回它。當正確執行(推測性地或非推測性地)時,這些都是無操作的。當推測錯誤時,堆疊指標最終將為負數。我們安排它保持規範地址,但除此之外,將低位元保留原樣,以允許堆疊調整正常進行,而不會干擾這一點。在被呼叫的函數中,我們可以提取此謂詞狀態,然後在返回時重置它

other_function:
        # prolog
        callq   other_function
        movq    %rsp, %rax
        sarq    63, %rax                # Sign extend the high bit to all bits.
        # ...

.LBB0_N:
        cmovneq %r8, %rax               # Conditionally update predicate state.
        shlq    $47, %rax
        orq     %rax, %rsp
        retq

當所有程式碼都以此方式減輕風險時,這種方法是有效的,甚至可以容忍非常有限的未減輕程式碼(狀態將往返於未減輕的函數中,只是不會被更新)。但它確實有一些限制。將狀態合併到 %rsp 中需要付出代價,而且它不能將減輕的程式碼與未減輕的呼叫程式中的推測執行隔離開來。

使用這種形式的程序間減輕風險還有一個優勢:通過形成這些無效的堆棧指標地址,我們可以防止推測性返回成功地將推測性寫入的值讀取到實際的堆棧中。這首先通過在計算堆棧上返回地址的地址與我們的謂詞狀態之間形成數據依賴關係來實現。即使滿足條件,如果錯誤預測導致狀態中毒,則生成的堆棧指標將無效。

重寫內部函數的 API 以直接傳播謂詞狀態

(尚未實作。)

我們可以選擇使用內部函數直接調整其 API,以接受謂詞作為參數並返回它。對於進入函數,這可能比嵌入到 %rsp 中稍微便宜一些。

使用 lfence 來保護函數轉換

可以使用 lfence 指令來防止後續載入在所有先前錯誤預測的謂詞都已解析之前進行推測性執行。我們可以使用這個更廣泛的屏障來處理在函數之間執行的推測性載入。我們在入口塊中發出它來處理呼叫,並在每次返回之前發出它。這種方法還有一個優勢,即通過停止所有進入已減輕風險的函數的錯誤推測,無論呼叫程式中發生了什麼,都可以提供最強程度的減輕風險。但是,這種混合本身就具有更高的風險。這種混合是否足以減輕風險需要仔細分析。

不幸的是,實驗結果表明,對於某些程式碼模式,這種方法的效能開銷非常高。一個典型的例子是任何形式的遞迴求值引擎。當使用 lfence 減輕風險時,熱的、快速的呼叫和返回序列會表現出顯著的效能損失。僅此組件一項就可能使效能下降 2 倍或更多,即使僅在混合程式碼中使用,也使其成為一種令人不快的權衡。

使用內部 TLS 位置傳遞謂詞狀態

我們可以定義一個特殊的執行緒局部值來在函數之間保存謂詞狀態。這通過使用呼叫程式和被呼叫程式之間的旁路通道來傳達謂詞狀態,從而避免了直接的 ABI 影響。它還允許對狀態進行隱式零初始化,這允許未經檢查的程式碼成為第一個執行的程式碼。

但是,這需要在入口塊中從 TLS 載入,在每次呼叫和每次返回之前儲存到 TLS,以及在每次呼叫之後從 TLS 載入。因此,預計它會比在函數入口塊中使用 %rsp 甚至 lfence 更加昂貴。

定義新的 ABI 和/或呼叫約定

我們可以定義一個新的 ABI 和/或呼叫約定,以明確地傳入和傳出函數的謂詞狀態。如果所有替代方案的效能都不夠好,那麼這一點可能會很有趣,但它會使部署和採用變得更加複雜,而且可能不可行。

高階替代緩解策略

有許多完全不同的替代方法來緩解變種 1 攻擊。目前為止 大多數討論 都集中在通過手動重寫程式碼來緩解 Linux 核心(或其他核心)中已知的特定可攻擊組件,使其包含不易受攻擊的指令序列。對於 x86 系統,這是通過在可能洩漏資料的程式碼路徑中插入 lfence 指令,或通過重寫記憶體存取以對已知的安全區域進行無分支遮罩來實現的。在 Intel 系統上,lfence 將阻止推測性載入秘密資料。在 AMD 系統上,lfence 目前是一個無操作指令,但可以通過設定 MSR 使其成為發送序列化指令,從而避免程式碼路徑的錯誤推測(緩解 G-2 + V1-1)。

然而,這依賴於在程式碼中找到並列舉所有可能被攻擊以洩漏資訊的點。雖然在某些情況下,靜態分析可以有效地大規模執行此操作,但在許多情況下,它仍然依賴於人為判斷來評估程式碼是否可能易受攻擊。特別是對於審查不太詳細但仍然容易受到這些攻擊的軟體系統來說,這似乎是一個不切實際的安全模型。我們需要一種自動且系統的緩解策略。

條件邊緣上的自動 lfence

擴展現有手工編碼緩解措施的一種自然方法是簡單地在每個條件分支的目標和 fallthrough 目的地插入 lfence 指令。這確保了沒有任何謂詞或邊界檢查可以被推測性地繞過。然而,這種方法的效能開銷簡直是災難性的。然而,它仍然是這項工作之前已知的唯一真正的「預設安全」方法,並且是效能的基準。

解決這種效能開銷並使其更易於部署的一種嘗試是 MSVC 的 /Qspectre 開關。他們的技術是在編譯器中使用靜態分析,僅將 lfence 指令插入到有攻擊風險的條件邊緣。然而,初步 分析 表明,這種方法並不完整,只能捕獲一小部分與初始概念驗證非常相似的可攻擊模式。因此,雖然它的效能是可以接受的,但它似乎並不是一種足夠系統的緩解措施。

效能開銷

這種全面緩解措施的效能開銷非常高。然而,與之前推薦的方法(例如 lfence 指令)相比,它具有非常大的優勢。正如使用者可以限制 lfence 的範圍來控制其效能影響一樣,這種緩解技術的範圍也可以受到限制。

然而,了解獲得完全緩解基準的成本非常重要。 在此,我們假設目標是 Haswell(或更新版本)處理器,並使用所有技巧來提高效能(因此,將低 2GB 保持不受保護,並在程式中任何 PC 周圍 +/- 2GB)。 我們運行 Google 的微基準測試套件和使用 ThinLTO 和 PGO 構建的大型高度調整伺服器。 所有這些都使用 -march=haswell 構建,以允許訪問 BMI2 指令,並且基準測試在大型 Haswell 伺服器上運行。 我們使用基於 lfence 的緩解措施和這裡介紹的加載硬化收集數據。 總之,使用加載硬化進行緩解比使用 lfence 進行緩解快 1.77 倍,並且與普通程式相比,加載硬化的開銷可能在 10% 到 50% 之間,大多數大型應用程式的開銷在 30% 或更低。

基準測試

lfence

加載硬化

緩解後的加速

Google 微基準測試套件

-74.8%

-36.4%

2.5 倍

大型伺服器 QPS(使用 ThinLTO 和 PGO)

-62%

-29%

1.8 倍

以下是微基準測試套件結果的可視化,有助於顯示摘要中丟失的一些結果分佈。 y 軸是加載硬化相對於 lfence 的對數刻度加速比(向上 -> 更快 -> 更好)。 每個方框和觸鬚代表一個微基準測試,其中可能測量了許多不同的指標。 紅線標記中位數,方框標記第一個和第三個四分位數,觸鬚標記最小值和最大值。

Microbenchmark result visualization

我們還沒有關於 SPEC 或 LLVM 測試套件的基準測試數據,但我們可以努力獲取這些數據。 儘管如此,以上內容應該可以非常清楚地描述效能,並且特定基準測試不太可能揭示特別有趣的特性。

未來工作:細粒度控制和 API 整合

這種技術的效能開銷可能非常大,並且是使用者希望控制或減少的。 這裡有一些有趣的選項會影響所使用的實現策略。

一個特別吸引人的選項是允許在合理精細的粒度(例如在每個函數的基礎上)選擇加入和退出此緩解,包括智能處理內聯決策——可以防止受保護的程式碼內聯到不受保護的程式碼中,並且不受保護的程式碼將在內聯到受保護的程式碼中時受到保護。 對於只有有限的一組程式碼可以被外部控制的輸入訪問的系統,可以通過這種機制限制緩解範圍,而不會損害應用程式的整體安全性。 效能影響也可能集中在一些關鍵函數中,這些函數可以使用效能開銷較低的方法手動緩解,而應用程式的其餘部分則接收自動保護。

對於限制緩解範圍或手動緩解熱函數,都需要在不完全破壞緩解的情況下支持混合緩解和未緩解的程式碼。 對於第一個用例,特別希望在不受保護的程式碼推測錯誤期間調用緩解的程式碼時,緩解的程式碼保持安全。

對於第二個用例,將自動緩解技術連接到顯式緩解 API 可能很重要,例如 http://wg21.link/p0928(或任何其他最終的 API)中描述的 API,以便有一種乾淨的方法可以從自動緩解切換到手動緩解,而不會立即暴露漏洞。 但是,在 API 更好地建立之前,很難想出如何做到這一點的設計。 我們將在這些 API 成熟時重新審視這一點。