沈思豪, 解 達, 宋 威
1(中國科學院 信息工程研究所 信息安全國家重點實驗室, 北京 100093)
2(中國科學院大學網絡空間安全學院, 北京 101408)
內存安全問題一直是安全領域中經久不衰的問題[1]. 從緩沖區溢出漏洞的利用開始, 到現在返回導向編程技術(return oriented programming, ROP)[1,2]、數據導向編程技術(data oriented programming, DOP)[3]攻擊的應用, 基于內存安全問題的攻擊手段在不斷地更新迭代. 相應的, 也不斷有內存安全防御方案被提出. 早期的內存安全防御方案大多基于軟件, 如棧幀守護者(stack canary)[4], Ccured[5]等, 這些方案雖然能夠達到較好的防御效果, 但是性能開銷較大. 所以這些方案鮮有被應用于商業處理器架構之上. 近幾年來, 隨著RISC-V等開源處理器架構的興起, 陸續有新的硬件輔助內存安全方案被提出. 硬件支持使得防御方案的性能開銷降低. 商業處理器架構逐漸產生了接納硬件輔助安全方案的趨勢.
然而目前, 無論是在工業界還是學術界, 都缺少對處理器內存安全性能進行評估的測試集. 現有的測試集, 如RIPE[6], CBench[7]等, 雖然能夠在特定的內存安全性質上進行測試, 但是難以系統全面地反映處理器的內存安全狀況; 此外, 對于x86系列以外的指令集架構的支持也不盡如人意.
因此, 設計一個系統的, 完善的, 跨平臺的, 可拓展性強的硬件內存安全測試集就顯得尤為重要. 為了解決這個問題, 我們提出了一個可拓展的內存安全測試集框架, 在該框架下給出了大小為160個測例的初始版本測試集. 受限于可用硬件資源, 我們僅在x86-64和RISC-V64兩種指令集的不同平臺上對測試集進行了測試. 初始版本的測試集涵蓋了內存的時間、空間安全性, 內存訪問控制, 指針完整性和控制流完整性等幾個方面的安全特性.
本文工作可開源獲取, 初始版本測試集可以在GitHub上找到: https://github.com/comparch-security/cpu-sec-bench.
內存安全攻擊和防御手段其實處于相互競爭和相互促進的關系中.
類似C/C++的編程語言在底層實現中缺乏內存安全支持, 導致使用C/C++語言編寫的大量應用程序和動態庫中包含緩沖區溢出、指針釋放后使用(useafter-free, UAF)等內存時間和空間安全性漏洞.
代碼注入攻擊利用上述漏洞, 將預先構造好的惡意代碼寫入堆棧等用戶數據區中, 將棧幀中保存的返回地址等代碼指針的值覆蓋為惡意代碼起始地址, 從而實現惡意的代碼執行.
代碼注入攻擊要求攻擊者能夠將惡意代碼寫入用戶的可寫數據區, 劫持程序的控制流指向惡意代碼, 保證惡意代碼能夠執行. 針對這些前提條件, 一些經典的內存安全防御方案被提出: 棧幀守護者[8]在函數返回時檢測棧幀是否被緩沖區溢出覆蓋, 阻止攻擊者劫持棧幀中保存的返回地址; 數據執行保護(data execution prevention, DEP)設置頁面可寫不可執行屬性, 將可執行頁和可寫頁分開, 保證攻擊者即使成功將惡意代碼植入用戶可寫數據區, 也無法將其作為代碼執行; 地址空間隨機化(address space layout randomization, ASLR)通過使用位置無關代碼(place-independent code, PIC),在程序加載時將加載基地址隨機化, 達到將程序執行期間的數據和代碼地址隱藏的目的.
由于上述方案特別是數據執行保護與地址空間隨機化的性能開銷低, 防御效果顯著, 所以得到了大范圍部署. 直接的代碼注入攻擊幾乎失效了. 但攻擊者仍然能夠訪問和調用程序自身和動態庫提供的數據和代碼. 更復雜的內存安全攻擊手段被提出, 來繞過上述防御方案, 如返回導向編程技術、跳轉導向編程技術(jump-oriented programming, JOP)[9]、偽造對象導向編程技術(counterfeit-object oriented programming,COOP)[10]、數據導向編程技術等, 這些攻擊手段都有一個共同的特征, 即不注入外部代碼, 而是直接復用受害者程序和標準庫中的代碼完成攻擊. 此類攻擊稱為代碼復用攻擊.
返回導向編程技術是典型的代碼復用攻擊[11], 常用于構造指令執行序列, 完成對系統函數mprotect等的調用, 關閉數據執行保護. 跳轉導向編程技術類似于返回導向編程記錄, 但在技術細節上存在不同.
返回導向編程技術攻擊會改變程序的正常控制流,控制流完整性保護(control flow integrity, CFI)[12]通過在程序的間接跳轉前插入驗證代碼, 保證程序的所有間接跳轉都在程序編譯時靜態分析得到的控制流圖的范圍內. 根據控制流分析精度的不同, 控制流完整性保護具體可以分為細粒度和粗粒度兩類. 前者使用不同的特征值區分不同的合法跳轉地址; 后者則不對合法跳轉地址進行區分.
傳統的控制流完整性保護實現通過二進制分析完成, 重點保護間接跳轉指令, 但是并不對類型進行檢查,無法對多態類對象的虛函數表指針提供保護. 偽造對象導向編程技術利用了這一點, 通過在用戶數據區偽造對象, 修改虛函數表指針, 復用其它多態類提供的虛函數表, 通過調用一系列虛函數完成攻擊.
偽造對象導向編程技術攻擊無法通過簡單的二進制控制流分析實現的控制流完整性保護進行檢測, 需要結合類型實現控制流完整性保護進行防御. 典型的防御方案如代碼指針完整性保護(code pointer integrity,CPI)[13], 指針標記(pointer tagging), 指針審計(pointer authentication)等. 在x86架構下的GCC提供了虛函數表驗證(vtable verification, VTV)[14]特性, 用于驗證虛函數調用的正確性, 但是默認沒有被GCC開啟.
數據導向編程技術[3]通過修改控制程序分支執行的關鍵數據來進行攻擊, 達到劫持程序控制流的目的.對于一些涉及敏感系統調用的程序, 數據導向編程技術攻擊同樣可以完成關閉數據執行保護的操作.
應對上述高級攻擊的內存安全防御方案一般有兩種實現方式. 一種使用純軟件方式實現, 主要在標準庫、內核和編譯器的層面進行安全增強, 很少或不依賴硬件提供支持, 導致性能開銷過高, 難以被大范圍部署. 例如, 代碼指針完整性保護雖然能夠防御大部分的代碼復用攻擊, 但是由于插入的驗證代碼過多, 導致性能開銷過高, 一直沒有被主流編譯器(GCC, LLVM)采納.
另一種方式使用硬件輔助的方法提供內存安全支持. 相較于純軟件的實現方式, 硬件輔助的實現方式能夠對安全方案進行加速, 大幅降低安全方案實現的硬件開銷. 典型的硬件輔助防御方案如Intel的Intel內存保護拓展(memory protection extension, MPX), Intel 控制流保護拓展(controlflow enforcement technology,CET), ARM公司在ARM v8.3-A中增加的Arm指針審計(pointer authentication, PA), ARM v8.5-A中增加的內存標簽拓展(memory tagging extension, MTE).
為了比較不同處理器提供的內存安全性, 我們需要一套測試集. 這套測試集: 1)相對完善, 能夠量化處理器的內存安全性; 2)可移植性強, 能夠在多個處理器平臺上進行測試.
然而, 現在學術界普遍使用的測試集大多只關注內存安全的某個特定方面, 缺乏對內存安全的整體把握和評估. 以經典的內存安全測試集RIPE為例: RIPE測試集[6]是一系列緩沖區溢出攻擊的集合. 它應用廣泛, 常被用于測試控制流完整性保護防御方案. 每個測例都受5個變量控制: 緩沖區溢出位置、受攻擊的代碼指針類型、溢出攻擊的類型、惡意代碼和攻擊使用的函數. RIPE使用枚舉窮盡各個變量組合的方法對緩沖區溢出做了相對完善的測試, 但是對于緩沖區溢出以外的漏洞涉及不多, 所以不適合作為一個完善的處理器內存安全測試集.
在上述觀察的基礎上, 我們提出了一種處理器內存安全測試框架, 并開源了一個基于該框架的內存安全測試集. 內存安全測試集的初始版本包括160項測例, 覆蓋了內存的時空安全性(spatial and temporal safety)、內存訪問控制、指針完整性、控制流完整性等方面. 不同類型的漏洞及其相應的防御方案分別由對應的測例進行評測. 測試集已經在x86-64和RISCV64兩種指令集架構的幾個不同平臺上進行了評估.
為了將測試范圍限制在內存安全上, 我們作如下假設: 1)攻擊者能夠控制用戶程序的輸入, 惡意利用程序的內存安全漏洞如注入惡意代碼; 2)用戶程序包含可以被攻擊者所利用的內存漏洞, 攻擊者可以利用漏洞實現對程序任意地址的讀寫; 3)我們還假設攻擊者的目的是攻擊用戶程序空間內的數據, 而不是內核數據; 4)此外, 我們也不考慮側信道攻擊, 如緩存側信道、瞬態執行攻擊等.
處理器內存安全測試集以平臺為對象進行測試.平臺定義為測試集可以運行的環境. 它包括被測處理器以及運行于其上的操作系統. 操作系統包括一個內核以及若干運行時庫.
測試集假設: 內存安全是一系列內存安全性質的集合; 所有的內存漏洞和漏洞利用都是由于對某些內存安全性質缺少檢查, 使得內存中的值被惡意泄露或篡改. 根據上述假設, 對內存安全的評估可以被細化為一系列對內存安全性質的測試, 測試這些內存安全性質是否能夠被惡意利用. 如果一項測例成功完成執行,說明平臺對該測例對應的內存安全性質缺少檢查.通過的測例數和被安全檢查攔截的測例分布, 可以反映系統整體的內存安全性.
測試集主要關注那些能夠被硬件輔助安全方案保護的內存安全性質. 對于利用內存漏洞展開的攻擊, 我們分析該攻擊破壞或利用了哪些內存安全性質, 而不關注攻擊本身.
以緩沖區溢出攻擊為例. 緩沖區溢出攻擊是一種越過緩沖區邊界對值進行修改的行為. 該行為破壞了緩沖區不應該越界訪問的內存安全性質. PUMP[15],AArch64 Address Sanitizer等硬件輔助的安全機制能夠對緩沖區越界訪問提供防護. 所以, 對于緩沖區溢出,測試集測試幾類常見的訪問越界行為, 這些行為可能不構成完整的攻擊. 這些行為如果被攔截, 則說明平臺保護了緩沖區不應越界訪問的性質.
然而, 由于測試集本身也是軟件, 也需要在系統環境下運行, 所以單憑測試集本身難以區分一個內存檢查到底是通過硬件方式還是純軟件方式實現的. 所以需要保證測試覆蓋的內存檢查是由平臺而不是第三方安全軟件實現的. 例如, 二進制翻譯技術和專用的內核安全補丁也能提供內存安全防護, 但由于它們使用純軟件方式實現, 所以應當排除在測試范圍之外.
測例主要覆蓋內存的空間安全性、時間安全性、訪問控制、指針完整性、控制流完整性等5個方面.
3.3.1 空間安全性
空間安全性指內存訪問總是落在正確的數據邊界和程序的可見域內的性質. 任何數據邊界外或是可見域外的訪問都是不安全的. 典型的破壞空間安全性的攻擊是緩沖區溢出攻擊. 它是對緩沖區的越界訪問. 除了緩沖區以外, 棧幀、動態分配對象、全局變量、只讀數據等均存在越界訪問的風險.
緩沖區溢出按照溢出方向可以分為上溢和下溢;按照緩沖區位置可以分為堆上溢出和棧幀溢出. 噴射攻擊是溢出攻擊的一種特殊應用, 它將溢出位置和目標位置之間的全部內存都進行填充, 插入指向目標位置的跳轉代碼, 降低控制流劫持的難度.
對于緩沖區溢出的檢測和防御方法包括內存檢測器(address sanitizer, Asan)[16]、內存標記[17]和重型指針(fat pointer)[18]; 對于棧幀越界訪問, 使用重型指針在棧幀粒度上保持數據完整性[19]、在棧幀之間填充字節[20]、在棧幀粒度進行數據隔離[21]都是有效的防御手段; 對于堆越界訪問, 部分防御方案將陷阱數據填充在對象之間[22], 或者在對象粒度上進行邊界檢查.
測試集的空間安全性測例共98項, 主要使用兩種方式構造緩沖區越界訪問: 1)通過合法的緩沖區指針和越界的地址偏移; 2)修改合法的緩沖區指針指向界外位置. 測試集對棧上、堆上、全局變量、只讀數據中的越界訪問都進行了測試.
3.3.2 時間安全性
時間安全性指內存中的數據訪問只發生在數據的生命周期之內. 任何發生在程序生命周期之前(未初始化數據)和之后(釋放后使用)的訪問都是不安全的. 時間安全性的測例只關心一個生命周期外的訪問是否會發生, 不會針對具體的內存分配算法進行測試.
釋放后使用相關的漏洞主要包括空懸指針(dangling pointer), 未初始化變量等. 在堆上和棧上的空懸指針都會為程序帶來較大的安全隱患.
應對空懸指針的防御方案包含空懸指針歸零[23]、解引用前檢查空懸指針[24]、阻止分配器在被釋放對象地址進行重分配[25]、阻止未初始化數據訪問、細粒度棧空間隨機化[26]、內存標記等.
與時間安全性相關的測試共有13項, 主要檢測棧上或堆上的數據在棧幀或對象被釋放后能否被空懸指針繼續訪問. 此外, 還檢查平臺是否具有保證相同類型的對象在相同的內存地址不被重新分配、函數每次調用時動態變化棧幀結構等性質.
3.3.3 訪問控制
訪問控制性質指限制了攻擊者內存訪問能力的性質. 主要用來防御信息泄露攻擊. 程序的函數體代碼、全局偏移量表(global offset table, GOT)都是潛在的攻擊目標. 攻擊者在運行時讀取程序的函數體代碼, 檢索可能成為gadget的代碼片段, 用以構造代碼復用攻擊.
全局偏移量表用于程序動態鏈接共享庫時檢索符號. 由于全局偏移量表表項在運行時動態更新, 所以需要存儲在可寫頁上. 攻擊者可以通過讀取全局偏移量表表項獲取動態庫函數在內存中的地址, 造成信息泄露. 攻擊者也可以通過修改全局偏移量表來劫持庫函數.
針對上述攻擊, 主要的防御技術包括地址空間隨機化, 防止攻擊者讀取可執行頁的代碼隨機化、可讀不可執行[27]等. 測試集的訪問控制測例共3項, 也圍繞這些防御技術展開, 主要檢查地址空間隨機化是否有效, 函數體代碼是否可讀, 以及全局偏移量表特定表項是否可讀等.
3.3.4 指針完整性
典型的控制流劫持攻擊和防御手段經常圍繞指針展開. 攻擊的第1階段修改保存敏感數據的指針, 破壞了指針完整性; 第2階段使用被修改的指針劫持控制流, 破壞了控制流完整性.
指針完整性測例主要關注保存敏感數據的指針的安全性. 敏感數據指針包括函數指針、虛函數表指針和全局偏移量表.
函數指針通常可拷貝但不可修改, 進行算數運算的情況非常罕見. 函數指針一般通過指針審計和指針標記[13]進行保護. 虛函數表指針指向一張函數指針表,其中每個表項指向類型對應的虛函數. 對虛函數表指針的保護方案包括代碼指針完整性保護[13]、GCC VTV等.
測試集的指針完整性測例共5項, 主要檢測平臺是否允許函數指針拷貝和算術運算, 是否允許對虛函數表指針進行讀取和修改、是否允許對全局偏移量表進行修改等.
3.3.5 控制流完整性
控制流體現了程序動態執行時指令間邏輯上的先后順序. 通過代碼指針調用完成的控制流跳轉稱為前向控制流; 通過返回地址完成的控制流跳轉稱為后向控制流. 前向控制流完整性指保護代碼指針解引用到合法的地址; 后向控制流完整性指保護返回地址不被惡意篡改.
控制流完整性相關的攻擊方式包括代碼注入攻擊、代碼復用攻擊等, 對于使用多態的程序還包括虛函數表劫持攻擊. 測試集的控制流完整性測例共41項,主要圍繞這些攻擊的典型防御方案如數據執行保護,控制流完整性保護等進行測試.
測試集的整體架構如圖1所示. 測試樣例由兩部分組成: 平臺無關的測試邏輯, 與平臺相關的支持庫.

