一個不同的賭注:假設攻擊者進得來
本級到目前為止的一切,都是一場漫長的奮戰,要阻止攻擊者奪取控制權。第一、二篇展示了一個被破壞的指標如何成為控制流劫持、進而成為一條 ROP 鏈;第三篇展示了那些試圖讓奪取變得不可能的緩解措施——ASLR、NX、金絲雀、CFI。沙箱化採取一個更謙卑、坦白說也更切實際的立場。它假設:儘管有那一切,終有一天某個錯誤會贏,而攻擊者選定的程式碼將在你的行程裡跑起來。既然如此,它問了一個分開的問題:當那段程式碼跑起來時,我們能把波及範圍縮到多小?
這個問題之所以有牙齒,是因為一個執行中的行程,遠比它受僱去做的工作要強大得多。想想一個 PDF 閱讀器:它誠實的任務是讀進位元組、畫出像素。然而它預設繼承了啟動它的那位使用者的全部權限——它能開啟任何你能開的檔案、刪掉你的家目錄、建立網路連線、生出新程式。這些對於繪製一頁都不需要,但在攻擊者的 ROP 鏈開始執行的那一刻,它們全都可用。那道介於被授予的權限與被需要的權限之間的縫隙,正是攻擊者真正的獎賞。沙箱化就是把它關上的那門功夫。
權限究竟住在哪裡:系統呼叫邊界
要縮小程式碼能做什麼,你首先得問:它的力量從何而來。回想作業系統那一級:一個使用者行程,靠它自己,只能在它自己的記憶體裡搬動位元組——加暫存器、跟著指標走、執行小工具。那全是無害的算術。它一旦想碰它自身以外的任何東西——讀一個檔案、開一個通訊端、分岔一個子行程、送一個訊號——它就不能直接做。它必須藉著發出一個系統呼叫、從使用者模式跨入核心模式來向核心請求。一支程式能造成的每一個真實世界後果,都流經那唯一一道窄門。
這就是整個沙箱化領域所倚靠的洞見,讓它落定。因為一個行程的全部權限都是透過系統呼叫來行使的,系統呼叫邊界便是安裝瓶頸的天然之處。假如你能站在那道門前、拒絕危險的請求——「這個行程可以讀寫它已持有的檔案描述符,但它不可開新檔案、不可連上網路、不可 exec 另一支程式」——那麼即使攻擊者完全控制了這個行程,他也被縮減成「你確實放行的那些呼叫所能達成的事」而已。一條劫持了 rip 的 ROP 鏈,仍卡在那同一道門後。它能在記憶體裡運算任何東西,但不開口請求就伸不進這個世界,而守門人說不行。
seccomp:架在門上的過濾器
seccomp(secure computing mode,安全運算模式)是 Linux 把那個過濾器恰恰架在系統呼叫門上的機制,由核心本身強制執行。一個行程安裝一支小小的過濾程式——以一種受限的位元組碼(classic BPF)寫成——核心會在這個行程發出的每一個系統呼叫上、在呼叫被執行之前先跑這支程式。過濾器檢視系統呼叫編號(來自系統呼叫表)與它的引數暫存器,然後回傳一個判決:放行這個呼叫、用 EPERM 之類的錯誤拒絕它、當場殺掉行程、或陷入一個處理常式。因為跑過濾器的是核心,所以沒有什麼包裝可繞過——這道檢查坐在攻擊者底下,而不是在他旁邊。
讓一個 seccomp 過濾器強壯的那門功夫,是預設拒絕規則,而它值得明說,因為相反的做法是一個常見而危險的錯誤。一個好的沙箱不去列出它禁止的呼叫——那份清單沒有盡頭、你會漏掉一個。它反而從拒絕一切出發,然後明確放行程式真正需要的那一小撮。一個媒體解碼器,一旦開好了它的輸入與輸出檔案描述符,也許就只放行 read()、write() 與 exit(),並禁止其餘一切。於是一個拿下了完美任意讀寫的攻擊者,仍然開不了檔案、呼叫不了 execve 去啟動一個外殼、開不了網路通訊端——核心在那個系統呼叫開始之前就拒絕了它。
seccomp filter, conceptually (default-deny):
for each syscall the process makes:
if nr == read -> ALLOW
elif nr == write -> ALLOW
elif nr == exit -> ALLOW
else -> KILL the process
result: a hijacked process can still
compute in memory, but execve("/bin/sh")
never reaches the kernel -> attack stalls.不過要對它的限制誠實,因為 seccomp 銳利卻狹窄。它依系統呼叫編號與引數的原生暫存器值來過濾——而關鍵在於,它不能安全地解參考一個指標引數,因為那個指標活在行程的記憶體裡、而那記憶體攻擊者也許剛剛改寫過(一個經典的檢查時到使用時的時間差風險)。所以 seccomp 能說「完全不准 openat」,卻無法可靠地說「openat 沒問題,但僅限 /tmp 底下的檔案」——那個路徑藏在一個它絕不可信任的指標後面。粗略的、結構性的規則是它的強項;細緻的、依內容而定的政策,是別的工具的活兒。
權能:把無所不能的 root 拆開
seccomp 收窄一個行程可以發哪些呼叫。POSIX 權能收窄的是另一回事——它帶著多少特權權限。它們解決的問題,是 Unix 那個古老的全有或全無切分:一個行程要嘛是個普通使用者,要嘛就是 root(使用者編號 0)、幾乎什麼都能做。許多程式只需要*一項root 等級的能力——一個網頁伺服器需要綁定到 80 埠、一個 ping 工具需要送出原生網路封包——但歷來它們得以完整的 root 身分執行才拿得到,這意味著一個闖入的攻擊者拿到了 root 的全部*力量,而非程式所需的那一塊。
權能把那塊單一巨大的 root 力量,剁成數十個可以一次只授予一個的獨立片段。CAP_NET_BIND_SERVICE 是綁定低編號埠的權利;CAP_NET_RAW 是製作原生封包的權利;CAP_SYS_ADMIN 則是一個出了名包山包海的雜物袋;如此等等。你可以恰好把一個程式所需的那一項權能交給它、別的都不給。那個網頁伺服器拿到 CAP_NET_BIND_SERVICE、其餘則以一個無特權的使用者身分執行——於是若它淪陷,攻擊者繼承了綁定一個埠的權利、而一絲一毫都不多。這就是最小權限原則被付諸實行:授予最少,突破就維持得小。
權能是一個真切的進步,但不是一道乾淨的分割,而假裝它是會誤導人。有些權能寬到實際上就是換了個名字的 root——光是 CAP_SYS_ADMIN 就授予了如此蔓生的一大片力量,持有它幾近於遊戲結束,而另外好幾個也能被串接、升級回完整的 root。所以權能對於狹窄、慎選的情況(綁定一個埠、設定時鐘)大幅降低了風險,卻不是把 root 神奇地分解成能安全分離的原子。授予一個權能是一個真實的決定:讀清楚它實際准許什麼,而且絕不為了方便就伸手去拿 CAP_SYS_ADMIN。
特權分離:拆開程式本身,而非只是規則
seccomp 與權能都在收窄單一行程的權限。特權分離是一個更上一層的結構性想法:把程式本身拆成兩個或更多行程,讓危險的工作與強大的工作刻意分置兩處。其道理簡單而有力。最可能被利用的程式碼,是那段碰觸不受信任輸入的程式碼——啃著一個惡意 PDF 的剖析器、從敵意網路上讀進位元組的協定處理常式。攻擊者最想要的權限,則是那些強大的東西——以 root 身分開檔案、持有那把私鑰。特權分離把這兩者擺進不同的行程,於是攻破第一個並不會把第二個交到你手上。
典範的設計拆成一個小巧、受信任、持有特權的監督者,與一個龐大、不受信任、做盡危險剖析的工人(常被稱為無特權子行程)。著名的例子是 OpenSSH:一個極小的特權行程擁有主機金鑰與身分驗證;一個分開、被重重限制的子行程處理從線路上來的原生位元組。當子行程需要某件特權的事被做時,它並不去做——它透過一條管線或一個通訊端,向監督者送出一個狹窄而具體的請求,而監督者依一份固定的政策檢查每一個請求、代子行程執行它。工人可以被徹頭徹尾地攻陷、卻仍只能請求;它永遠無法直接碰到那把金鑰。
現在看看這三項技術疊合得多麼乾淨,因為在實務上它們是被層層疊在一起、而非從中擇一。那個無特權的工人啟動、開好它將需要的檔案描述符與通訊端,然後放掉它不需要的每一項權能、並裝上一個緊緻的、預設拒絕的 seccomp 過濾器——此後即使被劫持,它也永遠無法重獲權限。那個特權監督者保留唯一要緊的那項權能、並對外開放一個刻意極簡的請求介面。其結果是:一個在工人裡打出完美漏洞利用的攻擊者,發現的是:沒有權能可濫用、幾乎沒有系統呼叫被准許,而唯一能搆到的強大角色,只能透過一條細小而經過驗證的訊息通道抵達。每一層都蓋住其他層留下的縫隙。
- 辨認信任邊界。找出不受信任的輸入最初進入之處——剖析器、網路讀取器——以及真正的權限所在——那把私鑰、那個只有 root 能做的操作。那兩端,正是一道信任邊界必須穿行其間之處。
- 拆成多個行程。把危險的剖析放進一個無特權的工人、把權限放進一個小監督者,於是攻破其一並非攻破其二。這是特權分離的核心一手。
- 在工人裡放掉特權。盡可能早,在它開好所需之物之後,卸下它能卸的每一項權能、並把它的憑證降為一個無特權的使用者——你已經放棄的權限,偷不走。
- 夾住系統呼叫。裝上一個預設拒絕的 seccomp 過濾器,只放行工人仍合法需要的那少數幾個呼叫,於是一個被劫持的工人開不了檔案、exec 不了外殼、也搆不著網路。
- 讓監督者疑神疑鬼。讓特權那一側依一份固定而極簡的政策,驗證來自工人的每一個請求,並假設工人懷有敵意——監督者如今是剩下的整片攻擊面,所以讓它小之又小。
沙箱化買得到與買不到什麼
值得以一筆誠實的帳作結,因為一個被過度推銷的沙箱是危險的東西。這些機制是真正隔離據以建造的根基:瀏覽器分頁、容器執行期、現代的應用程式商店,全都倚靠 seccomp、權能與命名空間——而同一個最小權限的想法,在以權能為基礎的安全設計裡、以及像 WASI 沙箱這樣的 WebAssembly 執行期裡,更乾淨地再次出現。做得好時,一個沙箱真切地把一個完整的程式碼執行漏洞利用,變成一條令人挫敗的死路,而這是一個巨大而真實的勝利。
但絕不要把一個沙箱誤當成一道牆。它並不阻止錯誤的存在、也不阻止行程被劫持——它只限制劫持者能搆到什麼。而它所畫的那道邊界,恰恰好到它有多小:一個多放行了一個系統呼叫、或多授予了一項太寬的權能、或其監督者誤處理了某一個請求的沙箱,就有一個洞,而攻擊者以獵捕那些洞為生。更糟的是,強制執行沙箱的那個核心本身就是一片浩瀚的表面——一個被准許的系統呼叫的實作裡的錯誤,就能讓一個被沙箱化的行程逃脫,這就是為什麼沙箱逃脫是被高度看重的漏洞。
握住正確的心智模型,整個這一級就各就各位。第一、二篇是攻擊者如何進來;第三篇抬高進來的代價;本篇在他們進來之後縮小那份獎賞;而第五篇將展示一個類別——像 Spectre 這樣的側通道——它根本不發出任何被禁止的系統呼叫就洩漏祕密,徹底從系統呼叫門底下溜了過去。每一層都假設它前面那一層會失效。那個假設不是悲觀;它正是縱深防禦之所以管用的全部理由,也是一個系統安全工程師所能懷抱的、最重要的一種思維習慣。