Try   HackMD

Linux 核心專題: 輕量級隔離運行環境

執行人: leowu0411
解說錄影

Reviewed by HeatCrab

在正式進入說明 Nabla container 之後使用了 hypercalls 這個名詞

unikernel 與一般通用 VMM 之間的 hypercalls 依然暴露出大量潛在攻擊面

請問 hypercalls 也是系統呼叫(syscall)的一種嗎?
還是其實兩者間毫無關聯,hypercalls 是特別用於虛擬機中一種操作的稱呼?

Hypercalls 是客體核心與 hypervisor 之間的特權呼叫機制,功能角色類似於使用者空間對核心的 syscall,但層級不同;當客體需要執行必須由 hypervisor 代辦的特權動作(如關機、重啟、熱插拔 vCPU / 記憶體或特殊 I/O 控制)時就會呼叫。
leowu0411

Reviewed by 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

Reviewed by I-Ying-Tsai

會不會有些情況有可能會產生新的 syscall 指令而繞過 zpoline?

會。有可能的繞過的情況已於文章中敘述,可參見 當前限制 leowu0411

Reviewed by yy214123

被大多數應用採用「猜測式」的 seccomp policy,他的優缺點是什麼?

猜測式 seccomp policy 指我們無法得知每個應用需要的系統呼叫為何,故名猜測式

  • 如果 seccomp policy 太嚴格可能導致應用非必要中止,反之則會留給攻擊者大的攻擊面,通常猜測式會採取後者,以確保應用能夠正常運行
  • 文中會提及此概念係因:當政策針對的是 vmm 而非應用時,我們就可以制定客製化的政策且不須頻繁更動,因為 vmm 對宿主的需求基本固定,這也是 vm 較容器安全的另一個原因,其較好預測並進行管理。
  • 其優點,由於不須額外分析該應用使用的系統呼叫較易於部署,缺點為安全性較低。
    leowu0411

Reviewed by 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

TODO: 研究 Nabla containers

觀看 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)。

議程紀錄

TODO: Nabla 的後續改進

為了進一步降低系統呼叫開銷,變體 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

TODO: 更新 Docker 映像檔

nabla-linuxthehajime/linux,以新的 Linux 核心和相關套件建立 Docker 映像檔

thehajime/linux 已升級 (rebase) 到接近 Linux v6.16-rc1
參照輕量級容器實作和擴充,作為 rootless container 的替代方案

紀錄觀看材料當下認知及疑惑

TODO : PIC vs PIE

展開以上內容到本頁面,使文件結構清晰且持續追蹤相關議題

Evolution of Virtualization for a Safer Cloud

參見 Say Goodbye to Virtualization for a Safer Cloud
參見 Linux 核心設計: 作業系統術語及概念

在雲端設計與營運中,能夠正確地將各租戶(tenant)彼此隔離,是一項基本能力。然而,目前尚無一項明確的、可量化的隔離指標。最為常見的區隔方法可以透過衡量雲端應用(guest)與宿主(host)之間介面層級是偏向高階(抽象)還是低階(接近硬體層)。

例如,一個行程預期的是高階抽象的 POSIX 介面,而一個 VM 則預期接觸的是低階的虛擬硬體介面。

以 linux kernel 作為雲端環境中提供租戶隔離的底層系統當例子:

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

  • (a) 即為容器化隔離,宿主核心提供客體 POSIX 介面,並依賴 cgroup 等機制作為隔離
  • (b) 則為經典的 QEMU/KVM 硬體模擬模型,客體有自己的作業系統,而宿主僅透過 KVM 模組提供客體低階的虛擬硬體介面

一般情況下,會認為客體與宿主間溝通的介面愈是低階愈是安全,這是因為宿主可以將客體不需要的介面完全封鎖,藉此限縮給予攻擊者操作的攻擊面 (attack surface)。

對於多租戶 (Multi-tenant) 的環境來說,我們希望透過某須手段,使得客體都像運行在自己的環境當中,不論客體如何在裡面搗亂,都無法影響宿主端的環境,這樣的環境稱為沙盒 (SandBox)。簡言之,假使一個客體能自己辦到的任務越多、依賴宿主愈少,我們就可以稱這個客體具備更加完善的沙盒化,在最極端的情況下,甚至能夠單除依靠軟體 (如 QEMU) 模擬指令來替代硬體支援,將雙方溝通的介面層級降至機器指令層級。以下介紹目前主流的隔離方法。

MicroVM

虛擬機器 (VM) 藉由虛擬機器監控器 (VMM) 作為客體與宿主間的中介層,提供硬體模擬供給客體,使客體能夠於該環境執行完整的作業系統,廣為人知的專案為 QEMU,雖然硬體模擬帶給宿主完善的保護,但比起容器更加笨重,開機就需要數倍的時間,且部份虛擬硬體甚至不會被使用,造成硬體使用效率降低。

旁註:雖然 QEMU 能夠逐道指令翻譯完整模擬硬體環境,但效能會極度被拖垮;通常與 linux 核心模組 KVM 搭配使用,後者提供 CPU 與記憶體的硬體虛擬化支援,使 QEMU 在啟用 KVM 加速後只需處理 I/O 模擬,CPU 指令能夠直接由 VT-x/AMD-V 執行,極大幅度提昇其性能。

MicroVM 因而誕生,其為極大程度精簡化的 VM,將用不到的功能給移除,只保留最低限度的硬體模擬,透過限縮功能與降低複雜度來提升 VM 的效能與使用資源,這樣的精簡化也為其帶來極快的啟動速度。

  • 知名的專案如 AWS Firecracker,其為 QEMU 的替代品,同樣使用 KVM 來建立虛擬機器,作為在 AWS Lamda 這類需快速啟動運作實體的使用情境的解決方案

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Unikernel

參見 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 實例,無法擴散。

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

上述文章 (撰寫於 2019) 提到一件有趣的事,Docker 在 2016 年收購 unikernel 新創公司 Unikernel Systems 時,外界曾期待 Docker 能將 container 打包為 unikernel,但直到該文章執筆時,仍未見任何整合的跡象。

查詢後發現直到現在仍然沒有這方面的跡象,可能是 Docker 更著重於可攜性與通用容器?

但值得注意的是,unikernel 與 container 在許多方面具有相似性:兩者皆為單一用途映像 (single-purpose image),且具備不可變性 (immutable),也就是映像內容在部署後不可變更。更新應用程式時,總是會建立新的映像。

此外 Unikernel 藉由限縮某些特性來提升整體效能,例如:

  • 整個作業系統只能有一個 process,這意味使用者的應用程式沒有辦法 fork 出其他的 process. 也因為這個特性使得其安全性頗高
  • 作業系統跟應用程式的記憶體是採用同個記憶體空間,透過這個機制減少需要轉換的次數來達成效能提升
  • 由於只能運行一個應用程式,所以若應用程式崩潰,這個特化的作業系統也沒有活著的必要,這時候重啟作業系統即可,反正成本很低

SandBox Container

上述兩項技術在隔離宿主與客體來看遠優於容器化技術,容器直接運行於宿主核心,因此可以呼叫完整宿主 Linux 系統呼叫集合,多數針對宿主核心的攻擊在容器中仍然有效。若容器被入侵,將會暴露出大量的宿主核心攻擊面。

  • 例如著名的 Dirty COW 攻擊(CVE-2016-5195);DirtyCOW 是 Linux 核心 "Copy-On-Write" (COW) 機制中的 race condition 漏洞,攻擊者可以將唯讀映射更改為可寫,進而修改本該受保護的檔案內容並取得非法提權。
    • 一般容器作為宿主核心的使用者空間行程,無法在這類針對核心漏洞的攻擊下,為宿主環境提供保護。惡意使用者若能在容器內執行攻擊程式,便能夠取得宿主核心的 root 權限。
  • 同樣,利用核心原有機制發起的攻擊如 fork bomb,容器也同樣無能為力。fork bomb 利用作業系統允許行程無限制建立子行程的機制,迅速耗盡宿主的可用行程數(pids)或其他資源,導致整台主機無法正常服務。若未正確設置資源限制,任何容器內的使用者都能發動此類拒絕服務攻擊,進而影響整個宿主系統的穩定性。
    • 雖然 Docker 能夠限制單一容器的最大行程數,但這種作法並不完善。其安全假設依賴於對應用程式最大行程數的預估,而這個值難以精確設定。太大仍可能導致耗盡宿主資源,太小則可能影響應用程式正常運作。

