從一條原則到一本帳
在本階的第一篇導覽裡,你認識了最小權限原則:每個程式、每個使用者,都應該剛好握有它做事所需的權力,一滴都不多。這是個美好的目標,但目標不是機制。要真正落實它,作業系統需要一份精確、可查核的紀錄,記下誰被允許對哪樣東西做什麼。少了這樣一本帳,「最小權限」就只是個願望。這篇導覽談的就是這本帳——它最乾淨的數學形式,以及真實系統儲存它的兩種非常不同的方式。
讓我們先釘牢整個領域所倚靠的三個詞。主體(subject)是個主動的行動者——一個使用者,或更有用地,一個執行中的保護域(你在第一篇導覽裡認識過這個想法:它是一個行程在某個時刻所握有的那一組權力)。物件(object)是個被動、被作用的東西——一個檔案、一塊記憶體、一個網路插槽、一台印表機。而權限(right,或稱存取模式)是個動詞,主體可以對物件施展它——讀、寫、執行、刪除。保護一路歸結到底,不過就是反覆回答同一個問題:此刻,這個主體對這個物件,握有這個權限嗎?
把整套政策當成一張巨表
美妙的想法來了。想像一張巨大的格子表。給每個主體自己的一列(row),給每個物件自己的一欄(column)。在主體 S 的列與物件 O 的欄相交的那一格裡,寫下 S 對 O 所握有的、恰恰好的那些權限。這張格子表就是存取矩陣,它是你系統保護政策完整、精確的陳述——沒有更多東西被藏起來,也不需要更多。要查「S 可以寫 O 嗎?」你做一件無聊到不行的事:去看 (S, O) 那一格,瞧瞧裡頭有沒有列著「寫」。整個保護,就是讀一張表裡的一格。
| file: essay | file: payroll | printer | domain: D2 --------------+---------------+-----------------+-----------+------------- domain D1 | read, write | -- | print | switch --------------+---------------+-----------------+-----------+------------- domain D2 | read | read, write | -- | -- --------------+---------------+-----------------+-----------+------------- domain D3 | -- | read | print | -- to answer "may D1 print?" -> look in cell (D1, printer) -> yes
留意那張圖裡狡猾的轉折:一個保護域也可以以欄的身分出現,使它成為一個物件。(D1, D2) 那一格裡的「switch」權限,意思是「當你在 D1 中執行時,你被允許移動進入 D2」。這就是一個程式如何在執行途中正當地改變自己權力的方式——而這正是攻擊者試圖去扳彎的那個危險樞紐,第三篇導覽會以權限提升(privilege escalation)的名義來探討它。矩陣不只列出權力;它也管轄權力如何被傳遞。
為什麼沒人整張存矩陣
矩陣是一種絕妙的思考方式,卻是一種糟糕的儲存方式。想像一台真實的機器:成千的使用者與行程,數以百萬計的檔案。一張字面上的格子表會有數百萬欄乘以數千列——數十億格——而且絕大多數都是空的,因為大多數主體對大多數物件根本沒有任何權限。把一張龐大、幾乎全空的表存起來,會浪費極大的空間,還讓每次變動都變慢。矩陣是稀疏的,所以我們從不把它整張寫出來。真實系統只保留非空的那些格——而精巧之處,在於你從哪個方向切開這張表來做這件事。
把一張表切成有用的長條,剛好有兩種自然的切法。你可以沿著欄切——為每個物件,蒐集「可以動它的主體有誰、又能怎麼動」的清單——或者你可以沿著列切——為每個主體,蒐集「它可以動哪些物件、又能怎麼動」的清單。同一張矩陣、同樣的資訊,卻是兩種截然不同的資料結構,個性也截然不同。欄的切片叫做存取控制清單(access-control list);列的切片叫做權能清單(capability list)。這篇導覽接下來,就是這兩者的故事,因為你日後遇到的每一套保護系統,骨子裡都是其中之一(或兩者的混合)。
沿著欄切:存取控制清單
拿一欄——一個物件——把整欄跟那個物件存在一起。結果就是一張存取控制清單(ACL):附在每個檔案上、一張寫著「Ana 可讀可寫;會計群組可讀;其他所有人,什麼都不行」的清單。這正是你早已身處的世界。你在檔案那一階認識的九個權限位元——寫成 rwxr-xr-x、用 chmod 755 設定——就是一張形狀固定、迷你的 ACL,被擠進寥寥幾個位元裡:三個主體(擁有者、群組、其他),各三個權限(讀、寫、執行)。一張完整的 ACL,只是拿掉「只能有三個主體」這條限制,讓你能指名個別的使用者與群組。
當你的問題以物件為中心時,ACL 大放異彩:「誰可以動這個檔案?」答案就釘在那兒,附在檔案上——容易讀、容易稽核、也容易撤銷(把一個名字從清單上劃掉,那個主體的存取權立刻就沒了)。最後這點很要緊:撤銷正是 ACL 的一大強項。反面則是相反的問題——「Ana 在整個系統裡能動的所有東西?」——這在 ACL 的世界裡答得很糟,因為你得打開機器上每一個物件、掃描它的清單找 Ana 的名字。ACL 讓「逐物件」的問題變便宜,卻讓「逐主體」的問題變昂貴。
沿著列切:權能
現在從另一個方向切。拿一列——一個主體——把整列跟那個主體存在一起。每個主體隨身帶著一張清單,列出它可以動的物件、以及它對每個物件握有的權限。那張清單裡的一個項目——「這個物件、這些權限」——就是一個權能(capability)。心裡的畫面是一串鑰匙圈:主體不去翻一張栓在門上的清單;而是握著一把鑰匙走上前,鑰匙合,門就開。一個權能,是一個無法偽造的權證,它同時指名一個物件、又授予對它的特定權限,全包在一個束裡。要行動,你出示權能;系統就認它,不必去查任何人。
讓權能安全的訣竅,是無法偽造——你絕不能自己亂塗一把鑰匙出來。系統用幾種方式達成這點:把權能清單放在核心裡,使用者程式碰不到、改不了(一個行程只握有一個指向它的索引,很像一個檔案描述符是個代替核心所保護項目的號碼);在硬體層為記憶體打上標記,使權能無法靠普通的算術製造出來;或者用一個密碼學簽章把每個權能包起來,核心驗得了,偽造者卻複製不出。每一種情況裡,重點都一樣:握有那個權證本身就是那份權限,所以檢查飛快,而且天生就是局部的。
權能能便宜地回答「逐主體」的問題(「這個域能做什麼?」——讀它的清單就好),也讓委派變得優雅:要分享存取權,你把鑰匙複製一份交給某人,不必經過中央去編輯。但它們把 ACL 的強項倒了過來:撤銷很難。一旦鑰匙的副本四散流通,要把存取權收回,就得把每一份副本都追捕回來——像試圖召回一把你早已借出去的實體鑰匙。這就是那個深刻而誠實的取捨:ACL 讓撤銷容易、卻讓「按主體列舉」變難;權能讓委派與「逐主體推理」變容易、卻讓撤銷變難。哪一個都不是單純地「比較好」——它們是對偶,是讀同一張矩陣的兩種方式。
保護環:用硬體來執行矩陣
這一切,強不過那道阻止主體乾脆改寫自己那一列的牆。那道牆是築在 CPU 裡的。回想最早那一階的兩層劃分——核心模式對使用者模式,由單獨一個模式位元決定特權指令准不准。這個想法的一般形式,是一疊層層相套的保護環,從最受信任的往內、到最不受信任的往外編號:環 0 是核心,擁有全部權力;外面的環則一圈比一圈被關得更嚴。一個身處外環的主體,在物理上就無法執行內環的特權指令、或碰觸內環受保護的記憶體——硬體會拒絕。
保護環,正是「執行保護的是作業系統、不是檔案」如何從一個有禮貌的約定,變成一個硬體事實。往內跨越——一個使用者程式向核心請求某樣東西——不能隨隨便便地做;它必須通過唯一一道有人看守的閘門,也就是系統呼叫,那是從外環通往環 0 唯一獲准的門。這就是最小權限原則穿上了硬體制服:大多數程式碼在權力有限的外環裡執行,只有那小小的、經過仔細審查的核心在環 0 裡執行。你能把那個完全受信任的核心保持得越小,你的可信計算基(trusted computing base,那組一旦被攻陷就會弄垮你全部保護的元件)就越小——這個主題,本階接下來會一再回到。