JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

現代防禦:ASLR、金絲雀與安全啟動

第三篇示範了緩衝區溢位如何劫持一支程式、一個立足點又如何升級成權力。本階段的最後一篇是反擊:每一套現代作業系統如今都內建的具體防禦——把位址隨機化、把資料標成不可執行、在堆疊上埋一隻金絲雀、為啟動鏈簽章、把程式關進沙盒——以及一個誠實的真相:每一道防禦都只是提高攻擊的成本,卻從不承諾攻擊不可能發生。

縱深防禦:沒有單一道牆

你帶著壞消息來到這一篇。第三篇帶你走過一次緩衝區溢位——寫過陣列尾端如何能覆寫坐在堆疊上的返回位址、把程式導向攻擊者的程式碼——也走過權限提升,把一個小立足點變成 root。自然的問題是:如果單一個臭蟲就能做到這一切,任何系統還怎麼活下來?答案不是一道完美的牆,而是許多道不完美的牆,每一道都便宜易加,每一道都逼攻擊者多跨過一個欄架。

這種把獨立屏障層層堆疊的做法叫做縱深防禦,也是本篇最重要的單一觀念。想像一座城堡:先有護城河,再有城牆,再有上鎖的主塔,再有守在寶藏旁的衛兵。沒有人相信光靠護城河就夠了——它的工作只是讓城牆更難抵達。下面的種種防禦也是這樣運作。ASLR 逼攻擊者去猜一個位址;不可執行位元讓他們落腳的那個位址變得無用;一隻堆疊金絲雀讓那次覆寫在返回之前就被逮到。打敗其中一道是可信的;同時、可靠地打敗全部,才是把多數攻擊擋在門外的那道牆。

ASLR:在黑暗中藏起家具

一個經典的攻擊需要知道東西在記憶體裡住在哪裡——它想跳過去的程式碼的確切位址,或它塞滿自己指令的那個緩衝區的位址。數十年來這很容易,因為一支程式的各部分每次執行都載入到相同、可預測的位址。攻擊者只要把位址寫死一次,就能永遠重用。位址空間配置隨機化(ASLR)打破了這個假設:每次程式啟動時,作業系統都把它的堆疊、它的堆積、它的函式庫、常常還有它的主要程式碼,載入到一個不同的、隨機選定的基底位址。家具一樣;但每次你走進去,房間都在黑暗中被重新擺過。

為什麼這在不弄壞程式的情況下竟然可行?因為一個你已經懂的東西:虛擬記憶體。每個行程都看見自己私有的虛擬位址空間,而分頁表把那些虛擬位址轉譯到實體頁框實際所在之處。作業系統可以在載入時把一整個區域滑到一個新的虛擬基底,因為程式內部是靠名字與相對偏移量來指稱東西的,而載入器會修補那少數幾個絕對參照。坐在外面的攻擊者讀不到分頁表;他們只知道配置曾經是隨機的,卻不知道這一次它落在哪個數字上。

這個猜測有多難?強度就只是隨機位元的數量。在 64 位元系統上,作業系統也許會隨機化函式庫基底的 28 個位元——那就是 2^28,大約 2.68 億種可能。一個盲目的攻擊者得猜中其中一個,而在多數設定下,猜錯就讓行程當掉,警覺的管理員會注意到。但對界線要誠實。在老舊的 32 位元系統上,能隨機化的位元少得多,所以暴力破解是可行的。而且只要程式洩漏出哪怕一個真實位址,ASLR 的全部強度就崩塌——一個印在錯誤訊息裡的指標,就把隨機基底告訴了攻擊者,其餘的配置靠減法就推得出來。ASLR 提高攻擊的成本;它並不把成本降到零。

NX 與金絲雀:守住堆疊本身