但問題是現在的 CI/CD 流程幾乎以容器進行佈署,這是由於其不須更改 legacy 程式碼,且不要求額外物件即可運行於宿主,跟一般行程一樣,極高的可攜性加上幾近於零的佈署成本奠定了這項技術的地位。

反觀上述提到另外兩種技術,microVM 多數在實作上疊加一層 Container 的管理介面,例如 VM 起來後內建 Container 的環境,能夠直接部署 Container 來應用,透過這樣的設計讓使用者可以繼續使用 container 般的操作流程同時又享有 VM 的安全性。而 Unikernel 則須替換原始碼的系統呼叫改為呼叫 libOS,且同樣需要為其準備環境方能運行於宿主。

SandBox Container,其目標為利用某些手段,使得以行程為基礎的容器也能夠運行於沙盒內,具體實現手法是攔截容器向宿主發送的系統呼叫,並導入客製化的 handler;由 google 維護的 gvisor 即是此類型專案,據官方文件說明,其可以被認為是合併的客體核心和 VMM,或是增強版的 seccomp,但方法與其更貼切的技術是 UML (User Mode Linux),這也是本次專題的核心議題,事實上看完以下內容後也可以發現他們的概念與實作目標確實極度相似。

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

seccomp

上述提及的 seccomp (secure computing) 是 linux 提供的一種安全機制,用於限制行程可執行的系統呼叫,實際上透過這項際有機制我們就可以攔截所有行程發出的系統呼叫,並導向我們自訂的邏輯,其有兩種模式:

  • strict 模式只能執行極少數系統呼叫(如 read, write, _exit, sigreturn 等),任何其它呼叫都會被強制終止
  • filter 模式則允許行程安裝一個 Berkeley Packet Filter (BPF) 程式,以更精細地決定如何「過濾」這些系統呼叫,簡要流程如下。
    • 在使用者空間,程式透過 prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, …) 或 libseccomp API 設定過濾策略;這些策略會被編譯為 BPF bytecode 後傳給核心,核心在經過 verifier 驗證後,將此過濾器綁定至目前執行緒。
    • BPF 程式在核心內執行,其會得到 struct seccomp_data 資料結構,並根據上一步制定的策略執行相應邏輯。
    ​​​​struct seccomp_data {
    ​​​​    int   nr;                   /* System call number */
    ​​​​    __u32 arch;                 /* AUDIT_ARCH_* value
    ​​​​                                  (see <linux/audit.h>) */
    ​​​​    __u64 instruction_pointer;  /* CPU instruction pointer */
    ​​​​    __u64 args[6];              /* Up to 6 system call arguments */
    ​​​​};
    
    • 每當行程執行系統呼叫,核心會產生對應的 seccomp_data,交由 BPF 程式執行一次判斷,並依回傳值決定動作
      • SECCOMP_RET_ALLOW(允許)
      • SECCOMP_RET_ERRNO(返回錯誤碼)
      • SECCOMP_RET_TRAP(觸發 SIGSYS)等行動

那麼為何 Docker 不直接以 seccomp 限縮與宿主溝通的介面,還需要額外的物件如 gvisor 來實現沙盒化呢?
原因在於為任意的、以前未知的應用程式可靠地定義一個策略是極其困難的。若設計過於嚴格的 seccomp policy,將可能導致應用程式不必要地提前終止。
事實上 Docker 也能夠透過 seccomp 限縮系統呼叫,但政策非常寬鬆,預設為禁止 300+ 系統呼叫中的 44 種。

Seccomp security profiles for Docker

Nabla Container

參見 solo5-architecture.md
參見 Unikernels as Processes

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 系統呼叫 (如下圖)。

screenshot-2025-06-16-pm8.00.46

Solo5 是一個具備沙盒隔離功能的執行環境,主要目的是用來執行以各種 unikernel 建構的應用程式:若將 unikernel 視為使用者行程,則這個行程相較原始應用程式執行的工作更多,而 solo5 意在提供此類行程與宿主作業系統或 hypervisor 間的介面,以下為其設計目標:

  • 盡可能做到 legacy-free
  • 縮小攻擊面,如 Linux 核心提供給一般行程的介面,或 QEMU 提供整套 VM 的介面,
  • 提供統一的抽象介面讓 unikernel 可以在各種平台上運行 (如 KVM 虛擬化、Linux 使用者空間行程等)

此外 Solo5 引入名為 "tender" 的概念。tender 是負責在載入或執行期間「照顧」(tending to) 客體應用的元件,透過選用不同 tender 變體,unikernel 能夠運行於不同宿主環境。以下介紹二種變體:

  • solo-hvt
    hvt (hardware virtualized tender),在此模式下 solo5 提供給 unikerenl 的環境為一個運行於 KVM 之上的輕量虛擬機器,屬於虛擬硬體隔離,tender 在這裡扮演的腳色相當於傳統 KVM/QEMU 架構中,QEMU 所扮演的角色,但程式碼規模比之小數個數量級。
  • solo-sqt
    Nabla Container 所使用的模式 (上圖所示 nabla-run tender),在此模式下,傳統 monitor 被替換為 tender process,unikernel 運行於此行程。
    screenshot 2025-06-17 上午10.49.12
    • Tender 在初始化期間首先開啟 I/O 所需的檔案描述子,接著將 unikerenl 載入自身定址空間,完成對應設置後,呼叫 unikernel entry function,並傳入啟動參數(如記憶體位置與大小),即成功運行應用程式。
    • 一旦啟動後,unikernel 照常執行。與傳統 VM 模型不同的是:當需要發出 hypercall 時,unikernel 會直接以一般的函式呼叫形式呼叫 tender 中的 hypercall 實作邏輯。而 tender 中的 hypercall 實作,與虛擬化監控器中對應邏輯相同:它會執行對 Linux 的系統呼叫,並將結果回傳給 unikernel。

seccomp

與 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 到系統呼叫的一對一對應關係:

  • blkinfonetinfo 僅回傳 boot 階段設定好的裝置資訊,因此不需實際呼叫系統呼叫
  • walltimehalt 幾乎是直接轉譯為對應的系統呼叫
  • 其餘 hypercall 通常對應到特定資源,這些資源的使用限制會交由 monitor(或此處的 tender)管理。

screenshot-2025-06-20 中午12.20.48

上圖很好地展示了 Nabla 容器與傳統 Docker 容器的對應關係。和 runc 的角色相同,runnc 也是一個容器 runtime 工具 (且符合 OCI 規範) ,其目的亦用於建立容器。runc 透過呼叫 Linux 的 cgroup, mount, namespace 等核心功能來完成容器建置,runnc 則會依據所選的後端執行方式呼叫不同的宿主功能:

  • 若使用 solo5-hvt,則 runnc 會透過 /dev/kvm API 來建立一個硬體虛擬化的 Nabla 容器
  • 若使用 solo5-spt,則會利用 seccomp 沙盒與創建 tender process 等方式建立執行環境。

port linux to Nabla

TBD

The Most Lightweight Virtual-Machine Monitor Is No Monitor at All : nabla-linux