圖1 內存安全測試集整體框架圖
每個測例為測試特定內存安全性質的C++程序;若某條性質對應的安全檢查缺失, 測例可以利用該漏洞完成測試邏輯并返回零值, 表示漏洞被成功利用; 否則測例返回非零值并提示測試失敗.
整個測試集的運行通過測試驅動控制. 測試驅動使用指定的編譯選項對測例進行編譯, 運行測例并統計測例的運行結果, 得到測例通過數量的量化數據.
測例中利用漏洞的惡意行為常常以匯編代碼的形式實現. 這是為了防止編譯器優化掉惡意行為. 這些匯編代碼在平臺相關的支持庫中. 測試邏輯是使用平臺無關的方式編寫的; 需要使用惡意代碼的部分通過調用平臺支持庫來完成.
測例的可移植性通過測試邏輯與支持庫的劃分實現. 二者之間通過宏的定義和調用產生聯系. 支持庫中的匯編代碼使用宏定義的方式組織; 測試邏輯通過引用宏定義完成調用. 不同平臺的支持庫對相同的宏名稱提供定義, 使用平臺特定的匯編代碼實現相同的動作. 每個測例都會引用公共頭文件(include/assembly.hpp), 該文件對不同平臺支持庫的頭文件進行了包裝,通過不同架構的預定義宏進行區分(如__x86_64、__riscv64), 編譯測試集時編譯器會根據預定義宏選擇正確架構對應的支持庫.
對于新增加的平臺或指令集架構, 只需要和其他支持庫對同樣的宏名稱進行定義即可, 不需要修改測試邏輯; 對于新增加的測例, 需要將測試使用支持庫提供的宏實現, 如果需要新增宏定義, 則需要在所有的支持庫中將對該宏進行定義. 對于新的指令集架構而言,目前只有約20個宏名稱需要被實現. 綜上所述, 測試集具有良好的可拓展性.
下面以控制流完整性的測例call-instruction-instack為例, 描述測例的代碼結構和測試流程.
測例call-instruction-in-stack用來測試將棧上地址作為目標地址的情況下, 函數調用是否能夠成功執行.測試邏輯的主要代碼如代碼清單1所示. 其中assembly.hpp和signal.hpp均為平臺支持庫的公共頭文件. 頭文件assembly.hpp負責提供FORCE_NOINLINE、CALL_DAT、FUNC_MACHINE_CODE等宏的宏定義. 頭文件signal.hpp負責提供異常處理所需的接口代碼. 部分平臺在檢測到違反內存安全規則的操作時會拋出異常,signal.hpp提供將這些異常捕獲并產生特定返回值的代碼.

