執行人: leowu0411
解說錄影
HeatCrab
在正式進入說明 Nabla container 之後使用了 hypercalls 這個名詞
unikernel 與一般通用 VMM 之間的 hypercalls 依然暴露出大量潛在攻擊面
請問 hypercalls 也是系統呼叫(syscall)的一種嗎?
還是其實兩者間毫無關聯,hypercalls 是特別用於虛擬機中一種操作的稱呼?
Hypercalls 是客體核心與 hypervisor 之間的特權呼叫機制,功能角色類似於使用者空間對核心的 syscall,但層級不同;當客體需要執行必須由 hypervisor 代辦的特權動作(如關機、重啟、熱插拔 vCPU / 記憶體或特殊 I/O 控制)時就會呼叫。
leowu0411
MikazukiHikari
Nabla 容器採用他們專為 unikernel 設計的輕量虛擬機器監控器 Nabla Tender。其關鍵理念是:unikernel 與一般通用 VMM 之間的 hypercalls 依然暴露出大量潛在攻擊面,因此若改用一個專為 unikernel 設計、且僅允許極少數系統呼叫的監控器,將可顯著提升安全性。
Nabla Tender 限制 hypercalls 並將其轉換為極少量 syscall 的設計確實縮小了攻擊面,但是否會因此犧牲一些必要的 I/O 效率或兼容性?例如在需要高頻率文件或網路操作的情境下,是否會因此影響效能?
- 縮小攻擊面主要是刪減客體用不到的虛擬硬體與對應 hypercall,讓監控器只暴露最小化介面。對於應用原本就會用到的功能(檔案讀寫、網路 I/O 等),仍透過白名單 syscall (read / write …) 通知宿主核心,I/O效率不受影響。
- 相比於一般 VMM (KVM/QEMU),Nabla Tender 支援的 syscall 集合極少,若應用依賴於被封鎖的系統呼叫,的確需要使用別種執行環境,但考量到其為針對單一用途 unikernel 而設計,而非運行一般作業系統,對雲端微服務場景而言,這種兼容性犧牲是可接受的。
leowu0411
I-Ying-Tsai
會不會有些情況有可能會產生新的 syscall 指令而繞過 zpoline?
會。有可能的繞過的情況已於文章中敘述,可參見 當前限制 leowu0411
yy214123
被大多數應用採用「猜測式」的 seccomp policy,他的優缺點是什麼?
猜測式 seccomp policy 指我們無法得知每個應用需要的系統呼叫為何,故名猜測式:
- 如果 seccomp policy 太嚴格可能導致應用非必要中止,反之則會留給攻擊者大的攻擊面,通常猜測式會採取後者,以確保應用能夠正常運行
- 文中會提及此概念係因:當政策針對的是 vmm 而非應用時,我們就可以制定客製化的政策且不須頻繁更動,因為 vmm 對宿主的需求基本固定,這也是 vm 較容器安全的另一個原因,其較好預測並進行管理。
- 其優點,由於不須額外分析該應用使用的系統呼叫較易於部署,缺點為安全性較低。
leowu0411
horseface1110
如果 Nabla Linux 的 seccomp 攔掉一個 syscall,但應用沒明確處理錯誤,開發者要怎麼知道是哪個 syscall 出錯?
針對本專案實際執行的 um-nommu(z),其 seccomp 政策為:攔截所有發起自落在 UML 「使用者空間區段」(虛擬記憶體區段) 的系統呼叫,一律改為發送
SIGSYS
,而該信號處理函式會跳至 UML 核心系統呼叫處理常式執行客體系統呼叫,故在此專案:
- 應用層不會收到未處理的
-ENOSYS
之類錯誤 ── 所有呼叫都被全面轉送到 UML 核心- 目前的 SIGSYS 訊號處理程式不會直接列出被攔截的系統呼叫,但它會收到含有暫存器快照的結構體;只要把其中的 RAX 值印出,就能得知是哪個 syscall 被攔截。
(可參見檔案 arch/um/nommu/os-Linux/signal.c)- 補充:當關掉 zpoline,um-nommu 即利用此方式全面攔截系統呼叫,但效能會比開啟 zpoline 差,可參見分析 um-nommu(s) 項目
leowu0411
觀看 The Most Lightweight Virtual Machine Monitor Is No Monitor at All、紀錄認知和提問,並測試 nabla-linux
理解 Unifying LKL into UML 的開發目標和手段,搭配研讀 LKL: 重用 Linux 核心的成果
研讀 Porting Linux to Nabla Containers
Nabla Linux 以單一非特權使用者程式執行,在 seccomp 的約束下只允許 12 種系統呼叫,藉此獲得與傳統虛擬機相當的隔離效果,同時保有容器級的輕量特性。Nabla Linux 結合 User-Mode Linux (UML) 與無 MMU 架構;核心、musl 與 busybox 均以 nommu 方式建置,使得整個系統得以在約 6 毫秒內完成啟動,並成功執行多個未修改的 Alpine 應用 (如 Python、nginx、redis)。
為了進一步降低系統呼叫開銷,變體 um-nommu(z) 利用 zpoline 進行動態系統呼叫轉換,而 um-nommu(s) 則單純依賴 seccomp,二者皆可比原本實作更快
此做法的代價來自 nommu 先天限制:程式必須是 PIE,可用 vfork(2) 而非 fork(2),各行程共用同一定址空間,mmap(2) 僅支援有限功能,且需將宿主核心的 vm.mmap_min_addr 設為 0 以便 zpoline 修改位址 0x0。x86-64 是目前唯一驗證過的子架構。zpoline 的轉換目標僅限於 execve(2) 載入的執行檔與直譯器,動態連結程式庫與 JIT 於執行期間產生的程式碼仍未涵蓋,可考慮透過LD_PRELOAD
延伸。綜合而言,Nabla Linux 展示在極小系統呼叫面與無監控程式情境下,仍能為容器工作負載提供 VM 等級隔離的可行方向,展現 UML 新的應用。
搭配閱讀 Documentation/virt/uml/nommu-uml.rst
自 nabla-linux 和 thehajime/linux,以新的 Linux 核心和相關套件建立 Docker 映像檔
thehajime/linux 已升級 (rebase) 到接近 Linux v6.16-rc1
參照輕量級容器實作和擴充,作為 rootless container 的替代方案
TODO : PIC vs PIE
展開以上內容到本頁面,使文件結構清晰且持續追蹤相關議題
參見 Say Goodbye to Virtualization for a Safer Cloud
參見 Linux 核心設計: 作業系統術語及概念
在雲端設計與營運中,能夠正確地將各租戶(tenant)彼此隔離,是一項基本能力。然而,目前尚無一項明確的、可量化的隔離指標。最為常見的區隔方法可以透過衡量雲端應用(guest)與宿主(host)之間介面層級是偏向高階(抽象)還是低階(接近硬體層)。
例如,一個行程預期的是高階抽象的 POSIX 介面,而一個 VM 則預期接觸的是低階的虛擬硬體介面。
以 linux kernel 作為雲端環境中提供租戶隔離的底層系統當例子:
cgroup
等機制作為隔離一般情況下,會認為客體與宿主間溝通的介面愈是低階愈是安全,這是因為宿主可以將客體不需要的介面完全封鎖,藉此限縮給予攻擊者操作的攻擊面 (attack surface)。
對於多租戶 (Multi-tenant) 的環境來說,我們希望透過某須手段,使得客體都像運行在自己的環境當中,不論客體如何在裡面搗亂,都無法影響宿主端的環境,這樣的環境稱為沙盒 (SandBox)。簡言之,假使一個客體能自己辦到的任務越多、依賴宿主愈少,我們就可以稱這個客體具備更加完善的沙盒化,在最極端的情況下,甚至能夠單除依靠軟體 (如 QEMU
) 模擬指令來替代硬體支援,將雙方溝通的介面層級降至機器指令層級。以下介紹目前主流的隔離方法。
虛擬機器 (VM) 藉由虛擬機器監控器 (VMM) 作為客體與宿主間的中介層,提供硬體模擬供給客體,使客體能夠於該環境執行完整的作業系統,廣為人知的專案為 QEMU
,雖然硬體模擬帶給宿主完善的保護,但比起容器更加笨重,開機就需要數倍的時間,且部份虛擬硬體甚至不會被使用,造成硬體使用效率降低。
旁註:雖然 QEMU
能夠逐道指令翻譯完整模擬硬體環境,但效能會極度被拖垮;通常與 linux 核心模組 KVM
搭配使用,後者提供 CPU 與記憶體的硬體虛擬化支援,使 QEMU
在啟用 KVM
加速後只需處理 I/O 模擬,CPU 指令能夠直接由 VT-x/AMD-V 執行,極大幅度提昇其性能。
MicroVM 因而誕生,其為極大程度精簡化的 VM,將用不到的功能給移除,只保留最低限度的硬體模擬,透過限縮功能與降低複雜度來提升 VM 的效能與使用資源,這樣的精簡化也為其帶來極快的啟動速度。
QEMU
的替代品,同樣使用 KVM
來建立虛擬機器,作為在 AWS Lamda 這類需快速啟動運作實體的使用情境的解決方案參見 Making Containers More Isolated: An Overview of Sandboxed Container Technologies
與 MicroVM 目標相同,但核心理念為「精簡客體作業系統」而非精簡硬體模擬;一般的作業系統是為了支援各式各樣的應用程式而設計,因此它的核心內含大量的驅動程式、通訊協定函式庫與排程器。然而,在今日的雲端應用中,大多數 VM 通常只承載一個單一的應用,例如 DNS, proxy 或資料庫。由於每個應用程式實際只會使用核心中的一小部分功能,其餘未使用的部分不但浪費系統資源,還增加潛在攻擊面。且程式碼基底越大,開發者就需要管理越多的漏洞。
這些問題促使電腦科學家開始設計僅具備最少核心功能、專為單一應用設計的作業系統 (single-purpose OS),於此目標下誕生的產物即是 Unikernel;Unikernel 的架構將傳統核心拆分成多個函式庫,然後只挑選出應用所需的部分,與應用程式一同封裝成單一映像。與 VM 類似,unikernel 會部署並執行於 VMM 之上。由於其體積極小 (small footprint),unikernel 可快速開機與擴展。
且因為 unikernel 映像中僅包含應用所需的函式庫,能夠不包含 shell,攻擊者幾乎無從下手,攻擊面極小。攻擊者即使成功入侵,也僅能影響單一的 unikernel 實例,無法擴散。
上述文章 (撰寫於 2019) 提到一件有趣的事,Docker 在 2016 年收購 unikernel 新創公司 Unikernel Systems 時,外界曾期待 Docker 能將 container 打包為 unikernel,但直到該文章執筆時,仍未見任何整合的跡象。
查詢後發現直到現在仍然沒有這方面的跡象,可能是 Docker 更著重於可攜性與通用容器?
但值得注意的是,unikernel 與 container 在許多方面具有相似性:兩者皆為單一用途映像 (single-purpose image),且具備不可變性 (immutable),也就是映像內容在部署後不可變更。更新應用程式時,總是會建立新的映像。
此外 Unikernel 藉由限縮某些特性來提升整體效能,例如:
上述兩項技術在隔離宿主與客體來看遠優於容器化技術,容器直接運行於宿主核心,因此可以呼叫完整宿主 Linux 系統呼叫集合,多數針對宿主核心的攻擊在容器中仍然有效。若容器被入侵,將會暴露出大量的宿主核心攻擊面。
但問題是現在的 CI/CD 流程幾乎以容器進行佈署,這是由於其不須更改 legacy 程式碼,且不要求額外物件即可運行於宿主,跟一般行程一樣,極高的可攜性加上幾近於零的佈署成本奠定了這項技術的地位。
反觀上述提到另外兩種技術,microVM 多數在實作上疊加一層 Container 的管理介面,例如 VM 起來後內建 Container 的環境,能夠直接部署 Container 來應用,透過這樣的設計讓使用者可以繼續使用 container 般的操作流程同時又享有 VM 的安全性。而 Unikernel 則須替換原始碼的系統呼叫改為呼叫 libOS,且同樣需要為其準備環境方能運行於宿主。
SandBox Container,其目標為利用某些手段,使得以行程為基礎的容器也能夠運行於沙盒內,具體實現手法是攔截容器向宿主發送的系統呼叫,並導入客製化的 handler;由 google 維護的 gvisor 即是此類型專案,據官方文件說明,其可以被認為是合併的客體核心和 VMM,或是增強版的 seccomp
,但方法與其更貼切的技術是 UML (User Mode Linux),這也是本次專題的核心議題,事實上看完以下內容後也可以發現他們的概念與實作目標確實極度相似。
上述提及的 seccomp
(secure computing) 是 linux 提供的一種安全機制,用於限制行程可執行的系統呼叫,實際上透過這項際有機制我們就可以攔截所有行程發出的系統呼叫,並導向我們自訂的邏輯,其有兩種模式:
read
, write
, _exit
, sigreturn
等),任何其它呼叫都會被強制終止prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, …)
或 libseccomp API 設定過濾策略;這些策略會被編譯為 BPF bytecode 後傳給核心,核心在經過 verifier 驗證後,將此過濾器綁定至目前執行緒。struct seccomp_data
資料結構,並根據上一步制定的策略執行相應邏輯。seccomp_data
,交由 BPF 程式執行一次判斷,並依回傳值決定動作
SECCOMP_RET_ALLOW
(允許)SECCOMP_RET_ERRNO
(返回錯誤碼)SECCOMP_RET_TRAP
(觸發 SIGSYS
)等行動那麼為何 Docker 不直接以 seccomp
限縮與宿主溝通的介面,還需要額外的物件如 gvisor 來實現沙盒化呢?
原因在於為任意的、以前未知的應用程式可靠地定義一個策略是極其困難的。若設計過於嚴格的 seccomp policy,將可能導致應用程式不必要地提前終止。
事實上 Docker 也能夠透過 seccomp
限縮系統呼叫,但政策非常寬鬆,預設為禁止 300+ 系統呼叫中的 44 種。
Nabla Container 落實上述 Unikerenl 的理念,其關鍵思想為,透過將應用連結成一個函式庫作業系統 (library OS,unikernel) 打包成單一映像檔,將許多原需透過系統呼叫完成的功能改由應用程式自行處理,以減少存取宿主端資源,從而縮小攻擊面。
Nabla 容器基於 unikernel 的思想提出 "Unikernel as process" (以行程的形式運行的 Unikernel) 讓 unikernel 應用程式以 process 的形式,在一個專用的 VMM 上或者直接於宿主上運行。
不採用一般常見的 VMM (如 QEMU),反之,Nabla 容器採用他們專為 unikernel 設計的輕量虛擬機器監控器 Nabla Tender。其關鍵理念是:unikernel 與一般通用 VMM 之間的 hypercalls 依然暴露出大量潛在攻擊面,因此若改用一個專為 unikernel 設計、且僅允許極少數系統呼叫的監控器,將可顯著提升安全性。
Nabla Tender 會攔截 unikernel 傳送給 VMM 的 hypercalls,並將這些 hypercalls 轉換為對應的 Linux 系統呼叫 (如下圖)。
Solo5 是一個具備沙盒隔離功能的執行環境,主要目的是用來執行以各種 unikernel 建構的應用程式:若將 unikernel 視為使用者行程,則這個行程相較原始應用程式執行的工作更多,而 solo5 意在提供此類行程與宿主作業系統或 hypervisor 間的介面,以下為其設計目標:
此外 Solo5 引入名為 "tender" 的概念。tender 是負責在載入或執行期間「照顧」(tending to) 客體應用的元件,透過選用不同 tender 變體,unikernel 能夠運行於不同宿主環境。以下介紹二種變體:
solo-hvt
:hvt
(hardware virtualized tender),在此模式下 solo5 提供給 unikerenl 的環境為一個運行於 KVM 之上的輕量虛擬機器,屬於虛擬硬體隔離,tender 在這裡扮演的腳色相當於傳統 KVM/QEMU 架構中,QEMU 所扮演的角色,但程式碼規模比之小數個數量級。solo-sqt
:與 VM 架構中,unikernel 與 monitor 之間的介面提供隔離不同,當 unikernel 以行程形式執行時,其隔離性來自於 tender 與宿主系統之間的介面,原因在於,當 tender 行程跳躍至 unikerenl 的entry point 時,行程本身就「成為」了該 unikernel,故跳躍前,tender 必須配置好 seccomp 過濾器,封鎖所有 Nabla Tender 不需要的系統呼叫。
與大多數應用採用「猜測式」的 seccomp policy 相比,tender 可以明確指定哪些系統呼叫被允許,歸功於 unikerenl 與宿主的溝通建立在 hypercall 與系統呼叫的一對一映射,能夠明確定義出白名單。
具體而言,Nabla 容器執行時僅允許極少數幾個系統呼叫,其餘所有系統呼叫在開啟 seccomp
後一律被阻斷,根據官方說明,Nabla 容器運行應用程式只需使用約 7 個 Linux 系統呼叫,包含 read
, write
, ppoll
, exit_group
, clock_gettime
, pwrite64
, pread64
等
Hppercall | System Call | Resource |
---|---|---|
walltime | clock_gettime | - |
puts | write | stdout |
poll | ppoll | net_fd |
blkinfo | - | - |
blkwrite | pwrite64 | blk_fd |
blkread | pread64 | blk_fd |
netinfo | - | - |
netwrite | write | net_fd |
newtread | read | bet_fd |
halt | exit_group | - |
上方表格展示 hypercall 到系統呼叫的一對一對應關係:
blkinfo
與 netinfo
僅回傳 boot 階段設定好的裝置資訊,因此不需實際呼叫系統呼叫walltime
與 halt
幾乎是直接轉譯為對應的系統呼叫上圖很好地展示了 Nabla 容器與傳統 Docker 容器的對應關係。和 runc 的角色相同,runnc 也是一個容器 runtime 工具 (且符合 OCI 規範) ,其目的亦用於建立容器。runc
透過呼叫 Linux 的 cgroup
, mount
, namespace
等核心功能來完成容器建置,runnc
則會依據所選的後端執行方式呼叫不同的宿主功能:
solo5-hvt
,則 runnc
會透過 /dev/kvm
API 來建立一個硬體虛擬化的 Nabla 容器solo5-spt
,則會利用 seccomp
沙盒與創建 tender process 等方式建立執行環境。TBD
nabla-linux
為落實 Nabla Container 概念中一種實作方式,其開發目標同樣希望解決傳統容器直接運行在宿主核心的問題,但同時希望不依賴於額外的 VMM (例如 Solo5 tender/QEMU/KVM) ,反之以更加輕量化的方式達到監管。
為此,開發者把目光放到 User-Mode Linux (UML) :
User-mode Linux 是將 Linux 核心移植到使用者空間的一個版本。它在 Linux 宿主端以一組行程 的形式執行一個 Linux 虛擬機器。一個 UML 虛擬機器幾乎能夠執行與宿主端相同的一組行程。
具體來說,UML 本身就包覽了 Nabal Container 中,Library OS 與 tender 的工作:
針對性能,由於 UML 高度依賴 ptrace
來攔截客體使用者空間與客體核心之間的溝通造成極大的效能開銷,nabla 容器為此實作 nabla-linux
。
nabla-linux
結合了 UML 以及不使用記憶體管理單元(no-MMU)的設定,以一個不具權限的使用者空間行程方式運行整個 Linux 核心。此外其透過 seccomp
限制僅使用約 12 個宿主系統呼叫。這些更動大幅降低 UML 使用者空間與核心溝通的開銷。
但同樣帶來若干負面影響:
fork()
fork()
,僅允許使用 vfork()
+ execve()
,確保子行程在轉換為新行程映像前不會修改共用的定址空間。以下首先介紹一般 UML 如何實作,藉此理解模擬虛擬記憶體以及利用 ptrace
機制作為客體核心與客體行程間的橋樑為什麼會造成效能瓶頸。
User-mode Linux 是將 Linux 核心移植到 userspace 的一個版本。它在 Linux 宿主上以一組行程的形式執行一個 Linux 虛擬機器。一個 UML 虛擬機器幾乎能夠執行與宿主相同的一組行程。以下介紹其實作時的考量
由於 UML 核心對於宿主來說為一般執行緒,故無法透過硬體判斷是否進入特權模式,因此 UML 採用 ptrace
系統呼叫的追蹤機制自行建構這個模式切換。
UML 中有一個特殊的 thread,其主要任務是使用 ptrace 來追蹤幾乎所有其他 threads。當一個行程處於 user space 時,其系統呼叫會被這個 tracing thread 截獲
當它在核心模式中時,其系統呼叫就不會被追蹤。這就是 UML 中對使用者模式與核心模式的區別方式。
從使用者模式切換到核心模式是由 tracing thread 處理的。當一個行程執行系統呼叫或接收到 signal 時,tracing thread 若有必要就會強制該行程轉入核心執行,並讓它在沒有追蹤系統呼叫的狀態下繼續執行。
反過來說,從核心模式回到使用者模式也是由 tracing thread 負責處理,但這次是由行程主動請求的。當行程在核心執行完畢 (無論是系統呼叫或 trap 結束) 時,它會對自己發送訊號。tracing thread 會攔截這個訊號,必要時恢復行程的狀態,然後讓行程在開啟 tracing 的狀態下繼續執行。
透過以上模式切換機制,能夠模擬系統呼叫 :
當 UML 行程呼叫系統呼叫,此系統呼叫將在 host 核心中被「取消」(annulled)。這是透過 tracing thread 將儲存系統呼叫編號的 register 改成 NR_getpid
,即 get_ptd()
,來完成的。
(無法直接跳過宿主系統呼叫,故選用無害的系統呼叫並無視回傳值)
當這個動作完成後,tracing thread 會將該行程的 registers 儲存至 thread structure,並套用一些預先儲存的狀態,接著發送信號給該行程使其longjump()
至核心區段。這個新狀態會讓行程開始執行系統呼叫處理常式,而後者會從儲存的 registers 中讀取系統呼叫編號與參數,然後呼叫對應的系統呼叫函式。
當系統呼叫執行結束後,行程會把回傳值存入其對應暫存器,然後請求 tracing thread (對自己發 signal) 將它切回使用者模式。tracing thread 會恢復之前儲存的暫存器快照,並讓該行程在啟用對系統呼叫追蹤的狀態下繼續執行。
類似於系統呼叫,當一個行程收到 signal 時,tracing thread 會比該行程更早察覺此事件。
當這種情況發生時,該行程會被轉為在核心模式繼續執行,但不會儲存其執行狀態,也不會套用任何新的狀態。UML 會針對所有重要的 signal 註冊自己的 handler,因此當行程被繼續執行時,會進入其中一個 UML handler,該 handler 會實作核心對該 signal 的解釋與處理邏輯。
在 UML 中是透過 SIGIO
來實作的。驅動程式會設置,在每當有輸入抵達時,系統就會產生一個 SIGIO
。
SIGIO handler 會使用 (如 select
) 來判斷是哪些 file descriptors 有等待中的輸入。接著,它會根據這些資訊判定每個 descriptor 對應的是哪一個 IRQ。一旦確定對應的 IRQ,它就會呼叫標準的 IRQ 處理程式碼來處理該中斷。
Memory faults 是透過 SIGSEGV
(Segmentation Fault) 來實作。
當一個 UML 行程發生無效的記憶體存取 (invalid memory access) 時,host 會為它產生一個 SIGSEGV
。
接著由 UML 核心中的 SIGSEGV handler 處理這個 signal,並判斷該記憶體存取是否是:
若是第一種情況,會呼叫 generic page fault handler,將新的 page 映射到該行程的地址空間中。
若是第二種非法存取,則會將 SIGSEGV
加入該行程的 signal queue。當行程嘗試返回使用者空間時,將會被這個 signal 終止,或者由行程本身的 signal handler 處理。
目前認為最複雜部份,與實體硬體上的 Linux 一樣,UML 也維護自己的分頁表 (page table) 和記憶體空間,但這套頁表只在 UML 內部有效,和宿主端的頁表彼此獨立。
由於 UML 核心以及 UML 上的行程對宿主而言都只是一般的使用者空間程式,宿主的硬體記憶體保護機制無法直接區分 UML 核心與 UML 使用者程式的權限。因此,UML 必須透過軟體方式落實自己的「使用者模式/核心模式」區隔,例如維護自有的頁表來管控各個 UML 行程可存取的區域。
UML 將一塊與虛擬機器「實體記憶體」大小相同的檔案映射到自己的定址空間,作為客體實體記憶體的映射區域。再將此檔案中的各個分頁對應映射到每個客體行程的虛擬定址空間中,以模擬傳統作業系統中分頁配置與回收。
值得注意的是,UML 核心程式的程式碼和資料本身也位於這些行程的定址空間中 (通常配置在高位址區域),對應到 UML 行程的角度則呈現為不可用的「記憶體空洞」 (unusable holes)。換言之,UML 會確保在客體行程的分頁表中,屬於核心的位址範圍不會映射給行程使用,以此來防止 UML 行程任意讀寫核心記憶體。若有 UML 行程嘗試存取這些受保護的核心位址,因為該位址對該行程而言並未映射,有效地造成「無效記憶體存取」,將導致和一般非法存取一樣的後果 (由 UML 核心捕捉並最終送出 SIGSEGV
給該行程)
如前所述,當一個 UML 行程在客體內發生無效的記憶體存取時,宿主會對對應的 UML 行程送出 SIGSEGV
訊號,而 UML 核心在宿主上註冊對 SIGSEGV
的訊號處理函式,因此當 UML 核心因 ptrace 捕獲此信號時,會進入客體核心的例外處理流程。UML 核心的 SIGSEGV handler 首先判斷此次記憶體存取導致的錯誤是否屬於有效範圍 (例如該位址屬於該行程的虛擬定址空間,但目前尚未映射實體頁面),或者為純粹非法的存取 (例如位址超出該行程的定址空間,或者位址屬於核心保留區域)。
SIGSEGV
訊號捕捉此事件並動態配置記憶體。SIGSEGV
排入該行程的訊號佇列中當 UML 將控制權切回該行程的使用者態時,便會根據 Unix/Linux 的信號機制對它送出 Segmentation Fault,導致該行程被終止 (除非該行程有自行定義的 SIGSEGV handler 可以攔截處理)。整體而言,透過上述機制,UML 成功地在使用者空間中模擬了傳統作業系統的虛擬記憶體管理:包含分頁配置、需求分頁 (demand paging) 以及記憶體存取違規的攔截處理等功能。
看到這裡最大疑惑點為,雖然 UML 維護自己的分頁表,但對於宿主端來說 UML 核心與 UML 行程皆位於使用者空間故客體行程仍然可以存取客體核心的記憶體位置,上述提到的 UML 攔截須真的觸發宿主端 page fault 才能夠觸發,那麼萬一 UML 核心頁切回客體使用者模式時未解除映射便導致使用者能夠直接對核心操作,換言之,其如何保證核心分頁在使用者模式下永遠保持無映射狀態,即上述「記憶體空洞」。
閱讀材料後發現,UML 在核心模式完成後會主動 munmap()
核心分頁方切換回使用者模式。當使用者錯誤存取未映射區段,將產生 SIGSEGV
,此時 tracing thread 便能夠介入並根據上述邏輯判斷操作是否合法。
註:上面介紹的機制為 TT 模式,雖然其已經顯得過時且幾乎不被使用 (除非在特殊情形下手動指定,目前預設模式皆為 SKAS0),了解其機制對理解 UML 的歷史演進與設計權衡仍有價值。
SKAS0 works pretty well now, it's faster and less complicated than TT mode,
and thus TT mode is mostly obsolete now. If you compile UML and run SKAS,
it'll auto-detect if your host kernel has the weird patches to support the
extensions for SKAS3, and if not it'll run SKAS0.
– Jan Engelhardt
UML 在實作上有三種模式:tt (Tracing thread) 模式以及 skas (separate kernel address space) 模式,而 skas 模式又細分為 skas0 以及 skas3 兩者間的區別以下會詳述。 tt mode 與兩種 skas 模式之間的主要差異在於 UML 如何在宿主上配置定址空間。
上圖可以看到三種 UML 執行模式在行程 (UML process) 定址空間的配置方式上有所不同。
SIGSEGV
訊號,並將所產生的頁錯誤資訊回傳給 UML 核心,以及用來修改行程的定址空間。
UML 行程與各模式中對應宿主行程之間的關係也由此而來。
注意用語:
在 tt 模式中,所有客體行程都運行在同一個宿主行程內不同的執行緒中,這是最初期的 UML 實作,由於所有客體行程接在同一個宿主定址空間,操作分頁與傳遞資訊最為直觀,但同時也正因為客體行程以及客體核心皆在同一定址空間、同一份宿主分頁表,為達到核心模式、使用者模式以及各行程間的記憶體保護,TT 模式在每一次切換皆須解除映射當前上下文所有分頁避免下一個上下文錯誤存取。
對於 tt 模式最大的問題仍舊為 UML 核心被放置在每個行程定址空間的上方 0.5GB,雖然可以在離開 UML 核心時將其記憶體設為唯讀 (或者解除映射),進入核心時再設為可寫,藉此保護核心記憶體不被行程更動,但這會帶來極大的效能損耗。
此外如上所述,針對系統呼叫、trap 等場景必須透過大量 ptrace 及訊號機制來模擬特權模式、攔截系統呼叫和中斷,進一步拖累效能。
為改善上述問題,UML 引入了 SKAS (Separate Kernel Address Space) 模式。SKAS 模式下,UML 核心運行於獨立的宿主定址空間。客體核心記憶體對 UML 行程完全不可見且不可存取,確保了核心資料的隔離與安全。由於不再需要讓所有執行緒共享同一地址空間,許多 TT 模式下的模擬開銷 (例如頻繁的 ptrace 操作和訊號傳遞) 得以消除,SKAS 模式在性能上明顯優於 TT 模式。
“skas mode does a system call with four host context switches, while tt mode does one with four host context switches and a host signal delivery and return. The speedup comes from losing that signal delivery…”
TT 模式在每次陷入核心時都多了一次訊號傳遞與返回的開銷,而 SKAS 模式僅需透過上下文切換即可完成,減少了額外開銷並利用較小的地址空間提升了效率,機制以下會詳述。
目前的理解應為在 TT 模式,一次 trap 將發生四次 context switch、四次 mode switch 以及一次信號收發,分別為:
而 SKAS 模式則為兩次 context switch 以及一次 mode switch:
與看到的資料略有落差,待進行實驗解惑。
最初的 SKAS 模式實作需要套用「SKAS3」修補 (patch) 於宿主核心,以提供特殊的機制,總題而言此修補須:
SIGSEGV
訊號後,提取分頁錯誤 (page fault) 資訊,例如錯誤發生的位址、存取類型與處理器旗標定址空間的操作是透過在 /proc
中新增的檔案 /proc/mm
落實的。
mmap()
munmap()
或 mprotect()
發生。請求的內容基本上就是對應系統呼叫的參數。這對將 UML 核心自 UML 行程分離是必須的,在 tt mode 中,處理分頁錯誤時只需呼叫 mmap()
,因為核心與行程共用相同的定址空間,但若行程與核心位於不同的空間,就需要其他方法。此修補允許我們透過寫入格式化結構至該檔案描述子來支援這些操作;映射、解除映射與更改分頁權限都是以此方式完成的,與修改與定址空間相關的 LDT entries 也是如此。(其實 tt 模式與一般作業系統相仿,但由於 UML 為使用者行程且無硬體 MMU 支援故無法比照原機制實作)
在 skas3 mode 中,則是每個虛擬處理器 (virtual processor) 對應一個主機行程。UML 核心運行於某個主機行程中,該行程會攔截其他主機行程的系統呼叫。在單處理器 UML 實例中,所有 UML 行程都在這個主機行程內執行。其運作方式是依需求切換不同的定址空間,這是由 UML 核心透過另一個 ptrace 擴充功能 PTRACE_SWITCH_MM
所控制的。此擴充允許被追蹤的行程切換其主機定址空間。
最後一點需要額外對宿主進行修補的是 UML 核心必須能夠從其他行程中提取分頁錯誤資訊。在 UML 中,行程的分頁錯誤會以接收到 SIGSEGV
訊號的形式表現出來。在 tt mode 中,這相對直觀,因為 UML 核心本身會收到 SIGSEGV
訊號,進入訊號處理函式時,其堆疊中就已經有所有的分頁錯誤資訊。
但在 skas3 mode 中。UML 核心不會直接收到 SIGSEGV
。 UML 核心只會收到來自宿主的通知,指出某個行程收到了 SIGSEGV
,然後 UML 核心會取消該訊號,使其實際上不會傳遞給該行程。因此,skas3 的修補新增了一個 ptrace 選項 PTRACE_FAULTINFO
,可從其他行程讀取頁錯誤資訊。
自此,這些宿主功能的新增就構成了 skas3 patch 。
SKAS3 雖然可以帶來顯著的安全性以及效能提昇但最大的問題是需要對宿主核心進行修補,為解決這個問題,SKAS0 因而誕生,此模式也是 UML 的預設模式。
其基本思想為:在每個 UML 行程的定址空間中插入少量程式碼,用來執行那些 skas3 模式需要透過宿主修補來完成的定址空間更新與資訊擷取工作。
這段插入的程式碼為 UML 核心在每個行程的定址空間頂部映射兩頁記憶體。其中一頁是為了 SIGSEGV
訊號框架而設,並設定為可寫入;另一頁則包含 UML 程式碼,並被設為唯讀。
mmap()
munmap()
或 mprotect()
。此頁面同時也包含 SIGSEGV
的訊號處理函式。/proc/mm
檔案描述子來要求定址空間變更。而訊號處理函式則實作了與 PTRACE_FAULTINFO
類似的功能:當接收到 SIGSEGV
訊號時,它會從自己的堆疊中讀取所有分頁錯誤資訊,並將資訊整理成 UML 核心可以讀取的格式。
最後,由於在未修改的主機核心中,無法在不建立新主機行程的情況下建立新定址空間,因此在 skas0 模式下每個 UML 行程仍需對應一個宿主行程。
總體而言,SKAS 模式成功在使用者空間落實了傳統OS的核心/使用者態隔離,大幅提昇了 UML 的安全性與效能,唯一明顯的缺點為是SKAS (嚴格而言是 SKAS3) 原本需要對宿主核心修補才能使用;不過後續出現的變體 SKAS0 解決了這個問題。
上述提到,UML 雖完美的實作 Nabla 容器中 LibOS 以及 tender 的訴求,但由於其實作方式為藉由 ptrace
不斷攔截客體行程與宿主核心之間的溝通接著導向客體核心,造成極大的效能瓶頸。
系統呼叫最少必須經歷二次上下文切換,而記憶體錯誤則須更多次 UML 核心行程與客體行程間的交互,相比直接跑在主機,額外增加許多工作量,nabla-linux 作為解決方案,更改了原生 UML 如何運行系統呼叫以及 trap 進入客體核心模式。至於外部裝置中斷,則承襲原生 UML,使用 Unix/Linux 信號機制進行處理。
參見 什麼是 Linux vDSO 與 vsyscall?——發展過程
參見 vDSO: 快速的 Linux 系統呼叫機制
nabla-linux 實作了一份修改過的 musl-libc(同 glibc,為標準 c 語言標準函式庫但更加精簡,常用於嵌入式系統,Alpine Linux 使用此函式庫),其中,函式庫之系統呼叫被替換為 vsyscall
而非呼叫宿主系統呼叫。
vsyscall
是 Linux 中用來加速某些系統呼叫的一種機制。時常與其一起被提到的技術為 vDSO
,儘管它們的基本功能相同(提供不需要進入核心模式即可存取的快速功能),但兩者在實作上仍有顯著差異。
vsyscall
是這兩種機制中較早引入的,最初設計是為了執行某些不需要特殊權限即可執行的系統呼叫。經典的例子是 gettimeofday()
,它僅需讀取核心所認定的當前時間。有些應用程式頻繁呼叫 gettimeofday()
,對即便極小的額外成本也很敏感。為了應對這類需求,Linux 核心允許將包含當前時間資訊的記憶體頁面以唯讀方式映射進使用者空間,該頁面也包含一個快速實作的 gettimeofday()
函式。透過這種虛擬系統呼叫,C 函式庫可以提供一個不需真正切換至核心模式的快速 gettimeofday()
。
藉由印出定址空間可以觀察到 vsyscall
的另一個特色,其分頁總是被映射至固定地址,映射起點始終為 0xffffffffff600000
。而應用程式能藉由輔助向量(auxiliary vector)的 AT_SYSINFO
項目得知函式在 vsyscall
分頁中的具體位置。
輔助向量(auxiliary vector,auxv)是在 Linux / ELF 程式執行時,由核心在使用者堆疊自動佈置的一段 key-value 陣列。以 Elf{32,64}_auxv_t
結構為單位,每個元素包含
a_type
:項目類型(用常數 AT_*
表示)a_val
:對應數值:可能是整數、也可能是指標但其實從 Linux 2.6.23 引入 x86-64 vDSO 開始,Linux 核心的開發者就已經下定決心要捨棄舊有的 vsyscall
分頁機制,這是由於 vsyscall
分頁必須定在固定位置,失去 ASLR 帶來的保護,攻擊者若能造成堆疊溢位,往往可以安排讓某個目標程式的函式「返回」到任意的記憶體位址。根據該位址上所存在的指令內容,可能引發各種任意行為。
virtual DSO (dynamic shared object),簡稱 vDSO,的出現解決了上述問題,不同於 vsyscall
分頁為一段程式碼,vDSO 將此分頁內容替換為 ELF 動態函式庫。編譯 Linux 核心時,會預先編譯出多個 DSO 檔案並嵌入至 Linux 核心中,當 Linux 系統啟動之際,核心將判斷處理器類型,選擇適當的 DSO 並複製到 vsyscall
頁面。應用程式能藉由輔助向量的 AT_SYSINFO_EHDR
項目得知函式在 vsyscall
分頁中的具體位置。
而 nabla-linux 藉由在 vsyscall
分頁安裝一小段跳板(trampoline)程式碼,使 musl-libc 能夠藉由此段跳板程式碼,呼叫 UML 系統呼叫處理常式,以下說明這是如何被完成的:
首先觀察 musl-libc 如何呼叫 linux ABI (擷取自 arch/x86_64/syscall_arch.h
) :
可以看到如果在 CONFIG_NOMMU
的設定下,系統呼叫之 ABI 會改由呼叫 call __sysinfo
,__sysinfo
為一個指標,指向存放於 vsyscall
分頁的 UML 系統呼叫處理常式。
而 musl-libc 是從哪裡取得 __sysinfo
的呢?可以從程式的進入點 __libc_start_main
找到,見第 12 行。
以上為 musl-libc 如何透過 vsyscall
繞過宿主系統呼叫,接著觀察 nabla-linux (linux-um-nommu) ,首先看到函式 create_elf_fdpic_tables()
(檔名:fs/binfmt_elf_fdpic.c
) ,此函式在負責把 argc/argv[]
, envp[]
與 auxv 排進新建立的使用者堆疊,建立 auxv 的工作也在此函式完成:
注意程式碼第 19 行,NEW_AUX_ENT(AT_SYSINFO, __kernel_vsyscall)
,將 AT_SYSINFO
項目設定為 __kernel_vsyscall
,為指向 UML 跳板程式碼的指標。
接著看到 __kernel_vsyscall
函式內部在做什麼以及其如何被連結至 vsyscall 分頁,我們可以從 vDSO 的 linker scripts 找到答案 (檔名 arch/x86/um/vdso/vdso.S
) :
VDSO_PRELINK
巨集,宣告 vDSO 分頁在運行時於定址空間的虛擬位置vdso-layout.lds.S
,該檔定義了最終動態函式庫 vDSO.so
內的內容以及 section 配置 :VDSO_PRELINK + SIZEOF_HEADERS
開始擺放.text*
的 section 放進輸出檔的 .text 區段FLAGS(5)
: PF_R|PF_X
(可讀、可執行、不可寫)。最後看到 arch/x86_64/um/entry_64.S
,其定義了 __kernel_vsyscall
實際內容,第 21 行的 call do_syscall_64
,我們終於看到如何進入 UML 的系統呼叫處理常式:
struct pt_regs
,但其位於 UML 核心能夠自由操作的記憶體空間,位於宿主使用者空間 (一般情況此區域位於 kernel stack 的最上方) 。struct pt_regs
,其中較須注意的是:
pushq %r11
:保存原使用者 stack pointerpushq %rcx
:return addresspushq %rax
:系統呼叫號碼PUSH_AND_CLEAR_REGS rax=$-ENOSYS
,完成 struct pt_regs
:
rax=$-ENOSYS
把 pt_regs->ax
設為 -ENOSYS
,為系統呼叫失敗的預設回傳值。pt_regs
結構」指標當作參數,供 do_syscall_64()
使用 (透過 %rdi
傳入) 。POP_REGS
(其與 PUSH_AND_CLEAR_REGS
相互對應),將堆疊上的通用暫存器狀態還原。%rcx
) 以及 RSP (%r11
),使控制權回到使用者空間 (注意 RIP 沒有被額外指令載回暫存器,這是因為 x86-64 的 ret
指令會自動從堆疊取得其資訊)。參見 zpoline: a system call hook mechanism based on binary rewriting
zpoline 為一種針對 x86_64 架構的系統呼叫攔截機制,其採用二進位改寫 (binary rewriting) 以及一段 trampoline 程式碼作為實作策略,在保證完整攔截(exhaustive hooking)且不覆寫不應修改之指令的情況下,zpoline 相較於現有方法,其 overhead 低了 28.1 到 761 倍。
透過上面介紹的 unikernel, nabla container,可以發現應用使用者空間的 OS 子系統能夠帶來許多優勢,理論上,透過系統呼叫攔截,我們應該能夠在這些子系統清楚透明地 (transparently) 套用於現有程式,而不需對 legacy 軟體作出修改。
然而最大的問題在於,當前 x86-64 為主的 UNIX-like 系統中(常見於伺服器環境),並無完美的系統呼叫攔截機制:
ptrace
LD_PRELOAD
) 無法做到完整攔截,因此無法用於要求高可靠性的系統,而另一類二進位改寫技術,則會覆寫原本不應修改的指令 (於以下段落解釋)在進一步解釋 zopline 之前,我們需要先認識 x86_64 架構中常見的跳躍指令: call
與 jmp
,兩者的區別為前者跳躍前會將下一條指令的位址(RIP)推入堆疊,以便之後可用 ret 指令返回;後者則無條件直接跳躍。
注意下文會看到指令 callq
其在 64 bits 架構下等價於 call
call
是語法上的泛稱,而 callq
在 GNU AT&T 語法中,表示 call to a 64-bit function(即 call quadword)。x86_64 處理器提供多種跳躍方式,大致可以分為以下二類:
call rel32
: 0xE8
+ 4 byte 相對地址jmp rel32
: 0xE9
+ 4 byte 相對地址call r/m64
: REX.W 0xFF r/m64
jmp r/m64
: REX.W 0xFF r/m64
REX.W
)r/m64
這個操作數型別表示 "register or memory (64-bit),而 在 x86-64 指令集裡只要看到 r/m…,會透過 ModR/M
位元組 (1 bytes) 來編碼。
mod
(2 bits):11
表示 r/m 欄位是暫存器;00/01/10
則表示間接記憶體操作。reg
(3 bits):在 0xFF
家族中用來選擇子指令:
010b = call r/m64
100b = jmp r/m64
r/m
(3 bits):目標暫存器或記憶體編碼:000b = %rax
, 001b = %rcx
…REX.W
為位寬 1 byte (0b0100WRXB
) 的 x86-64 專用「擴充前置碼」
W = 1
表示將「預設 32-bit」的通用運算提升成 64-bit,但 call/jmp
指令,不在意 32/64
,所以 可把 W
省掉。RXB
則用於擴充 ModR/M
位元組將其三個區塊各擴充 1 bits
rax
, rcx
, …) 擴展為 16 個 (加上 r8
~ r15
),若目標寄存器為後者,則需要把 B
設為 1WRBX
皆為 0 時,REX.W
位元能夠直接從指令省略結合上面的介紹,當目標位址已經存在某個暫存器,而我們欲利用其進行間接跳躍,opcode 可以為:
rax
作為目標暫存器 call *%rax
= 0xFF 0xD0
-> 0xD0 = 0b11010000
r8
作為目標暫存器 call *%r8
= 0x41 0xFF 0xD0
-> 0x41 = 0b01000001
0xD0 = 0b11010000
簡言之,zpoline 的運作方式是:將二進位程式中所有 syscall
與 sysenter
指令(其 opcode 分別為 0x0f 0x05
和 0x0f 0x34
,皆為僅佔用 2 bytes 的指令)替換為跳轉至自定 hook 函式的指令,藉此攔截系統呼叫。
了解 x86_64 架構下的跳躍指令後,不難發現,二進位改寫最大挑戰在於如何在僅有的兩個 byte 空間內,塞入能跳躍到任意記憶體位置(hook 函式)的指令?
如果硬塞進去就會覆寫到位於syscall
與 sysenter
之後的指令,引發非預期行為。
zopline 利用 x86-64 架構下 UNIX-like 系統的系統呼叫 calling convention,解決這個問題:
rax
暫存器,並執行 syscall
或 sysenter
,執行緒的上下文會被切換到核心,並呼叫預先註冊的系統呼叫處理常式。rax
值執行對應的系統呼叫。利用此特性,zpoline 將 syscall
/ sysenter
替換為 callq *%rax
指令,如上面提到其 opcode 為 0xff 0xd0
,亦為 2-byte 指令。因此,這個替換不會破壞相鄰的原始指令。
根據 calling convention,rax
中永遠儲存著系統呼叫編號。因此,這會導致程式跳躍至虛擬位址空間中某個位於 0 ~ 500(取決於宿主核心有多少個系統呼叫)之間的地址。
接著,zpoline 會在虛擬位址 0 上實作一段跳板程式碼,設計如下:
nop
指令(0x90
)nop
指令之後,插入一段程式,將控制權轉交至真正的 hook 函式。那麼整體執行起來就會像下圖:
callq *%rax
會跳轉至跳板程式中對應系統呼叫編號的位置(即一個 nop 指令);nop
指令「滑落(fall-through)」,直到最後一個 nop
之後的 jmp hook
被執行幸運的是,zopline 的開發者同樣有關注到 nabla-linux 這項專案,並將 zopline 整合進入 nabla-linux,取名為 um-nommu(z),為其帶來巨大的潛力。
上述提到,nabla-linux 攔截系統呼叫的方式完全仰賴於特製的 musl-libc,運行在 nabla-linux 上的程式必須使用該標準函式庫,且從標準函式庫外部所發起的系統呼叫仍舊無法攔截,諸多限制與低可攜性使其只能駐足於實驗性技術階段,距離解決真實世界問題仍有一段落差。
透過 zopline,程式原始碼無須作出任何更動或連結特定函式庫,即能做到系統呼叫完整攔截並送往自定義 hook 函式,接下來介紹 zopline 如何被整合進入 nabla-linux (檔案 arch/x86_64/um/nommu/zopline.c
) ,首先看到跳板程式碼設置:
NR_syscalls
(大約 512) 位置都填上指令 nop
(0x90
)__kernel_vsyscall
) 的地址入暫存器 r11
rcx
,模擬真實系統呼叫jmp *%r11
以跳到 __kernel_vsyscall
(即 nabla-linux hook 函式),最後將此分頁設為僅允許執行接著看到二進位改寫如何被實作,首先看到 load_elf_fdpic_binary()
函式,會在 execve()
流程的收尾階段被呼叫,依據目標 ELF 將程式碼與資料段映射至記憶體 (fdpic 表示載入位置無關執行檔,PIE) :
可參見 cs:app CH7
__weak
前綴,這使得 elf_arch_finalize_exec
這個符號在鏈結時被視為「弱符號」 (預設情況函式符號皆為強符號),而 zpoline 此時在實作另一份自定義的 elf_arch_finalize_exec
函式,即能夠在鏈結時將此處的弱函式符號覆蓋為自定義強函式符號 (以下為其簡化後版本)。__zpoline_translate_syscalls()
遍歷此 ELF 的 所有 PT_LOAD
可執行段:
0x0F 0x05
/ 0x0F 0x34
(syscall
/sysenter
) → 直接覆寫成 0xFF 0xD0 (call *%rax)
。exec
: 對主程式進行重寫interp
:對動態載入器 (ld-musl.so
, ld-linux.so
) 進行重寫
glibc
而言,「動態載入器」(ld-linux*.so
) 與函式庫本身 (libc.so
) 是 兩個獨立的 ELF 檔,也是我們較常看到的libc.so
,load_elf_fdpic_binary()
,接著進入使用者空間。在目前的 um-nommu(z) 實作中,zpoline 已成功整合進 nabla-Linux 核心,且不必依賴額外「特製」的使用者空間函式庫;只要可執行檔於 execve(2) 階段被載入,就能夠被 zpoline 就重寫。然而,這項轉換目前僅涵蓋:
exec_params
)PT_INTERP
直譯器(interp_params
)對於動態連結程式庫 (無論是在程式開始前載入,或執行期間透過 dlopen()
引入) 以及 JIT 即時產生的機器碼頁面,目前無法作為目標;若其中含有原生 syscall
指令,就可能繞過 zpoline。
採用靜態連結可將所有程式碼於 execve()
階段一次性納入 zpoline 的重寫範圍 (但仍無法阻止 dlopen()
與 JIT) ,雖然靜態鏈節檔案體積較大,但在 nabla-linux 這種單映像、單職責的容器環境裡,此取捨是較能夠接受的。
.so
的記憶體節省編譯 um-nommu,基本上與編譯正常核心相似,首先於 leowu0411/linux-nommu-z 取得已經整合 zpoline 的核心程式碼並進編譯,注意使用 ARCH=um
指定欲編譯為 UML 架構:
順利編譯後應該可以在 build
目錄下看到執行檔 vmlinux,我們可以利用其於使用者空間運行 linux 核心!執行以下命令觀察此執行檔可以發現,由於 No-MMU 環境下所有行程共用同一定址空間,因此核心以靜態連結方式產生。:
接著就跟運行一般核心一般,我們需要為這個 no-mmu 環境準備 rootfs,這裡採用 Alpine Linux,因其套件皆為 PIE(Position-Independent Executable),天生適合 No-MMU;具體做法是以 Alpine Linux 為基底,再將 BusyBox 與 musl-libc 置換為相容的 no-MMU 版本。:
注意這些調整僅牽涉編譯旗號或系統呼叫替換,無需修改程式邏輯,整體成本低於改寫程式,我們可以利用以下已經封裝的 docker 映像,並將其輸出為 .ext4 格式的檔案磁碟供 uml 作為檔案系統。
接著為 uml 準備 TUN/TAP
虛擬網路介面,用以連結宿主網路;TUN/TAP
介面是一種由宿主核心提供的虛擬網路裝置,允許 UML 與宿主互相交換網路封包。
TAP
模式提供 Layer-2 乙太通道,讓 UML 在網路上就像直接接到宿主的同一區域網路。使用時,需由宿主先建立並設定一個 TAP 裝置(例如 tap100
),再於 UML 執行命令中指定該裝置名稱。tap100
一個私有 IP (192.168.122.1/24
),後續 UML 可以把它作為 gateway,接著把介面狀態設為 UP(啟用)。tap100
與內部介面綁定,就像做了一張虛擬網卡 (此例 vec0
),且取得該宿主裝置的描述子;
tap100
中的 ring buffer。tap100
時,UML 能夠透過 poll/epoll
得知 fd 可讀,隨即取回資料並交回客體的 protocal stack。可以透過 ifconfig
觀察這個新網路裝置:
最後我們終於可以啟動 uml,執行以下命令:
vec0:transport=tap,ifname=tap100,depth=128,gro=1
transport=tap
:指定由宿主的 TAP 裝置做為資料通道ifname=tap100
:綁定到宿主事先建立好的 tap100
depth=128
以及 gro=1
涉及概念待釐清ubd0=./alpine.ext4
ubd
= User-mode Block Device;0
代表「第一顆磁碟」。./alpine.ext4
);UML 會把它映射成客體的 /dev/ubd0
(或 /dev/ubda
,視 devfs 而定)。root=
,UML 會嘗試直接把這顆磁碟掛到 /
作為根檔案系統。rw
: 通用開機旗標:要求核心以 read-write 模式掛載根檔案系統(預設其實是 ro
)。init=/sbin/init
: 指定客體開機後執行的第一支程式。這裡用BusyBox
的 /sbin/init
,也可換替成自訂腳本TODO 打包 zopline 版本 docker image
/sbin/init
,其負責接手核心控制權並初始化使用者空間。它會依照 /etc/inittab
的設定先執行開機腳本
/etc/init.d/rcS
,此腳本對我們開機時設定的 vec0 介面進行網路參數設定(IP, route, DNS 等)poweroff
即可退出 uml。但注意此時的 um-mommu 並不是 zpoline 版本,而是依靠 seccomp
進行系統呼叫攔截的 um-nommu(s),但我們可以很快切換過去,步驟如下。
mmap_min_addr
65536
(64 KiB)以減少 NULL dereference 攻擊面0x0
安插跳板程式碼,故在啟動前暫時降至 0,建議在測試完後將其改回預設值zpoline=1
,執行 dmesg | grep zpoline
預計能看到以下輸出00000000-00001000 --xs
,此段記憶體即為跳板程式碼,--xs
表示執行/共享,不可讀寫至此,我們已經成功在 um-nommu(z) 上啟動 Alpine Linux,並能順利執行 shell, BusyBox 等使用者空間程式。接下來,會利用事先打包好的 Docker 映像,透過 podman 以 rootless 方式啟動,展示其無特權執行的能力。該映像中已包含 Python, Redis, Nginx 等常用應用。以下說明實際執行結果與問題。
fork()
仍限制需改用 master_process off; daemon off
等設定,假使直接執行 nginx &
,系統會回傳 fork() failed
並中止執行。TAP
裝置未對容器授予 NET_ADMIN
能力,容器便無法存取該裝置;此問題仍待後續尋求合適的解決方案。
sudo
就啟動服務,是因為在建立 TAP 裝置時,已將該裝置的存取權限授予目前的非特權使用者。然而,rootless Podman 內的行程運行於獨立的 network namespace,無法繼承主機 TAP 裝置的使用者權限,因此此作法在容器中並不適用。首先量測 zpoline 為 uml 帶來的效能改進,上圖展示在各種不同隔離環境下的系統呼叫延遲,紅色代表原生宿主,淺綠以及綠色代表原生 uml,淺藍色代表使用 seccomp
filter 以信號作為攔截手段的 uml,最後深藍色代表 nabla-linux 加上 zpoline。
select-n
: 對分別 n 個檔案進行 select 系統呼叫,可以看到 um-nommu(z) 延遲略低於原生系統,這是因為對 um-nommu(z) 來說,系統呼叫等同一般函式呼叫,不涉及特權模式切換。syscall
: 低 overhead 的系統呼叫,此選用 getpid()
,旨在測量由系統呼叫發起的使用者與核心切換之延遲成本
nop
的數量seccomp
攔截並透過 SIGSYS
信號處理常式的手法 (淺藍色) 有 10 倍以上的效能改進fork() + execve()
的表現是所有組別中最差的,係因二進位改寫即發生在程式載入的時候。
最後看到網路吞吐量的表現如何,利用 iperf()
測量二裝置,192.168.122.1
(host tap100)與 192.168.122.2
(UML guest vec0)透過 tap
橋接互傳資料的表現。
iperf(f)
,f 表示 forward,由客戶端傳送至伺服器端,為傳送封包表現iperf(r)
,r 表示 reversed,為接收封包的表現相比於系統呼叫延遲,將 uml 替換為 no-mmu 甚至再加上 zpoline,也沒有為吞吐量沒有帶來數倍的效能改進是因為,整體吞吐量的關鍵瓶頸其實在於 tap
介面把封包從宿主複製進 UML 使用者空間的過程,而不是 context-switch 的成本。(native 在這個實驗等同於直接走 loopback,扮演理論上限的角色)