nabla-linux 為落實 Nabla Container 概念中一種實作方式,其開發目標同樣希望解決傳統容器直接運行在宿主核心的問題,但同時希望不依賴於額外的 VMM (例如 Solo5 tender/QEMU/KVM) ,反之以更加輕量化的方式達到監管。

為此,開發者把目光放到 User-Mode Linux (UML) :
User-mode Linux 是將 Linux 核心移植到使用者空間的一個版本。它在 Linux 宿主端以一組行程 的形式執行一個 Linux 虛擬機器。一個 UML 虛擬機器幾乎能夠執行與宿主端相同的一組行程。

  • 對於宿主端來說,UML 本身只是一般行程,nabla 容器因此可以在完全不需要額外監控器的情況下受到監管。
  • 對於客體行程來說就像自己擁有獨立核心,而非如傳統容器共用宿主核心

具體來說,UML 本身就包覽了 Nabal Container 中,Library OS 與 tender 的工作:

  • Library OS:直接由 UML-nommu 核心提供;應用不需修改即可呼叫 POSIX 介面
  • tender:不必再把 unikernel hypercall 轉成宿主系統呼叫,UML 本身就能夠完成與宿主間的溝通

針對性能,由於 UML 高度依賴 ptrace 來攔截客體使用者空間與客體核心之間的溝通造成極大的效能開銷,nabla 容器為此實作 nabla-linux

nabla-linux 結合了 UML 以及不使用記憶體管理單元(no-MMU)的設定,以一個不具權限的使用者空間行程方式運行整個 Linux 核心。此外其透過 seccomp 限制僅使用約 12 個宿主系統呼叫。這些更動大幅降低 UML 使用者空間與核心溝通的開銷。

但同樣帶來若干負面影響:

  • 單一定址空間(Single Address Space)
    由於沒有 MMU,所有客體行程與客體核心共享一個平坦的記憶體空間。沒有虛擬記憶體或記憶體保護,因此任何程式若寫入 NULL 指標,都可能導致整個客體崩潰。
  • 不支援系統呼叫 fork()
    由於無虛擬記憶體支援,無法運行 fork(),僅允許使用 vfork() + execve(),確保子行程在轉換為新行程映像前不會修改共用的定址空間。
  • 只允許 PIE 執行檔:
    所有客體可執行檔都必須為位置無關(Position Independent Executables, PIE),才能安全地運行於共用定址空間中。這與 Alpine Linux 的作法一致。

以下首先介紹一般 UML 如何實作,藉此理解模擬虛擬記憶體以及利用 ptrace 機制作為客體核心與客體行程間的橋樑為什麼會造成效能瓶頸。

User-mode linux (UML)

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 會恢復之前儲存的暫存器快照,並讓該行程在啟用對系統呼叫追蹤的狀態下繼續執行。

Trap & Fault

類似於系統呼叫,當一個行程收到 signal 時,tracing thread 會比該行程更早察覺此事件。
當這種情況發生時,該行程會被轉為在核心模式繼續執行,但不會儲存其執行狀態,也不會套用任何新的狀態。UML 會針對所有重要的 signal 註冊自己的 handler,因此當行程被繼續執行時,會進入其中一個 UML handler,該 handler 會實作核心對該 signal 的解釋與處理邏輯。

外部裝置中斷 (external device interrupts)

在 UML 中是透過 SIGIO 來實作的。驅動程式會設置,在每當有輸入抵達時,系統就會產生一個 SIGIO
SIGIO handler 會使用 (如 select) 來判斷是哪些 file descriptors 有等待中的輸入。接著,它會根據這些資訊判定每個 descriptor 對應的是哪一個 IRQ。一旦確定對應的 IRQ,它就會呼叫標準的 IRQ 處理程式碼來處理該中斷。

記憶體錯誤 (Memory faults)

Memory faults 是透過 SIGSEGV (Segmentation Fault) 來實作。
當一個 UML 行程發生無效的記憶體存取 (invalid memory access) 時,host 會為它產生一個 SIGSEGV
接著由 UML 核心中的 SIGSEGV handler 處理這個 signal,並判斷該記憶體存取是否是:

  • 合法的存取,但 fault 是因為該 page 尚未被映射進行程的記憶體空間
  • 或是純粹非法的存取 (illegal access)

若是第一種情況,會呼叫 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 首先判斷此次記憶體存取導致的錯誤是否屬於有效範圍 (例如該位址屬於該行程的虛擬定址空間,但目前尚未映射實體頁面),或者為純粹非法的存取 (例如位址超出該行程的定址空間,或者位址屬於核心保留區域)。

  • 如果是前者「合法但尚未映射」的情況,UML 會呼叫一般的 Linux 分頁錯誤處理程序,配置一個新的分頁並將其映射到對應行程的定址空間中,然後讓行程繼續執行這種情形對應到一般作業系統中的「分頁錯誤」:例如行程第一次讀寫某個尚未配置實體記憶體的合法位址時,UML 核心透過 SIGSEGV 訊號捕捉此事件並動態配置記憶體。
  • 反之,若判定此次存取是非法的 (例如位址不在該行程的合法範圍內,或企圖存取核心的保護頁面),則 UML 核心不會嘗試映射頁面,而是將該信號視為一般的存取違規,將 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 執行模式

UML 在實作上有三種模式:tt (Tracing thread) 模式以及 skas (separate kernel address space) 模式,而 skas 模式又細分為 skas0 以及 skas3 兩者間的區別以下會詳述。 tt mode 與兩種 skas 模式之間的主要差異在於 UML 如何在宿主上配置定址空間。

screenshot-2025-06-16-am10.39.07
上圖可以看到三種 UML 執行模式在行程 (UML process) 定址空間的配置方式上有所不同。

  • tt mode 將整個 UML 核心映射至行程定址空間的上方 0.5GB 區域。
  • skas0 mode 將 UML 核心保留在行程定址空間之外,只有兩頁記憶體映射至行程定址空間最頂端。這兩頁用於接收 SIGSEGV 訊號,並將所產生的頁錯誤資訊回傳給 UML 核心,以及用來修改行程的定址空間。
  • skas3 mode 中則不需要這兩頁,這使得其行程能夠使用整個定址空間 (係因額外核心額外修補)。

screenshot-2025-06-16-am10.39.12
UML 行程與各模式中對應宿主行程之間的關係也由此而來。

  • tt mode 有一個獨立的宿主執行緒 (即 tracing thread),用來控制其他執行緒的執行。
    在 UML 實例中的各個行程與執行緒,於宿主上都有對應的執行緒。每個這樣的宿主行程,其定址空間頂端都映射了 UML 核心。
  • skas3 mode 中,則沒有獨立的 tracing thread,這個角色由 kernel thread (核心執行緒) 扮演。宿主上僅有一個行程,所有 UML 行程都在此行程中執行 (可視此宿主行程為一個 UML 虛擬 CPU)。
  • skas0 mode 是 tt mode 與 skas3 mode 的混合體。如同 skas3 mode,它沒有 tracing thread,且 UML 核心運行於一個獨立的 kernel thread 中;但如同 tt mode,一個 UML 行程會對應到一個主機行程。

注意用語:

  • process 是「行程」,而非「進程」 (不要反覆犯錯!)
  • file descriptor 是「檔案描述子」,而非「符」
  • patch 是「修補」(需要依據前後文調整),而非「補丁」,用語力求文雅且清晰
  • addess space 是「定址空間」,而非「位址」,用語應當精準
tt mode

在 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 以及一次信號收發,分別為:

  • 客體行程呼叫系統呼叫陷入宿主核心模式
  • 上下文切換至 tracing thread,其做好設置後上下文切換回原行程
  • 行程模式切換回宿主使用者模式並跳躍至客體核心區段完成 UML 系統呼叫函式,對自己發送信號:
    • 再一次陷入陷入宿主核心模式並上下文切換至 tracing thread
  • tracing thread 做好設置並再次上下文切換回原行程使其繼續執行原程式。