代碼清單 1. call-instruction-in-stack的測試邏輯#include "include/assembly.hpp"#include "include/signal.hpp"int gv = 1;int FORCE_NOINLINE helper(const unsigned char* m) {CALL_DAT(m);return gv;}int main(){unsigned char m[] = FUNC_MACHINE_CODE;… //異常處理初始化代碼int rv = helper(m);… //異常處理收尾代碼exit(rv);}
宏FORCE_NOINLINE的作用是使強制被修飾函數不生成內聯代碼, 原因是內聯代碼在部分平臺上會影響測例測試邏輯的正確性; 宏CALL_DAT(addr)的作用是將addr作為目標地址, 進行函數調用; 宏FUNC_MACHINE_CODE的作用是模擬函數體代碼, 使得CALL_DAT宏產生的函數調用一旦成功執行, 后續指令能夠正常返回或是產生特定異常并被main函數中異常捕獲邏輯捕獲, 使得測例能夠正常退出.
宏CALL_DAT的具體實現為C擴展內嵌匯編代碼, 所以不同的指令集架構上, 實現各不相同. 在x86-64指令集架構上其實現如代碼清單2所示, 在RISCV64指令集架構上其實現如代碼清單3所示.

代碼清單 2. CALL_DAT宏的RISC-V64架構實現#define CALL_DAT(ptr) asm volatile( "jalr ra, %0, 0;" : : "r"(ptr) : "ra" )代碼清單 3. CALL_DAT宏的x86-64架構實現#define CALL_DAT(ptr) asm volatile( "call *%0;" : : "r" (ptr) )
如果需要將本測例移植到ARM AArch64架構, 則只需要在ARM AArch64指令集架構的平臺支持庫和頭文件中實現對FORCE_NOINLINE、CALL_DAT和FUNC_MACHINE_CODE這3個宏的定義即可.
測例測試邏輯的核心部分在helper函數. 如果helper函數中的CALL_DAT宏成功執行, FUNC_MACHINE_CODE將會和main函數中的異常處理代碼結合, 將rv的值設置為0. 測例將以返回值0退出,表示測試通過. 如果CALL_DAT宏的執行拋出異常,則main函數中的異常處理代碼會將拋出的異常轉換為非零返回值-1并退出程序.
對于x86-64架構, 我們使用一臺較舊的Intel i7-3770 CPU搭配Ubuntu 16.04操作系統, 和一臺較新的Intel Xeon 8280 CPU搭配Ubuntu 18.04操作系統進行測試.
對于RISC-V64架構, 我們使用SiFive公司的HiFive Unleashed和HiFive Unmatched兩款開發板進行測試, 兩塊開發板分別基于SiFive公司的u540和u740 CPU, 操作系統均為SiFive公司提供的預編譯OpenEmbedded操作系統.
我們在x86-64和 RISC-V64兩個ISA架構的4個平臺上應用測試集進行了測試. 平臺列表如表1所示.

