控制流完整性(control-flow integrity)
/ C-F-I /
程式的合法流程像一條有核定轉轍點的鐵路:列車只能在真正的道岔處換軌,且只能去時刻表允許的目的地。控制流劫持就是列車任意跳上它想要的軌道。控制流完整性(CFI)是一道在道岔處裝設守衛的防禦:在每個間接跳躍或呼叫處,它檢查目的地是否為程式確實被允許抵達的,若否就中止。
具體而言,危險的轉移是那些「間接」的——透過函式指標的間接呼叫(call rax)、透過 vtable 的虛擬呼叫、以及返回(ret)。對固定位址的直接呼叫無法被劫持,所以 CFI 聚焦於間接者。在編譯期,編譯器替每個間接呼叫點計算出合法目標的集合(例如只允許型別簽章相符的函式,或只允許某類別虛擬表的成員)。接著它在每個間接轉移之前插入一個執行期檢查:算出的目標在允許集合裡嗎?是就放行;否就代表程式正遭攻擊,於是中止。這自然地分成前向邊 CFI(守護呼叫與跳躍)與後向邊 CFI(守護返回),後者通常由影子堆疊實施。於是即使攻擊者損毀了一個函式指標或 vtable 項目,檢查也會在跳躍前否決那個假目的地。
它之所以重要,是因為 CFI 直接攻擊程式碼重用利用的「目標」:一條 ROP 鏈或一次被劫持的 vtable 呼叫,試圖把控制送往程式從未打算去的地方,而那正是 CFI 所禁止的。誠實的說法:CFI 並非全有或全無。它的強度取決於允許目標集合有多「精確」——粗粒度 CFI(允許任何函式起點)仍給巧妙的鏈留下空間,而細粒度、以型別為基礎的 CFI 緊得多,但成本更高、也更難套用到大型 C 程式庫。它同樣不修記憶體漏洞;它讓利用該漏洞的劫持失敗。硬體協助(Intel CET 的間接分支追蹤、ARM 的 BTI)讓實施更便宜。
// 在間接呼叫之前,CFI 插入一個檢查(概念上): if (!valid_target(fp)) // fp 在這個呼叫點的允許集合裡嗎? __cfi_abort(); // 被損毀的指標 -> 殺掉行程 call fp // 只有對被認可的目的地才會抵達
每個間接轉移都受一個檢查把關,確認目的地是程式被允許抵達的。
CFI 的保護只與它的目標集合精確度一樣好:粗粒度 CFI 仍允許某些 gadget 鏈;前向邊 CFI 守護呼叫,而後向邊(返回)通常需要影子堆疊——兩者都不修復底層漏洞。