而 SKAS 模式則為兩次 context switch 以及一次 mode switch:

  • 客體行程呼叫系統呼叫陷入宿主核心模式
  • 上下文切換至 UML 核心行程,該行程直接完成 UML 系統呼叫函式,將結果存放至原行程暫存器,上下文切換回原行程。
  • 原行程模式切換回宿主使用者模式,並繼續原程式邏輯。

與看到的資料略有落差,待進行實驗解惑。

SKAS3

https://user-mode-linux.sourceforge.net/old/diary.html

最初的 SKAS 模式實作需要套用「SKAS3」修補 (patch) 於宿主核心,以提供特殊的機制,總題而言此修補須:

  1. 建立、操作與銷毀不與任何行程相關聯的主機定址空間
  2. 在行程收到 SIGSEGV 訊號後,提取分頁錯誤 (page fault) 資訊,例如錯誤發生的位址、存取類型與處理器旗標
  3. 操作其他行程的 Local Descriptor Table (LDT) entries

定址空間的操作是透過在 /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 。

SKAS0

SKAS3 雖然可以帶來顯著的安全性以及效能提昇但最大的問題是需要對宿主核心進行修補,為解決這個問題,SKAS0 因而誕生,此模式也是 UML 的預設模式

其基本思想為:在每個 UML 行程的定址空間中插入少量程式碼,用來執行那些 skas3 模式需要透過宿主修補來完成的定址空間更新與資訊擷取工作。

這段插入的程式碼為 UML 核心在每個行程的定址空間頂部映射兩頁記憶體。其中一頁是為了 SIGSEGV 訊號框架而設,並設定為可寫入;另一頁則包含 UML 程式碼,並被設為唯讀。

  • 程式碼頁面中包含一個函式,會根據 UML 核心的請求,呼叫 mmap() munmap()mprotect()。此頁面同時也包含 SIGSEGV 的訊號處理函式。
  • 當 UML 行程需要進行定址空間變更時,就會呼叫這個函式,其作用相當於透過 /proc/mm 檔案描述子來要求定址空間變更。

而訊號處理函式則實作了與 PTRACE_FAULTINFO 類似的功能:當接收到 SIGSEGV 訊號時,它會從自己的堆疊中讀取所有分頁錯誤資訊,並將資訊整理成 UML 核心可以讀取的格式。

最後,由於在未修改的主機核心中,無法在不建立新主機行程的情況下建立新定址空間,因此在 skas0 模式下每個 UML 行程仍需對應一個宿主行程。

總體而言,SKAS 模式成功在使用者空間落實了傳統OS的核心/使用者態隔離,大幅提昇了 UML 的安全性與效能,唯一明顯的缺點為是SKAS (嚴格而言是 SKAS3) 原本需要對宿主核心修補才能使用;不過後續出現的變體 SKAS0 解決了這個問題。

um-nommu

上述提到,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 而非呼叫宿主系統呼叫。

vDSO & vsyscall

vsyscall 是 Linux 中用來加速某些系統呼叫的一種機制。時常與其一起被提到的技術為 vDSO,儘管它們的基本功能相同(提供不需要進入核心模式即可存取的快速功能),但兩者在實作上仍有顯著差異。

vsyscall 是這兩種機制中較早引入的,最初設計是為了執行某些不需要特殊權限即可執行的系統呼叫。經典的例子是 gettimeofday(),它僅需讀取核心所認定的當前時間。有些應用程式頻繁呼叫 gettimeofday(),對即便極小的額外成本也很敏感。為了應對這類需求,Linux 核心允許將包含當前時間資訊的記憶體頁面以唯讀方式映射進使用者空間,該頁面也包含一個快速實作的 gettimeofday() 函式。透過這種虛擬系統呼叫,C 函式庫可以提供一個不需真正切換至核心模式的快速 gettimeofday()

$ cat /proc/self/maps | grep vsyscall
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0   [vsyscall]

藉由印出定址空間可以觀察到 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) :

#define __SYSCALL_LL_E(x) (x) #define __SYSCALL_LL_O(x) (x) #define CONFIG_NOMMU #ifdef CONFIG_NOMMU extern unsigned long __sysinfo; #endif // ... skip here // system call 3, write(fd, buf, count) static __inline long __syscall3(long n, long a1, long a2, long a3) { unsigned long ret = -1; #ifdef CONFIG_NOMMU if (__sysinfo) __asm__ __volatile__ ( "call *%1" : "=a"(ret) : "r"(__sysinfo), "a"(n), "D"(a1), "S"(a2), "d"(a3) : "rcx", "r11", "memory"); #else __asm__ __volatile__ ( "syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2), "d"(a3) : "rcx", "r11", "memory"); #endif return ret; } // ... skip here

可以看到如果在 CONFIG_NOMMU 的設定下,系統呼叫之 ABI 會改由呼叫 call __sysinfo__sysinfo 為一個指標,指向存放於 vsyscall 分頁的 UML 系統呼叫處理常式。

而 musl-libc 是從哪裡取得 __sysinfo 的呢?可以從程式的進入點 __libc_start_main 找到,見第 12 行。

void __init_libc(char **envp, char *pn) { size_t i, *auxv, aux[AUX_CNT] = { 0 }; __environ = envp; // 在初始化的使用者堆疊,auxv 在 envp 之後故其首先找到指向 auxv 的指標,存入 lib.auxv for (i=0; envp[i]; i++); libc.auxv = auxv = (void *)(envp+i+1); // 接著其將所有 auxv 內容存入陣列 aux 中,並透過索引 AT_SYSINFO 取得 __sysinfo for (i=0; auxv[i]; i+=2) if (auxv[i]<AUX_CNT) aux[auxv[i]] = auxv[i+1]; __hwcap = aux[AT_HWCAP]; if (aux[AT_SYSINFO]) __sysinfo = aux[AT_SYSINFO]; libc.page_size = aux[AT_PAGESZ]; // ... } int __libc_start_main(int (*main)(int,char **,char **), int argc, char **argv) { char **envp = argv+argc+1; /* External linkage, and explicit noinline attribute if available, * are used to prevent the stack frame used during init from * persisting for the entire process lifetime. */ __init_libc(envp, argv[0]); // ... }

以上為 musl-libc 如何透過 vsyscall 繞過宿主系統呼叫,接著觀察 nabla-linux (linux-um-nommu) ,首先看到函式 create_elf_fdpic_tables() (檔名:fs/binfmt_elf_fdpic.c) ,此函式在負責把 argc/argv[], envp[] 與 auxv 排進新建立的使用者堆疊,建立 auxv 的工作也在此函式完成:

#ifdef ELF_HWCAP2 NEW_AUX_ENT(AT_HWCAP2, ELF_HWCAP2); #endif NEW_AUX_ENT(AT_PAGESZ, PAGE_SIZE); NEW_AUX_ENT(AT_CLKTCK, CLOCKS_PER_SEC); // ... NEW_AUX_ENT(AT_UID, (elf_addr_t) from_kuid_munged(cred->user_ns, cred->uid)); NEW_AUX_ENT(AT_EUID, (elf_addr_t) from_kuid_munged(cred->user_ns, cred->euid)); NEW_AUX_ENT(AT_GID, (elf_addr_t) from_kgid_munged(cred->user_ns, cred->gid)); NEW_AUX_ENT(AT_EGID, (elf_addr_t) from_kgid_munged(cred->user_ns, cred->egid)); NEW_AUX_ENT(AT_SECURE, bprm->secureexec); bprm->exec += exec_params->load_addr; NEW_AUX_ENT(AT_EXECFN, bprm->exec); // ... NEW_AUX_ENT(AT_SYSINFO, __kernel_vsyscall); #undef NEW_AUX_ENT

注意程式碼第 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) :

參見 10分鐘讀懂 linker scripts