表1 內存安全測試集運行平臺參數
4.2.1 不同平臺間安全性對比
為了保持一致性, 我們在不同平臺上統一使用操作系統提供的GNU g++編譯器, 使用相同的編譯選項“-O2 -std=c++11 -Wall”進行編譯. 測試集結果的概要如表2所示.

表2 不同平臺成功執行測試樣例數
時間安全性: Intel Xeon 8280、HiFive Unleashed、HiFive Unmatched平臺上, 部分堆上的釋放后使用相關測例運行失敗. Intel i7-3770平臺全部測例運行成功.說明除了Intel i7-3770平臺外, 其他各被測平臺都具有一定的內存時間安全性防御能力. 通過調查原因發現,這些平臺使用了較新版本的GLIBC. 后者采用了新的內存分配算法, 在同一片內存區域釋放后和分配前插入了垃圾內容, 阻止了釋放后信息泄露以及偽造未初始化變量對象攻擊. 不過新的算法仍然能夠被強制在同一塊內存區域重新分配相同類型的對象, 一些使用空懸指針的釋放后使用攻擊仍然有效. 此外, 各個被測平臺對棧上的釋放后使用同樣缺乏有效的安全檢查.
指針完整性: 在各個被測平臺上, 讀寫代碼指針和虛函數表指針的測例全部成功執行. 雖然編譯器在編譯階段給出了指針算數運算的警告, 但是指針算數運算測例在各個平臺仍然能夠成功執行. 修改全局偏移量表表項的測例在Intel Xeon 8280平臺上執行失敗,但在其他平臺上成功執行. 說明4個被測平臺中, 只有Intel Xeon 8280平臺默認提供了部分指針完整性檢查.該檢查來自重定位只讀保護(relocation read-only,RELRO), 大多數Linux發行版都默認提供了部分重定位只讀保護. 但是在HiFive Unmatched和HiFive Unleashed兩個平臺上重定位只讀保護并沒能覆蓋庫函數的入口.
訪問控制: 檢測發現Intel i7-3770平臺的地址空間隨機化測例成功執行. 其他各被測平臺除了地址空間隨機化測例以外其余測例都成功執行. 說明被測平臺中大部分都默認開啟了地址空間隨機化保護, 但是缺乏對信息泄露的進一步防御. 分析原因發現, Intel i7-3770平臺編譯器默認的編譯選項不支持生成位置無關代碼, 導致對用戶程序的地址空間隨機化無法使用. 增加“-pie -fPIE”選項后地址空間隨機化相關測例執行失敗, 地址空間隨機化保護成功開啟.
空間安全性: 在4個被測平臺上, 所有98個測例都成功完成了測試. 說明被測平臺默認提供的安全防護中缺乏對內存越界訪問的安全檢查. 軟件上常使用address sanitizer來檢測越界訪問, 但是性能代價太高,只適合在開發階段使用, 無法部署到產品中. 硬件拓展如CHERI[28]、PUMP[15]等雖然實現了對越界訪問的檢查, 但是是以修改系統ABI、增加硬件開銷和性能開銷為代價的.
控制流完整性: 對于后向控制流劫持相關的測例,與返回導向編程技術相關的測例都成功執行; 代碼注入攻擊的測例悉數被數據執行保護攔截. 對于前向控制流劫持, 除了代碼注入攻擊的測例被數據執行保護攔截, 其他類型攻擊相關的測例都成功執行. 對于虛函數表保護, 替換虛函數表、偽造虛函數表的相關測例均成功執行. 對部分使用新的內存分配算法的平臺, 虛函數指針復用攻擊相關的測例執行失敗, 調查原因發現, 新的內存分配算法在釋放對象時清零了虛函數表指針. 上述被測平臺都具有一定的控制流完整性防御能力.
總的來說, 在各個被測平臺上, 由默認配置提供的安全防護并無太大區別. 各平臺默認都沒有對空間安全性提供有效的保護; 在HiFive Unleashed和HiFive Unmatched平臺上由于配套的工具鏈和運行時庫增加了安全防護, 所以提供了更好的時間安全性保護. 地址空間隨機化和數據執行保護雖然為各平臺提供了一定的內存安全防護能力, 但覆蓋面較窄, 只能限制在特定的幾項內存安全性質上.
4.2.2 不同編譯器與編譯選項間對比
編譯器不同的編譯選項也提供了部分安全防護.在Intel Xeon 8280平臺上, 使用GCC 10.3.0和GLIBC 2.32對不同的編譯選項進行了測試. 為了測試LLVM提供的控制流完整性保護防御機制, 也將LLVM13在Intel Xeon 8280平臺上進行了測試.
很可惜, 由于工具鏈移植仍然不完整, RISC-V的GCC和LLVM沒有提供對VTV和CFI的支持, RISCV架構的address sanitizer無法正常工作, 剩余的可用內存安全選項測試得到的結果差別不大, 對判斷RISCV架構平臺的內存安全性意義不大, 所以我們將只對Intel Xeon 8280平臺的測試結果進行討論.
我們按照功能將編譯器提供的安全方面的編譯選項分為幾組, 如表3所示.