ASLR 藏起東西在哪裡。接下來兩道防禦更直接地攻擊溢位本身。第一個是不可執行規則,也叫 DEP(資料執行防護)或 NX 位元。回想記憶體階段的分頁表項目:每一個都帶著它那一頁的權限旗標。NX 多加一個旗標,意思是「這一頁裝的是資料、絕非指令——CPU 必須拒絕在這裡執行任何東西」。如今那個把 shellcode 灌進緩衝區再跳過去的經典攻擊一抵達就死了:緩衝區住在堆疊或堆積上,那些被標成不可執行,所以 CPU 一試著執行那些位元組就發生錯誤、作業系統就殺掉行程。程式碼頁可執行但不可寫;資料頁可寫但不可執行;攻擊者想要的,是一頁兩者皆是。

第二道防禦是堆疊金絲雀,得名自礦工帶進坑道、在毒氣害死他們之前先示警的那種鳥。這個點子小而美。當一個函式開始時,編譯器悄悄在堆疊上、就放在已存返回位址的前面,擺一個秘密的隨機值——金絲雀。當函式即將返回時,它先檢查金絲雀是否仍是原本的值。一次延伸到足以蹂躪返回位址的緩衝區溢位,途中必然會寫過那個坐在緩衝區與返回位址之間的金絲雀。於是檢查失敗,程式刻意中止,而非返回進攻擊者掌控的地盤。金絲雀無法阻止那次覆寫,但它可靠地偵測到了它最常見的那種形式。

stack frame, growing downward (high address at top)

  ...caller's frame...
  +---------------------+
  |  saved return addr  |  <- overflow wants to overwrite THIS
  +---------------------+
  |  CANARY (random)    |  <- ...but must cross this on the way
  +---------------------+
  |  local buffer[64]   |  <- attacker writes here, then keeps going UP
  +---------------------+

  on overflow: buffer ... -> CANARY (clobbered) -> return addr (clobbered)
  on return:   if (canary != original)  -> abort, do NOT return
一次線性溢位必須穿過金絲雀才能抵達返回位址。若金絲雀變了,函式就拒絕返回。NX 則另外確保:即使在緩衝區裡有個落腳點,那裡也不能被執行。

注意這些防禦如何彼此組合,也注意它們的接縫。NX 阻止你執行緩衝區的程式碼,於是攻擊者發明了返回導向程式設計——把程式自己既有的可執行片段串接起來、而非注入新程式碼,這完全繞過了 NX。但要串接那些片段,你必須知道它們的位址,而那正是 ASLR 所藏起的。金絲雀則逮住那兩種技巧都仰賴用來抵達返回位址的那種線性覆寫。每一道防禦都有已知的繞法;合在一起,它們逼攻擊者同時擊敗全部三道。那種互鎖,就是被化為具體的縱深防禦,而不是三個不相干的把戲。

安全啟動:信任總得從某處開始

至此每一道防禦都假設作業系統本身是誠實的。但若攻擊者在核心執行之前就把它腐化呢——在它底下塞進一個rootkit,讓那段本該執行規則的程式碼,反倒是對你撒謊的東西?那麼 ASLR、NX 與金絲雀,全都由一個攻擊者早已掌控的裁判來執行。這正是為什麼信任鏈必須從啟動開始,靠安全啟動。它的點子是一場簽章接力:一小段你被迫信任的韌體,在交棒之前先檢查下一階段的密碼學簽章、那一階段再檢查它之後的那個,如此一路向上直到核心——於是除非某個早已被信任之物已經為它背書,否則什麼都不會執行。

  1. 通電。CPU 開始執行燒在主機板上的韌體——通常是 UEFI。這第一環是信任的根:它之所以被信任,是因為它在實體上無法被普通軟體改寫。
  2. 韌體找到下一階段——啟動載入器——並拿韌體裡保存的一把受信任的公開金鑰,去驗證它的密碼學簽章。若簽章缺失或不對,它就拒絕執行它並停下。
  3. 已驗證的啟動載入器對核心如法炮製:它在載入核心映像之前先檢查它的簽章。一個被竄改的核心——比方說,一個嫁接了 rootkit 的——通不過檢查,永遠拿不到控制權。
  4. 核心啟動,而直到此刻,系統的其餘部分才在它能真正執行的防禦之下啟動,因為在它之前沒有任何不受信任的程式碼跑過。這場接力一路把一根誠實的接力棒交了上來。