#define VDSO_PRELINK 0xffffffffff700000 #include "vdso-layout.lds.S" // skip here ....
  • 程式碼第一行 VDSO_PRELINK 巨集,宣告 vDSO 分頁在運行時於定址空間的虛擬位置
  • 接著展開 vdso-layout.lds.S ,該檔定義了最終動態函式庫 vDSO.so 內的內容以及 section 配置 :
#include <asm/vdso.h> SECTIONS { // other section, skip here .... . = VDSO_PRELINK + SIZEOF_HEADERS; // other section, skip here .... .text : { *(.text*) } :text =0x90909090 } // skip here .... PHDRS { text PT_LOAD FLAGS(5) FILEHDR PHDRS; /* PF_R|PF_X */ // other section setting, skip here .... }
  • 看到程式碼第 7 行,告訴連結器以下內容自虛擬地址 VDSO_PRELINK + SIZEOF_HEADERS 開始擺放
  • 第 11 行則將所有 input object 裡名字匹配 .text* 的 section 放進輸出檔的 .text 區段
  • 最後第 18 行將 .text 區段設定為 LOADABLE,表示其在 output file (executable) 運行時需要被載入到記憶體當中,並設定旗號 FLAGS(5) : PF_R|PF_X(可讀、可執行、不可寫)。

最後看到 arch/x86_64/um/entry_64.S,其定義了 __kernel_vsyscall 實際內容,第 21 行的 call do_syscall_64,我們終於看到如何進入 UML 的系統呼叫處理常式:

#define ENTRY(x) .text; .globl x; .type x,%function; x: #define END(x) .size x, . - x EENTRY(__kernel_vsyscall) movq %rsp, %r11 movq current_ptregs, %rsp /* 8 bytes * 20 registers (plus 8 for the push) */ addq $168, %rsp /* Construct struct pt_regs on stack */ pushq $0 /* pt_regs->ss (index 20) */ pushq %r11 /* pt_regs->sp */ pushfq /* pt_regs->flags */ pushq $0 /* pt_regs->cs */ pushq %rcx /* pt_regs->ip */ pushq %rax /* pt_regs->orig_ax */ PUSH_AND_CLEAR_REGS rax=$-ENOSYS mov %rsp, %rdi movq current_top_of_stack, %rsp call do_syscall_64 movq current_ptregs, %rsp POP_REGS addq $8, %rsp /* skip orig_ax */ addq $8, %rsp /* skip ip */ addq $8, %rsp /* skip cs */ addq $8, %rsp /* skip flags */ popq %rsp ret END(__kernel_vsyscall)
  • 程式碼第 5 行,將原本使用者空間的 stack pointer(RSP)暫存於暫存器 R11。
  • 第 6 行把堆疊切換到 UML 核心事先準備好的區域 current_ptregs,用於存放系統呼叫所需的暫存器快照 struct pt_regs ,但其位於 UML 核心能夠自由操作的記憶體空間,位於宿主使用者空間 (一般情況此區域位於 kernel stack 的最上方) 。
  • 從第 9 行至第 17 行,開始填寫struct pt_regs ,其中較須注意的是:
    • pushq %r11:保存原使用者 stack pointer
    • pushq %rcx:return address
    • pushq %rax:系統呼叫號碼
    • 第 17 行呼叫 PUSH_AND_CLEAR_REGS rax=$-ENOSYS,完成 struct pt_regs
      • 此巨集把其餘 15 個通用暫存器 push 到堆疊中,並清空它們的內容,避免未初始化或資訊外洩。
      • rax=$-ENOSYSpt_regs->ax 設為 -ENOSYS,為系統呼叫失敗的預設回傳值。
  • 接著第 19 行,將這份「UML 的 pt_regs 結構」指標當作參數,供 do_syscall_64() 使用 (透過 %rdi 傳入) 。
  • 第 20 行將 RSP 指向 UML 核心堆疊的頂端 (宿主角度為使用者堆疊) 。
  • 最後當系統呼叫處理完畢後:
    • 將 RSP 重新指回 current_ptregs
    • 呼叫 POP_REGS (其與 PUSH_AND_CLEAR_REGS 相互對應),將堆疊上的通用暫存器狀態還原。
    • 最終回復先前保存於堆疊的 RIP (%rcx) 以及 RSP (%r11),使控制權回到使用者空間 (注意 RIP 沒有被額外指令載回暫存器,這是因為 x86-64 的 ret 指令會自動從堆疊取得其資訊)。

zpoline

參見 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 軟體作出修改。

screenshot-2025-06-22-pm2.07.43

然而最大的問題在於,當前 x86-64 為主的 UNIX-like 系統中(常見於伺服器環境),並無完美的系統呼叫攔截機制:

  1. 現有 kernel 支援會對程式造成無法接受的效能下降:
    • ptrace
    • Syscall User Dispatch (SUD)
    • legacy binary rewriting 技術(如 int3 signaling )
  2. 若採用需修改核心或安裝額外 kernel module 的方法,其不被主線核心支援,將嚴重影響應用程式的可攜性。
  3. 需要重新編譯 user-space 程式碼的作法(常見於 Unikernel 系統)通常無法實施,因為使用者往往無法取得原始碼。
  4. 將應用程式與特製的標準函式庫(即上述原生 nabla-linux 採用的方法)連結,用來將系統呼叫改為呼叫特定 OS 子系統函式的方式,會限制標準函式庫的選擇,且無法攔截從標準函式庫外部所發起的系統呼叫。
  5. 其他二進位改寫技術以及函式攔截技術 (如 LD_PRELOAD) 無法做到完整攔截,因此無法用於要求高可靠性的系統,而另一類二進位改寫技術,則會覆寫原本不應修改的指令 (於以下段落解釋)
  6. 雖然 BSD Packet Filter(BPF)與其延伸版本 eBPF 可套用於核心函式攔截,但無法在不修改核心原始碼的情況下改變或模擬系統呼叫的行為。

x86_64 中的跳躍

在進一步解釋 zopline 之前,我們需要先認識 x86_64 架構中常見的跳躍指令: calljmp,兩者的區別為前者跳躍前會將下一條指令的位址(RIP)推入堆疊,以便之後可用 ret 指令返回;後者則無條件直接跳躍。

注意下文會看到指令 callq 其在 64 bits 架構下等價於 call

  • call 是語法上的泛稱,而 callq 在 GNU AT&T 語法中,表示 call to a 64-bit function(即 call quadword)。