表3 內存安全相關不同編譯選項組
下面對表3中的選項組進行解釋.
默認選項: 只要求-O2優化, 其他為編譯器默認選項; RELRO: 開啟對全局偏移量表的全面保護; 棧保護:通過在棧中插入canary實現棧覆寫保護(stack smashing protection); VTV: GCC支持的虛函數表驗證特性,用于應對偽造對象導向編程技術攻擊; CFI: LLVM支持的前向控制流攻擊防御機制; 全部防護: 對編譯器應用上述支持的所有編譯選項; Asan: 開啟動態address sanitizer; 無防護: 關閉包括數據執行保護在內的所有防護, 包括內核提供的地址空間隨機化等.
在默認選項下, Intel Xeon 8280平臺使用GCC 10.3的通過測例數為142, 與使用平臺默認的編譯工具相比, 全局偏移量表篡改可行性的測例通過, 但是有4個堆上釋放后使用的測例失敗, 原因是采用了新的GLIBC庫. 使用LLVM通過的測例數同樣為142, 不過由于LLVM生成的代碼默認不開啟PIE選項, 并且在編譯時不允許代碼指針算術運算, 所以具體成功執行的測例稍有區別.
在RELRO選項下, Intel Xeon 8280平臺下GCC編譯通過測例數減少了1, LLVM編譯通過測例數減少了2. 可見開啟RELRO選項對測試集涉及的內存安全漏洞并不敏感.
測試結果如表4所示.