值得精確說清楚安全啟動證明了什麼、又沒證明什麼。它證明的是來源——每一階段都由前一階段信任的金鑰簽過章——而非被簽章的程式碼沒有臭蟲或無害。一個正確簽章、卻帶著安全漏洞的核心,照樣完美通過安全啟動;簽章關乎真實性,無關品質。而整條鏈仰賴那第一環真正不可變、以及那些受信任的金鑰是由你確實信任的人所選定。誰有資格握有金鑰,是一個真實而有爭議的問題。安全啟動是對抗持久的、潛伏在作業系統之下的惡意軟體的強力保護;它不保證它之上的作業系統毫無瑕疵。

圍堵:假設破口會發生,控制爆炸範圍

至此的防禦都試圖阻止一次淪陷。圍堵則與「無論如何它還是會發生」這個可能性和解,並問:當這支程式真的被接管時,它能造成的損害可以多小?這就是第一篇的最小權限原則被化為可執行。一個沙盒讓程式在一個刻意被弄得貧瘠的世界裡執行:它只能碰那些被明確授予的檔案、那些系統呼叫、那條網路,其餘一概不能。一個被攻陷、卻只能開啟剛好一張圖片、且發不出半條網路連線的看圖程式,遠比一個能在你帳號裡橫行無阻的,是個小得多的戰利品。

Linux 用兩種互補的工具來建造這個。SELinuxAppArmor 是強制存取控制系統:在普通的擁有者/權限查驗之上,核心還會查閱一份管理員寫的政策,例如說「這個網頁伺服器可以讀它的內容目錄、可以在 443 埠監聽,其餘一概不行——即使是 root 也不行」。它之所以「強制」,是因為程式無法放棄它;不像擁有者能放寬的、屬於任意性的 rwx 位元,這份政策是從上而下強制執行的。另外,命名空間與控制群組則是建造容器的核心功能:命名空間給一個行程它自己對檔案系統、行程清單與網路的私有視角,而控制群組則限制它能消耗多少 CPU 與記憶體。

這一切立足於何處:可信運算基底

退一步,問問本篇每一道防禦最終信任的是什麼。ASLR 由核心決定;NX 由 CPU 與核心檢查;金絲雀由編譯器插入的程式碼檢查;安全啟動信任韌體;沙盒信任核心去執行政策。那一整套「為了讓你的安全成立、就必須正確」的硬體與軟體,叫做可信運算基底(TCB)。它不是你因為它和善而信任的程式碼——它是你被信任的程式碼,因為只要它有任何一部分壞了,建在它之上的每一個保證就悄悄失效。

這把整個階段重新框定為單一個設計目標:把 TCB 保持得小。一個被沙盒圍住的看圖程式裡的臭蟲是被圍堵住的;而執行那個沙盒的核心裡的臭蟲,卻能讓一切失效。這正是為什麼這個階段一再回到使用者/核心界線與最小權限——你每把一行程式碼搬有特權的 TCB,就是一行不再能背叛整個系統的程式碼。這也是為什麼最敏感的系統會傾注心力,去縮小、甚至用數學去證明那個它們無法避免信任的小核心。

所以帶著實務者誠實的心態離開這個階段,而非一份虛假的安全感。沒有任何單一開關能讓系統安全。保護機制決定一個已被識別的主體能做什麼;驗證決定那個主體究竟是誰;而這裡的防禦提高了把臭蟲變成權力的成本。它們每一個都有我們大聲講明的界線——ASLR 敗於一個被洩漏的位址、金絲雀只逮得到線性覆寫、安全啟動證明來源而非品質、容器共享一個核心。你照樣把它們層層疊起,因為安全不是一道你蓋完就完工的牆;它是一個你持續提高的成本,誠實地,明知攻擊者也正在提高他們的。