x86_64 處理器提供多種跳躍方式,大致可以分為以下二類:

  • 近位址跳躍 (relative near jump/call),能夠跳躍到 ±2GB 範圍內的位置(相對於下一條指令)。:
    • call rel32 : 0xE8 + 4 byte 相對地址
    • jmp rel32 : 0xE9 + 4 byte 相對地址
    • 總長度需要 5 bytes
  • 間接跳躍 (indirect jump/call),指令從暫存器或記憶體位置讀取跳躍目的地,因此不需要在指令中嵌入實際的位址值 (此處僅介紹目標位址已經存在某個暫存器的狀況):
    • call r/m64 : REX.W 0xFF r/m64
    • jmp r/m64 : REX.W 0xFF r/m64
    • 總長度 2 ~ 3 bytes (取決於是否忽略REX.W

r/m64 這個操作數型別表示 "register or memory (64-bit),而 在 x86-64 指令集裡只要看到 r/m…,會透過 ModR/M 位元組 (1 bytes) 來編碼。

ModR/M byte = [ mod ][ reg ][ r/m ]
                2b     3b     3b
  • 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 設為 1
    • 其餘兩個 bit 在目標位址已經存在某個暫存器的狀況可跳過不考慮
  • 而當 WRBX 皆為 0 時,REX.W 位元能夠直接從指令省略

結合上面的介紹,當目標位址已經存在某個暫存器,而我們欲利用其進行間接跳躍,opcode 可以為:

  • rax 作為目標暫存器 call *%rax = 0xFF 0xD0 -> 0xD0 = 0b11010000
  • r8 作為目標暫存器 call *%r8 = 0x41 0xFF 0xD0 -> 0x41 = 0b01000001 0xD0 = 0b11010000

挑戰 & 原理

簡言之,zpoline 的運作方式是:將二進位程式中所有 syscallsysenter 指令(其 opcode 分別為 0x0f 0x050x0f 0x34,皆為僅佔用 2 bytes 的指令)替換為跳轉至自定 hook 函式的指令,藉此攔截系統呼叫。

screenshot-2025-06-22-pm2.24.48

screenshot-2025-06-22-pm2.25.57

了解 x86_64 架構下的跳躍指令後,不難發現,二進位改寫最大挑戰在於如何在僅有的兩個 byte 空間內,塞入能跳躍到任意記憶體位置(hook 函式)的指令?

  • 相對跳躍需要 5 個 bytes
  • 間接跳躍至絕對地址則需要 12 個 bytes (兩道指令)
    ​​​​movabs $0xdeadbeefcafebabe, %rax // 10 bytes
    ​​​​call *%rax // 2 bytes
    

如果硬塞進去就會覆寫到位於syscallsysenter 之後的指令,引發非預期行為。

screenshot-2025-06-22-pm2.28.17

zopline 利用 x86-64 架構下 UNIX-like 系統的系統呼叫 calling convention,解決這個問題:

  • 當一般 linux 行程執行系統呼叫時,其先將欲呼叫之系統呼叫編號放入 rax 暫存器,並執行 syscallsysenter ,執行緒的上下文會被切換到核心,並呼叫預先註冊的系統呼叫處理常式。
  • 系統呼叫處理常式在透過 rax 值執行對應的系統呼叫。

利用此特性,zpoline 將 syscall / sysenter 替換為 callq *%rax 指令,如上面提到其 opcode 為 0xff 0xd0,亦為 2-byte 指令。因此,這個替換不會破壞相鄰的原始指令。

根據 calling convention,rax 中永遠儲存著系統呼叫編號。因此,這會導致程式跳躍至虛擬位址空間中某個位於 0 ~ 500(取決於宿主核心有多少個系統呼叫)之間的地址。

接著,zpoline 會在虛擬位址 0 上實作一段跳板程式碼,設計如下:

  • 虛擬位址 0 ~ 最大系統呼叫編號的範圍會填入重複的單 byte nop 指令(0x90
  • 緊接在最後一個 nop 指令之後,插入一段程式,將控制權轉交至真正的 hook 函式。

那麼整體執行起來就會像下圖:

screenshot-2025-06-22-pm3.04.26

  • 被重寫的 callq *%rax 會跳轉至跳板程式中對應系統呼叫編號的位置(即一個 nop 指令);
  • 同時會把 caller 的返回位址壓入堆疊 (call 指令會做)
  • 執行流程會順著 nop 指令「滑落(fall-through)」,直到最後一個 nop 之後的 jmp hook 被執行
  • 此時 hook 函式執行時的暫存器狀態,與核心模式的系統呼叫常式看到的一模一樣;
  • 當 hook 函式結束時,便利用堆疊中儲存的 caller 位址跳回原程式。

um-nommu(z)

幸運的是,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) ,首先看到跳板程式碼設置:

static int __init setup_zpoline_trampoline(void) { ... /* zpoline: map area of trampoline code started from addr 0x0 */ __zpoline_start = 0x0; for (i = 0; i < NR_syscalls; i++) __zpoline_start[i] = 0x90; ... /** * put code for jumping to __kernel_vsyscall. * movabs [$addr],%r11 * jmpq *%r11 */ ptr = NR_syscalls; /* 49 bb [64-bit addr (8-byte)] movabs [64-bit addr (8-byte)],%r11 */ __zpoline_start[ptr++] = 0x49; __zpoline_start[ptr++] = 0xbb; __zpoline_start[ptr++] = ((uint64_t) __kernel_vsyscall >> (8 * 0)); __zpoline_start[ptr++] = ((uint64_t) __kernel_vsyscall >> (8 * 1)); __zpoline_start[ptr++] = ((uint64_t) __kernel_vsyscall >> (8 * 2)); __zpoline_start[ptr++] = ((uint64_t) __kernel_vsyscall >> (8 * 3)); __zpoline_start[ptr++] = ((uint64_t) __kernel_vsyscall >> (8 * 4)); __zpoline_start[ptr++] = ((uint64_t) __kernel_vsyscall >> (8 * 5)); __zpoline_start[ptr++] = ((uint64_t) __kernel_vsyscall >> (8 * 6)); __zpoline_start[ptr++] = ((uint64_t) __kernel_vsyscall >> (8 * 7)); /* 59 pop %rcx */ __zpoline_start[ptr++] = 0x59; /* 41 ff e3 jmp *%r11 */ __zpoline_start[ptr++] = 0x41; __zpoline_start[ptr++] = 0xff; __zpoline_start[ptr++] = 0xe3; /* permission: XOM (PROT_EXEC only) */ ret = os_protect_memory(0, PAGE_SIZE, 0, 0, 1); if (ret) panic("failed: can't configure permission on trampoline code"); return 0; } arch_initcall(setup_zpoline_trampoline);
  • 第 4 行 for 迴圈將地址 0 ~ NR_syscalls (大約 512) 位置都填上指令 nop (0x90)
  • 接著第 13 至 24 行將 hook 函式 (__kernel_vsyscall) 的地址入暫存器 r11
  • 第 27 行將 call 指令推入堆疊的 RIP 存入暫存器 rcx,模擬真實系統呼叫
  • 29 到 31 行填入 jmp *%r11 以跳到 __kernel_vsyscall (即 nabla-linux hook 函式),最後將此分頁設為僅允許執行
  • 第 38 使此函式在核心早期初始化階段即執行完畢,早於任何使用者行程載入。

接著看到二進位改寫如何被實作,首先看到 load_elf_fdpic_binary() 函式,會在 execve() 流程的收尾階段被呼叫,依據目標 ELF 將程式碼與資料段映射至記憶體 (fdpic 表示載入位置無關執行檔,PIE) :

可參見 cs:app CH7

... retval = elf_arch_finalize_exec(&exec_params, &interp_params); if (retval) goto error; finalize_exec(bprm); // 釋放 binfmt 資源 start_thread(regs, entryaddr, current->mm->start_stack);
  • 在此函式的結尾 (第 2 行),函式內即執行 zopline 二進位改寫,在原本的核心中,該函式定義如下
/* fs/binfmt_elf_fdpic.c */
int __weak elf_arch_finalize_exec(struct elf_fdpic_params *exec,
                                  struct elf_fdpic_params *interp)
{
        return 0;             // 預設什麼也不做
}
  • 注意到 __weak 前綴,這使得 elf_arch_finalize_exec 這個符號在鏈結時被視為「弱符號」 (預設情況函式符號皆為強符號),而 zpoline 此時在實作另一份自定義的 elf_arch_finalize_exec 函式,即能夠在鏈結時將此處的弱函式符號覆蓋為自定義強函式符號 (以下為其簡化後版本)。
/* arch/x86/um/zpoline.c */ int elf_arch_finalize_exec(struct elf_fdpic_params *exec_params, struct elf_fdpic_params *interp_params) { if (!um_zpoline_enabled) return 0; down_write(&current->mm->mmap_lock); int n = __zpoline_translate_syscalls(exec_params); n += __zpoline_translate_syscalls(interp_params); up_write(&current->mm->mmap_lock); pr_debug("zpoline: rewritten %d syscalls\n", n); return 0; }
  • 第 8 行取得 write lock,確保沒有其他執行緒同時修改程式碼段。
  • __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 檔,也是我們較常看到的
      • musl 的特殊之處在於:同一個 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 這種單映像、單職責的容器環境裡,此取捨是較能夠接受的。

  • Nabla 容器通常只跑一個任務,映像大小不影響「多租戶密度」
  • 也較少需要考慮多程式共用同一份 .so 的記憶體節省

實際操作

編譯 um-nommu,基本上與編譯正常核心相似,首先於 leowu0411/linux-nommu-z 取得已經整合 zpoline 的核心程式碼並進編譯,注意使用 ARCH=um 指定欲編譯為 UML 架構:

$ make mrproper
$ make ARCH=um x86_64_nommu_defconfig O=build
$ scripts/config --file build/.config --set-val CONFIG_STATIC_LINK y
$ make -j$(nproc) ARCH=um O=build CONFIG_UML_NET_VECTOR=y \
                    CFLAGS+="-DCONFIG_UML_NET_VECTOR"

順利編譯後應該可以在 build 目錄下看到執行檔 vmlinux,我們可以利用其於使用者空間運行 linux 核心!執行以下命令觀察此執行檔可以發現,由於 No-MMU 環境下所有行程共用同一定址空間,因此核心以靜態連結方式產生。:

$ file build/vmlinux
build/vmlinux: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, BuildID[sha1]=b5a0125926cf11fcae288b7841475f80ad87b730, with debug_info, not stripped

接著就跟運行一般核心一般,我們需要為這個 no-mmu 環境準備 rootfs,這裡採用 Alpine Linux,因其套件皆為 PIE(Position-Independent Executable),天生適合 No-MMU;具體做法是以 Alpine Linux 為基底,再將 BusyBox 與 musl-libc 置換為相容的 no-MMU 版本。:

  • BusyBox:以 PIE 編譯,並將 fork() 改為 vfork() 以避免 COW 需求。
  • musl-libc:同樣 PIE 編譯;原本 musl 對 x86_64 No-MMU 支援不全,已於修補中加以擴充。

注意這些調整僅牽涉編譯旗號或系統呼叫替換,無需修改程式邏輯,整體成本低於改寫程式,我們可以利用以下已經封裝的 docker 映像,並將其輸出為 .ext4 格式的檔案磁碟供 uml 作為檔案系統。

$ cid=$(sudo docker create \
        --entrypoint /bin/true \
        ghcr.io/thehajime/alpine:3.20.3-um-nommu)
$ sudo docker export "$cid" > alpine-nommu.tar
$ sudo docker rm "$cid"

$ dd if=/dev/zero of=alpine.ext4 bs=1 count=0 seek=1G
$ chmod o+rw alpine.ext4
$ yes | mkfs.ext4 alpine.ext4 || true
$ mnt=$(mktemp -d)
$ sudo mount alpine.ext4 "$mnt"
$ sudo tar -xf alpine-nommu.tar -C "$mnt"
$ sudo umount "$mnt"
$ rmdir "$mnt"

接著為 uml 準備 TUN/TAP 虛擬網路介面,用以連結宿主網路;TUN/TAP 介面是一種由宿主核心提供的虛擬網路裝置,允許 UML 與宿主互相交換網路封包。

$ sudo ip tuntap add dev tap100 mode tap user $USER
$ sudo ip addr add 192.168.122.1/24 dev tap100
$ sudo ip link set up dev tap100
  • TAP 模式提供 Layer-2 乙太通道,讓 UML 在網路上就像直接接到宿主的同一區域網路。使用時,需由宿主先建立並設定一個 TAP 裝置(例如 tap100),再於 UML 執行命令中指定該裝置名稱。
  • 在宿主端,指定這個新增的網路裝置 tap100 一個私有 IP (192.168.122.1/24),後續 UML 可以把它作為 gateway,接著把介面狀態設為 UP(啟用)。
  • 資料收發機制:UML 啟動時,其 vector drivers 會將 tap100 與內部介面綁定,就像做了一張虛擬網卡 (此例 vec0),且取得該宿主裝置的描述子;
    • TX:客體送出的乙太封包由 UML 核心寫入該描述子,宿主核心 TUN 驅動將其寫入 tap100 中的 ring buffer。
    • RX:當宿主核心將 frame 投遞到 tap100 時,UML 能夠透過 poll/epoll 得知 fd 可讀,隨即取回資料並交回客體的 protocal stack。

可以透過 ifconfig 觀察這個新網路裝置:

tap100: flags=4099<UP,BROADCAST,MULTICAST>  mtu 1500
        inet 192.168.122.1  netmask 255.255.255.0  broadcast 0.0.0.0
        ether 3a:c8:0b:91:e9:76  txqueuelen 1000  (Ethernet)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 10 overruns 0  carrier 0  collisions 0

最後我們終於可以啟動 uml,執行以下命令:

$ ./build/vmlinux vec0:transport=tap,ifname=tap100,depth=128,gro=1 \
ubd0=./alpine.ext4 rw mem=1024m loglevel=8 console=tty init=/sbin/init
  • 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

Run /sbin/init as init process with arguments: /sbin/init with environment: HOME=/ TERM=linux uml-vector uml-vector.0 vec0: tap: using vnet headers for tso and tx/rx checksum uml-vector uml-vector.0 vec0: netif_napi_add_weight_locked() called with weight 128 (none):/# ifconfig | grep vec0 vec0 Link encap:Ethernet HWaddr 32:52:34:E3:8A:D0 (none):/# ping -c 3 192.168.122.1 PING 192.168.122.1 (192.168.122.1): 56 data bytes 64 bytes from 192.168.122.1: seq=0 ttl=64 time=0.145 ms 64 bytes from 192.168.122.1: seq=1 ttl=64 time=0.109 ms 64 bytes from 192.168.122.1: seq=2 ttl=64 time=0.124 ms --- 192.168.122.1 ping statistics --- 3 packets transmitted, 3 packets received, 0% packet loss round-trip min/avg/max = 0.109/0.126/0.145 ms (none):/# poweroff (none):/# EXT4-fs (ubda): re-mounted 1f743ba3-fad8-44aa-8414-5891a154b906 ro. The system is going down NOW! Sent SIGTERM to all processes Sent SIGKILL to all processes Requesting system poweroff reboot: Power down
  • 可以看到第一行為這環境的一支程式,即是我們指定的 /sbin/init,其負責接手核心控制權並初始化使用者空間。它會依照 /etc/inittab 的設定先執行開機腳本
    • /etc/init.d/rcS,此腳本對我們開機時設定的 vec0 介面進行網路參數設定(IP, route, DNS 等)
  • 接著我們測試其與宿主的溝通是否成功 (line 11),結果顯示 UML 與宿主間的網路已正常運作
  • 接著輸入 poweroff 即可退出 uml。

但注意此時的 um-mommu 並不是 zpoline 版本,而是依靠 seccomp 進行系統呼叫攔截的 um-nommu(s),但我們可以很快切換過去,步驟如下。

$ sudo sh -c "echo 0 > /proc/sys/vm/mmap_min_addr"
$ ./build/vmlinux vec0:transport=tap,ifname=tap100,depth=128,gro=1 \
ubd0=./alpine.ext4 rw mem=1024m loglevel=8 console=tty zpoline=1 init=/sbin/init
  • 第一行的命令調整 mmap_min_addr
    • 典型 Linux 會將行程可映射的最低位址設為 65536 (64 KiB)以減少 NULL dereference 攻擊面
    • 而 zpoline 需在 0x0 安插跳板程式碼,故在啟動前暫時降至 0,建議在測試完後將其改回預設值
  • 而第二行則與先前啟動 uml 的命令基本一樣,不過多了參數 zpoline=1 ,執行 dmesg | grep zpoline 預計能看到以下輸出
(none):/# dmesg | grep zpoline
Kernel command line: vec0:transport=tap,ifname=tap100,depth=128,gro=1 ubd0=../alpine.ext4 rw loglevel=8 console=tty zpoline=1 init=/sbin/init root=98:0
zpoline: setting up trampoline code done
  • 於宿主端我們觀察行程記憶體配置,預計能看到以下結果,最值得注意的是第一行 00000000-00001000 --xs,此段記憶體即為跳板程式碼,--xs 表示執行/共享,不可讀寫
$ ps aux | grep vmlinux
$ cat /proc/<PID>/maps
00000000-00001000 --xs 00000000 00:01 10344                              /dev/zero (deleted)
60000000-60001000 r--p 00000000 103:04 3451096                           /home/leo/linux2025/linux-nommu-z/build/vmlinux
60001000-6045b000 r-xp 00001000 103:04 3451096                           /home/leo/linux2025/linux-nommu-z/build/vmlinux
6045b000-605a3000 r--p 0045b000 103:04 3451096                           /home/leo/linux2025/linux-nommu-z/build/vmlinux
605a3000-605a6000 rwxp 005a3000 103:04 3451096                           /home/leo/linux2025/linux-nommu-z/build/vmlinux
605a6000-605b8000 rw-p 005a6000 103:04 3451096                           /home/leo/linux2025/linux-nommu-z/build/vmlinux

...

7ffff7ff9000-7ffff7ffd000 r--p 00000000 00:00 0                          [vvar]
7ffff7ffd000-7ffff7fff000 r-xp 00000000 00:00 0                          [vdso]
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                          [stack]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

至此,我們已經成功在 um-nommu(z) 上啟動 Alpine Linux,並能順利執行 shell, BusyBox 等使用者空間程式。接下來,會利用事先打包好的 Docker 映像,透過 podman 以 rootless 方式啟動,展示其無特權執行的能力。該映像中已包含 Python, Redis, Nginx 等常用應用。以下說明實際執行結果與問題。

# um-nommu(s)
$ podman run -it -v /dev/shm:/dev/shm --rm ghcr.io/leowu0411/alpine:demo-3.20.6

# um-nommu(z)
$ podman run -e ZPOLINE=1 -it -v /dev/shm:/dev/shm --rm ghcr.io/leowu0411/alpine:demo-3.20.6
  • python
(none):/# python
Python 3.12.11 (main, Jun  9 2025, 08:58:46) [GCC 13.2.1 20240309] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 2 * 100
200
>>> print("This is um-nommu(z)")
This is um-nommu(z)
  • redis
(none):/# redis-server &
[1] 42 redis-server
(none):/# 42:C 27 Jun 2025 11:57:57.401 # WARNING Memory overcommit must be enabled! Without it, a background save or replication may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
42:C 27 Jun 2025 11:57:57.401 * oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
42:C 27 Jun 2025 11:57:57.401 * Redis version=7.2.9, bits=64, commit=00000000, modified=0, pid=42, just started
42:C 27 Jun 2025 11:57:57.401 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
42:M 27 Jun 2025 11:57:57.401 * Increased maximum number of open files to 10032 (it was originally set to 1024).
42:M 27 Jun 2025 11:57:57.401 * monotonic clock: POSIX clock_gettime
                _._                                                  
           _.-``__ ''-._                                             
      _.-``    `.  `_.  ''-._           Redis 7.2.9 (00000000/0) 64 bit
  .-`` .-```.  ```\/    _.,_ ''-._                                  
 (    '      ,       .-`  | `,    )     Running in standalone mode
 |`-._`-...-` __...-.``-._|'` _.-'|     Port: 6379
 |    `-._   `._    /     _.-'    |     PID: 42
  `-._    `-._  `-./  _.-'    _.-'                                   
 |`-._`-._    `-.__.-'    _.-'_.-'|                                  
 |    `-._`-._        _.-'_.-'    |           https://redis.io       
  `-._    `-._`-.__.-'_.-'    _.-'                                   
 |`-._`-._    `-.__.-'    _.-'_.-'|                                  
 |    `-._`-._        _.-'_.-'    |                                  
  `-._    `-._`-.__.-'_.-'    _.-'                                   
      `-._    `-.__.-'    _.-'                                       
          `-._        _.-'                                           
              `-.__.-'                                               

42:M 27 Jun 2025 11:57:57.403 * Server initialized
42:M 27 Jun 2025 11:57:57.403 * Loading RDB produced by version 7.2.9
42:M 27 Jun 2025 11:57:57.403 * RDB age 6 seconds
42:M 27 Jun 2025 11:57:57.403 * RDB memory usage when created 0.85 Mb
42:M 27 Jun 2025 11:57:57.403 * Done loading RDB, keys loaded: 0, keys expired: 0.
42:M 27 Jun 2025 11:57:57.403 * DB loaded from disk: 0.000 seconds
42:M 27 Jun 2025 11:57:57.403 * Ready to accept connections tcp

(none):/# redis-cli 
127.0.0.1:6379> ping
PONG
127.0.0.1:6379> 
  • nginx
(none):/# cat /etc/nginx/http.d/default.conf 
# This is a default site configuration which will simply return 404, preventing
# chance access to any other virtualhost.
server {
	listen 80 default_server;
	listen [::]:80 default_server;

	# Everything is a 404
	location / {
		return 404;
	}

	# You may need this to prevent return 404 recursion.
	location = /404.html {
		internal;
	}
}

(none):/# nginx -g "master_process off; daemon off;" &
[1] 31 nginx -g master_process off; daemon off;
(none):/# curl -I 127.0.0.1
HTTP/1.1 404 Not Found
Server: nginx
Date: Fri, 27 Jun 2025 12:28:38 GMT
Content-Type: text/html
Content-Length: 146
Connection: keep-alive
  • 透過 rootless Podman 於 um-nommu(z) 能夠成功執行 Python, Redis 與單行程 Nginx,證實使用者空間常見服務在此環境仍能運作
  • 對於 nginx ,惟缺乏 fork() 仍限制需改用 master_process off; daemon off 等設定,假使直接執行 nginx & ,系統會回傳 fork() failed 並中止執行。
  • 此外,rootless Podman 仍存在一項限制:若在宿主系統預先建立的 TAP 裝置未對容器授予 NET_ADMIN 能力,容器便無法存取該裝置;此問題仍待後續尋求合適的解決方案。
    • 在本機測試時,我們之所以能夠不使用 sudo 就啟動服務,是因為在建立 TAP 裝置時,已將該裝置的存取權限授予目前的非特權使用者。然而,rootless Podman 內的行程運行於獨立的 network namespace,無法繼承主機 TAP 裝置的使用者權限,因此此作法在容器中並不適用。

分析

參見 Continuous Benchmarking

lmbench

首先量測 zpoline 為 uml 帶來的效能改進,上圖展示在各種不同隔離環境下的系統呼叫延遲,紅色代表原生宿主,淺綠以及綠色代表原生 uml,淺藍色代表使用 seccomp filter 以信號作為攔截手段的 uml,最後深藍色代表 nabla-linux 加上 zpoline。

  • select-n : 對分別 n 個檔案進行 select 系統呼叫,可以看到 um-nommu(z) 延遲略低於原生系統,這是因為對 um-nommu(z) 來說,系統呼叫等同一般函式呼叫,不涉及特權模式切換。
  • syscall : 低 overhead 的系統呼叫,此選用 getpid(),旨在測量由系統呼叫發起的使用者與核心切換之延遲成本
    • 較原生系統略快原因同上,此外對 zpoline 來說,有部分延遲取決於 nop 的數量
    • 但相比於使用 seccomp 攔截並透過 SIGSYS 信號處理常式的手法 (淺藍色) 有 10 倍以上的效能改進
  • 最後值得注意的是,zpoline 在 fork() + execve() 的表現是所有組別中最差的,係因二進位改寫即發生在程式載入的時候。

iperf3
最後看到網路吞吐量的表現如何,利用 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,扮演理論上限的角色)