表4 Intel Xeon 8280平臺下不同編譯選項組測試集編譯運行通過測例數
開啟棧保護選項下, Intel Xeon 8280平臺下對測試集通過測例數幾乎沒有任何影響, 因為大多數返回導向編程技術攻擊都可以定位返回地址保存位置, 并在不觸碰canary的情況下能夠修改返回地址. 失敗的測例為偽造棧幀攻擊相關的測例.
VTV選項下, Intel Xeon 8280平臺下6項偽造對象導向編程技術相關測例都測試失敗. 不過將虛函數表替換為子類、父類的行為仍然沒有被攔截.
CFI選項下, Intel Xeon 8280平臺下幾乎沒有提供任何安全增強. 可能的原因是LLVM CFI要求在鏈接期間對所有的類定義都可見. 這需要使用靜態鏈接方式編譯. 而為了應對編譯器優化策略, 所有可執行文件均以動態鏈接方式鏈接. 這導致鏈接時分析將對虛函數表指針和函數指針的修改操作識別為了合法操作.
全部防護選項下, Intel Xeon 8280平臺下GCC編譯測試集共有26項測例失敗; LLVM編譯測試集共有22項測例失敗.
開啟Asan選項下, Intel Xeon 8280平臺下GCC編譯測試集通過的測例數減少為8. 通過的測例僅包括兩項訪問控制測試(read-func和read-GOT)以及6項棧上釋放后使用攻擊測例. LLVM編譯測試集通過的測例數減少為21. LLVM編譯測試集中, 返回導向編程技術和偽造對象導向編程技術攻擊相關的測例都測試失敗, 但是跳轉導向編程技術攻擊相關的測例仍然成功執行, 全局偏移量表表項修改可行性的測例也成功執行. 不過LLVM編譯測試集中所有的釋放后使用相關測例都測試失敗, 包括被GCC Asan漏掉的棧上釋放后使用攻擊測例.
無防護選項下, Intel Xeon 8280平臺下GCC編譯測試集僅有5項測例失敗. 失敗測例均為堆上釋放后使用攻擊測例. LLVM編譯測試集除了沒有編譯通過的代碼指針算術操作測例之外, 結果與GCC編譯測試集相同. 結合上述各點, GCC編譯器與LLVM編譯器安全特性提供的內存安全檢查大致相近, 只是在使用動態鏈接類型定義時LLVM的CFI安全特性未能發揮有效作用, 相比于GCC稍遜一籌. 不過, LLVM測試集中關于代碼指針算數運算的測例沒有通過編譯, 而GCC測試集中只是給出了警告, 這也說明兩款編譯器對于內存安全問題防護具有不同的側重點. 兩款編譯器提供的address sanitizer攔截了絕大多數的內存安全惡意行為, 說明大多數的內存安全性質都依賴于內存的空間安全性.
關于測試集, 早期的測試集主要用于測試計算機的計算性能. 19世紀70年代的LINPACK測試集用于測量計算機進行線性代數數值計算的性能, 至今還用于超算的性能衡量中. Dhrystone[29]為衡量計算機普通整數運算提供了性能指標; CoreMark專注于微控制器的性能測量; SPEC測試集[30]則用于性能更強的通用計算機. PARSEC[31]則主要集中于衡量共享內存和多線程應用的性能.
在2005年, Kratkiewicz等[32]提出了使用構造的小型緩沖區溢出攻擊測試現有的軟件防御方案.2006年, BASS[33]吸收了SPEC的思想, 將7個包含有不同種類內存漏洞的測例綜合進行安全性驗證, 同時提供了一個框架用于自動生成利用內存漏洞攻擊. 據我們所知, BASS是最早的嘗試衡量計算機安全性的測試集; 然而該測試集的測試范圍只限定在幾個特殊的內存空間漏洞上. RIPE[6]是當前內存安全領域應用最為廣泛的安全測試集. 通過枚舉幾種攻擊方式的組合,RIPE能夠覆蓋850種緩沖區溢出攻擊和返回導向編程技術攻擊. 它也被用于衡量硬件輔助的控制流攻擊防御方案. 但是按照RIPE的方法覆蓋緩沖區溢出和返回導向編程技術攻擊就需要850項測例, 要對內存安全進行較全面的覆蓋可能難以實現.
最近幾年出現了新的安全測試集設計. CONFIRM[34]是最近提出的用于衡量不同控制流完整性防御方案的兼容性和可用性的安全測試集, 但是缺少對于安全性的評估. CBench[7]對控制流完整性防御方案的實際效果進行評估, 采用與BASS類似的設計, 共使用7個大類共18個包含漏洞的程序. 與本文工作相比, CBench使用完整的攻擊進行測試, 而且集中在被測防御機制本身, 而不是實現這些機制的平臺, 另外, CBench也不支持跨平臺, 只能在x86-64架構上運行.
由于目前我們可用的平臺支持的指令集架構只包括Intel x86-64和RISC-V64, 測試集目前僅在這兩個指令集上進行了測試, 對于其他指令集架構的支持正在進行中. 未來計劃增加對ARM AArch64和龍芯/MIPS指令集架構的支持.
雖然主要測試目標是處理器及相應的指令集架構的內存安全水平, 但是測試集的執行并不能脫離測試環境. 這也導致在測例不通過時, 有時較難區分具體是處理器的硬件防御機制起了作用, 還是操作系統、編譯器或標準庫的軟件防御機制起了作用.
上述問題向測試集引入了操作系統、編譯器和標準庫等無關變量. 一種消除這些無關變量的方法是將所有被測平臺都強制安裝特定的操作系統、編譯器和相同版本的標準庫. 這種方法雖然在理論上可行, 但是實踐的難度很大. 如果將測試環境縮小到只包含內核與命令行工具的最小系統, 在嵌入式平臺上比較容易實現, 但是在一般的服務器和PC機上安裝最小環境則比較困難. 如果將測試環境規定為特定版本的操作系統發行版(如Ubuntu), 那么這一發行版并非能夠被所有被測平臺支持, 如Mac M1和其他眾多嵌入式平臺等.
基于上述原因, 我們不強制所有平臺運行特定的操作系統、編譯器和相同版本的標準庫, 而是默認為某種發行版, 假定該發行版提供的測試環境足夠小. 我們將被測目標的含義擴大為硬件平臺及其支撐的運行環境. 受控變量除了處理器和硬件平臺之外, 還包括平臺上運行的操作系統、編譯器和標準庫.
為了消除增加受控變量帶來的影響, 保證能夠正確的分析測試的結果, 在測試集的構成上, 測例盡量使用不同的非零返回值去標注不同位置和不同原因造成的測試失敗, 從而為判斷生效的防御類型提供線索.
此外, 我們也使用現有的平臺對編譯器提供的內存安全標志選項進行了討論, 分析了主流編譯器提供的內存安全防護的有效性. 操作系統和標準庫這些變量對內存安全防御的影響也可以通過配置不同的內核安全功能、標準庫版本進行評估. 不過限于篇幅和工作量的關系, 這些評估現在還沒有展開.
我們設計了一套兼具綜合性和可移植性的內存安全測試集框架. 初始的測試集包含160項測例, 覆蓋了內存時空安全性、訪問控制、指針完整性和控制流完整性等幾個方面. 每一類漏洞及其相關的防御方案都被若干測例評估. 為驗證可用性, 我們將測試集在Intel x86-64和RISC-V64指令集架構上進行了評估. 我們的評估結果顯示, 雖然地址空間隨機化和數據執行保護等防御方案對被測平臺提供了部分內存安全保護,但大部分的內存漏洞在部分處理器的默認編譯器配置下仍然能夠被利用. 開啟額外的編譯器安全特性能夠抵御特定類型的內存安全攻擊. 盡管address sanitizer作為調試工具不能用于生產環境中, 它在捕獲內存安全攻擊上十分有效. 就相同平臺上的編譯器表現來看,LLVM和GCC能夠提供相近的內存安全保護, 兩者對內存安全保護的側重各有不同.
致謝
感謝郭雄飛提供的HiFive Unleashed開發板以及中國科學院軟件研究所PLCT團隊贈與的HiFive Unmatched開發板. 兩套硬件設施對我們在RISC-V架構平臺上的測試起到了很大幫助.