Try   HackMD

Linux 核心專題: 輕量級容器實作和擴充

執行人: Andrushika

任務描述

比照〈Linux containers in 500 lines of code〉,善用 Linux 核心提供的 namespaces, capabilities, seccomp 等機制,開發輕量級的 Linux container,使其具備部分 Docker 和 nerdctl 功能,並學習 tinitiniktls,開發兼具輕量和安全的 init(1) 實作。

TODO: 打造輕量級容器

研讀 Linux containers in 500 lines of code 並著手練習,最終專案應指定為 MIT License 或 BSD License 一類較寬鬆的條款,避免直接自 GPL 授權的程式碼複製 (應適度改寫)。預期要能運作 Alpine Linux 在內的 Linux 系統。
限定用 C 語言或 Rust 語言開發,可改寫現有專案,但應向授課教師確認細節。
嘗試降低對超級使用者 (即 root) 權限的依賴

延伸閱讀: Rootless containers

參考專案:

TODO: 探討 tini/tiniktls 實作考量

改進以下草稿並強化實作細節
確認 tini 及 tiniktls 得以運作於自行開發的 Linux container

tini 是個極簡化的 init 系統,專門用來啟動單一子行程並等待其結束;即使該子行程已成為僵屍行程 (zombie process),也能正確偵測,並將收到的訊號 (signal) 一併轉發給它。若你在 Docker 容器中執行,只需在 docker run 時加上 --init 參數,就會自動注入 /sbin/docker-init(即 tini)到容器內,並取代原有的 ENTRYPOINT,讓你的程式直接在 tini 底下執行。

在步調快速的容器化微服務世界中,效率、安全性與簡潔性至關重要。應用程式需要安全地通訊,通常會使用 TLS,但在每個小型服務中嵌入並管理完整的 TLS 堆疊,可能導致映像檔臃腫、資源消耗增加,以及擴大受攻擊面 (attack surface)。tiniktlstini 程式的巧妙進化,它透過將 TLS 操作 offload 到 Linux 核心 (藉由 KTLS) 和 tini 本身,讓子應用程式能夠保持輕巧並專注於核心功能。

tini 在 Linux 容器的應用

Docker 與其他容器技術徹底改變應用程式的部署方式。然而,在容器內正確執行應用程式,特別是作為行程 ID 1 (PID 1) 執行時,伴隨著特定的責任。Linux 系統中的 PID 1 很特別:它是 init 行程,負責接管孤兒行程 (orphaned processes,防止殭屍行程 [zombie processes] 產生) 並正確處理訊號,以確保容器得以優雅關閉。

許多應用程式並非設計來作為 PID 1 執行。於是 tini 應運而生:專為容器設計的最小化 init 程式,其主要考量如下:

  1. 正確的訊號處理: tini 會將訊號 (如 SIGTERMSIGINT) 轉發給它所啟動的子行程,讓應用程式得以優雅關閉
  2. 殭屍行程回收: 若主應用程式 fork 後的子行程在主應用程式結束前終止,tini (作為 PID 1) 會回收這些殭屍行程,防止資源洩漏
  3. 降低資源佔用: tini 相當小巧,對容器映像檔增加的負擔很低

基於這些特性,tini 是 Dockerfile 中常見的 ENTRYPOINT,為主容器化應用程式確保穩健的行程管理。

KTLS: Linux 核心 TLS 基礎建設

雖然 TLS 對於安全性至關重要,但傳統的使用者空間 (userspace) TLS 函式庫 (如 OpenSSL、GnuTLS 或 rustls) 會在使用者空間執行交握 (handshake) 和應用程式資料的對稱加解密。這涉及到系統呼叫、核心空間與使用者空間之間的資料複製,以及密碼學運算的 CPU 週期消耗。

KTLS 提供顯著的效能最佳化。KTLS 允許 TLS 會話 (session) 的對稱密碼學部分直接在核心中處理。其過程通常如下:

  1. 使用者空間交握: TLS 交握 (認證、金鑰協商) 仍由 TLS 函式庫在使用者空間執行
  2. 金鑰 offload : 一旦會話金鑰 (及其他參數如初始向量 (IV) 和序號 (sequence number)) 建立完成,它們會透過 setsockopt() 函式,使用 SOL_TLS 層級以及 TLS_TX (用於傳輸) 和 TLS_RX (用於接收) 等選項傳遞給核心。特定的密碼學資訊是透過像 struct tls_crypto_info 這樣的結構或其特定加密套件 (cipher) 的變體 (例如 struct tls12_crypto_info_aes_gcm_128) 來提供。
  3. 核心資料路徑: 應用程式隨後可在該 socket 上傳送和接收未加密的資料。核心會對該已啟用 KTLS 的 socket 上的傳出資料進行透明加密,並對傳入資料進行透明解密。

KTLS 的優勢:

  • 降低 CPU 負載: 將密碼學運算 offload 到核心可以更有效率,尤其是在系統具備硬體密碼學加速 (例如 Intel AES-NI 指令集) 的情況
  • 更低延遲: 資料路徑繞過使用者空間,減少上下文切換 (context switch) 和資料複製
  • 簡化應用程式資料處理: KTLS 設定完成後,應用程式只需處理純文字資料流

延伸閱讀:

融合 tini 與 KTLS

tiniktlstini 的概念又向前推進一步。有鑑於 tini 已是 PID 1 並且管理著主應用程式,tiniktls 將 TLS 交握邏輯和 KTLS offload 功能直接整合到這個 init 行程中。

核心架構與工作流程:

  1. tini 基礎: tiniktls 保留 tini 的核心職責:
    • 它會 spawn 主子應用程式 (透過命令列參數傳遞)
    • 它處理從容器執行環境 (runtime) 到子行程的訊號轉發
    • 它回收殭屍行程 (若透過 TINI_SUBREAPER 設定,還可以充當「子回收器」(subreaper)
  2. 用於子行程通訊的控制 socket:
    • tiniktls 啟動時,會建立 Unix domain socket 對 (socketpair())。此 socket 對的一端 (main 函式中的 child_sock) 會傳遞給 fork 出來的子應用程式,通常是透過像 TINIKTLS_FD 這樣的環境變數。這成為了控制通道。
    • 若子應用程式需要 TLS 服務,它會透過這個 TINIKTLS_FD 使用簡單的純文字協定與 tiniktls 通訊
  3. 純文字控制協定:
    • 子行程傳送類似以下的命令:
      • CONNECT tls://hostname:port[?query_args]:請求一個對外的 TLS 連線。
      • ACCEPT tls://listen_addr:port[?query_args]:請求 tiniktls 監聽傳入的 TLS 連線。
      • CONNECT tcp://...ACCEPT tcp://...:用於純 TCP (無 TLS)
      • CLOSE tls://listen_addr:port:停止監聽
    • 查詢參數 (query arguments) 可指定諸如客戶端/伺服器憑證 (cert=key=)、期望的 TLS 版本 (tls=1.2)、特定的加密套件 (ciphers=...) 或連線參數 (backlog=) 等。
  4. tiniktls 作為 TLS 交握引擎。當 tiniktls 收到 TLS 的 CONNECTACCEPT 命令時:
    • 它會為實際的網路通訊建立一個新的 socket
    • 它使用 OpenSSL (SSL_CTX_newSSL_newSSL_set_fd 等) 來執行 TLS 交握 (SSL_connect 用於客戶端,SSL_accept 用於伺服器端)
    • 它使用安全的加密套件設定 OpenSSL (例如 tiniktls.c 中的 ktls12_cipher_listktls13_cipher_suites) 並啟用 KTLS 意圖 (SSL_OP_ENABLE_KTLS 設定於 SSL_CTX)
    • 為客戶端連線設定 SNI (Server Name Indication,伺服器名稱指示)
    • 可根據查詢參數或預設值載入伺服器/客戶端憑證和金鑰
  5. KTLS offload 啟用:
    • 至關重要的是,在 OpenSSL 交握成功後,tiniktls (在正確的實作中) 會提取協商好的會話金鑰、初始向量、序號和加密套件詳細資訊
    • 然後它會使用 setsockopt() 函式,配合 SOL_TLSTLS_TXTLS_RX 選項,將適當的 struct tls_crypto_info (或其變體) 提供給核心。這會告知核心接管該 socket 的加解密工作
  6. 檔案描述子 傳遞給子行程:
    • 一旦 TCP 連線建立 (對於純 TCP) 或 TLS 交握完成且 KTLS 設定完畢 (對於 TLS),tiniktls 會透過控制 socket (childfd) 將回應傳回給子行程。
    • 對於建立資料連線的成功 OK 回應,tiniktls 會使用 sendmsg() 函式搭配 SCM_RIGHTS 選項,將網路 socket 的檔案描述子 (若使用 TLS,現在已啟用 KTLS) 傳遞給子行程
    • 子應用程式接收到這個 FD 後,就可以直接使用標準的 read(), write(), send(), recv() 等函式呼叫,傳送和接收未加密的應用程式資料
  7. 使用 epoll 的事件驅動架構:
    • tiniktls 使用 epoll 以非阻塞 (non-blocking) 方式管理多個檔案描述子:childfd (控制 socket)、監聽 socket,以及正在進行連線或 TLS 交握的 socket
    • ktls_serve() 函式包含主事件迴圈,處理事件並推進每個受管理 socket 的狀態機 (state machine) (例如 STATE_TCP_CONNECTINGSTATE_TLS_HANDSHAKING_CLIENTSTATE_KTLS_SETUP 等)

考量因素:

  • 降低應用程式負載:應用程式的資料路徑不需要連結 OpenSSL 或任何其他 TLS 函式庫。這大幅減少執行檔空間、記憶體佔用和建置複雜性
  • 集中式 TLS 邏輯: TLS 版本、加密套件、憑證處理和 KTLS 設定的複雜性都由 tiniktls 管理。TLS 堆疊的安全性更新只需套用於 tiniktls,而非每個獨立的應用程式
  • 效能提升:利用 KTLS,應用程式可從核心層級的加解密中受益,這通常比使用者空間 TLS 更快,尤其是在有硬體輔助的情況下
  • 簡化開發者工作: 應用程式開發者在從 tiniktls 接收到 FD 後,只需處理標準 socket 和純文字資料。

tiniktls 為容器中的 TLS offload 提供輕巧高效的方案。對於那些重視映像檔大小的應用場景而言,尤具有吸引力,甚至可在特定情境提供 sidecar proxy 的的替代方案。

tiniktls 的效益分析:

  • 縮減映像檔大小: 從每個微服務中移除 OpenSSL/密碼學函式庫,可顯著縮小容器映像檔。這有助於更快的映像檔拉取、降低儲存成本和更快的擴展 (scaling)
  • 更低的執行時期記憶體佔用: 由於不用載入完整的 TLS 堆疊,每個應用程式實例消耗的記憶體更少。這允許更高的部署密度 (每個節點可容納更多容器)
  • 更快的應用程式啟動速度: 應用程式本身的程式碼和需要初始化的函式庫更少
  • 簡化的依賴關係:減少每個應用程式中需要追蹤和更新漏洞的函式庫數量
  • 效能: KTLS 為潛在高吞吐量或對延遲敏感的函式提供高效的加解密,且無使用者空間的額外負載

應用情境:

  1. 建構直接使用 TCP (不一定是 HTTP/S) 的客製化網路服務,這些服務需要高吞吐量、低延遲以及 TLS 加密。例如即時資料串流、特化的資料庫協定,或金融應用程式的服務間通訊。這些服務通常不需要完整 proxy 的 L7 功能。
  2. 針對原本不支援 TLS 的應用程式新增 TLS 功能,也就是 inetd 風格的方裝:若原有應用程式可透過讀取標準輸入和寫入標準輸出的方式運作,則可以潛在地設定 tiniktls (透過修改或擴展其現有命令集) 來監聽 TLS 連線、執行交握,然後 fork 舊版應用程式,並將已啟用 KTLS 的 socket 重新導向到子行程的 stdin/stdout

核心機制

namespace

根據 man page 上的 namespace(7) 定義:

A namespace wraps a global system resource in an abstraction that makes it appear to the processes within the namespace that they have their own isolated instance of the global resource. Changes to the global resource are visible to other processes that are members of the namespace, but are invisible to other processes. One use of namespaces is to implement containers.

namespace 有多種分類,有 cgroup, IPC, network, mount, PID, time, user, UTS 等等,負責隔離不同的系統資源,參閱官方文件。所以 user namespace 只是其中的一種,負責隔離 UID 和 GID,特別的是 user namespace 建立後可以讓行程擁有看起來像 root 的權限,可以再用來建立其他 namespace。(namespace 需要有 root 才可以建立)
雖然會紀錄 owner,但建立出來的 namespace 和其建立者 (user namespace) 並非從屬關係,層級上都是同一層。權限管理上,只要有權限控制一個 namespace 的 owner user namespace,就可以控制該 namespace。

每個行程都會對應到一套 namespace,namespace 是全系統存在並可見的,例如:
(print $1, $9, $10, $11 是資料對應的 column)

$ ls -l /proc/$$/ns | awk '{print $1, $9, $10, $11}'
total 0
lrwxrwxrwx. cgroup -> cgroup:[4026531835]
lrwxrwxrwx. ipc -> ipc:[4026531839]
lrwxrwxrwx. mnt -> mnt:[4026531840]
lrwxrwxrwx. net -> net:[4026531969]
lrwxrwxrwx. pid -> pid:[4026531836]
lrwxrwxrwx. pid_for_children -> pid:[4026531834]
lrwxrwxrwx. time -> time:[4026531834]
lrwxrwxrwx. time_for_children -> time:[4026531834]
lrwxrwxrwx. user -> user:[4026531837]
lrwxrwxrwx. uts -> uts:[4026531838]

capability

把 root 的權限拆成多個小項目,可以分配給不同行程;但只靠 capability 沒辦法管理所有權限,例如,有些執行的程式會直接檢查 uid = 0,就可以直接執行,其不在 capability 的管轄範圍內,所以還需要依靠其他手段一起管理權限。

root 的權限拆成非常多細項,包含 CAP_SYS_NICE (可以竄改行程的 nice 值), CAP_DAC_OVERRIDE(在檔案讀、寫、執行時跳過權限檢查)等等,對於 container 來說,capability 沒有管理好對於 host 會是大災難,例如可能可以在容器內修改行程的 nice,而 scheduler 是沒有 namespace 隔離的,這時候整個系統的排程就會受到影響。

Past and current implementation

A full implementation of capabilities requires that:

  • For all privileged operations, the kernel must check whether
    the thread has the required capability in its effective set.
  • The kernel must provide system calls allowing a thread's
    capability sets to be changed and retrieved.
  • The filesystem must support attaching capabilities to an
    executable file, so that a process gains those capabilities
    when the file is executed. (binary file 也具有 capabilities)

Thread capability sets

每個 thread 都具有以下幾種 capability sets,用來處理不同情況下的權限管理機制:

  • Permitted: 所有能用的潛在 capability,隨時可以被啟用
  • Effective: 當前真正作用中的 capability。執行操作的時候真正檢查的是這欄,是 Permitted 的子集合
  • bounding: 在執行 binary 時,binary 也有機會賦予自己 capability。這個 set 用來設定最多接受 binary 給的哪些特權
  • Inheritable: exec() 時參考,用來指定將哪些 capability 傳遞下去給被執行的 binary。

    thread 也可以目前不要這個權限(Permitted 裡面沒有),但當 exec() 某些有能力的 binary 時,可以選擇性地獲得它。

  • Ambient: 同樣 exec() 時參考,但是是必定會傳給下一個 thread 的 capability 清單。需注意的是,需要同時在 permitted 和 inheritable 之中,才可以被指到 Ambient 中。

File capability sets

比較少,僅有三種:

  • Permitted: 當 thread 執行自己時,會直接賦予它的 capability (加入 thread 的 permitted set)
  • Inheritable: 聲明希望繼承自 thread 的權限,如果 thread 不給就拿不到。會和 thread 的 Inheritable 做 AND,晉升到 Permitted
  • Effective: 一個 bit,如果這個 bit 為 1,則執行時 permitted 會直接生效為 effective

執行 execve() 時的 capability 轉換演算法

P'(ambient)     = (file is privileged) ? 0 : P(ambient)

P'(permitted)   = (P(inheritable) & F(inheritable)) |
                 (F(permitted) & P(bounding)) | P'(ambient)

P'(effective)   = F(effective) ? P'(permitted) : P'(ambient)

P'(inheritable) = P(inheritable)    [i.e., unchanged]

P'(bounding)    = P(bounding)       [i.e., unchanged]

特別把這兩個小規則拿出來講:

P'(permitted)   = (P(inheritable) & F(inheritable)) |
                 (F(permitted) & P(bounding))

我一開始的疑問是,既然這兩個規則的作用那麼像,為何不能縮減成一條規則就好?
舉例來說,如果我想要讓某 binary 永遠能獲得權限,那透過 P(inheritable) & F(inheritable) 單一一條規則也能做到,為何需要另一條規則?

兩者的差異在責任歸屬的問題:
F(permitted) & P(bounding)單向授權、是 binary 本身說明「我要有這個能力」、「我的最低執行要求」;thread 無需為 binary 準備,也無須知道 capability 的存在。所以要另外依賴系統上限 P(bounding) 來控制才安全。

P(bounding) 有點像是權限控制的防火牆、絕對不能碰的底線

P(inheritable) & F(inheritable) 則是雙向奔赴。binary 說「我想要」、process 說「我願意給你」,這時候授予權限就很安全。

回到最初自己的疑問:如果想要讓某 binary 永遠能獲得權限,且只透過 P(inheritable) & F(inheritable) 這條規則,那實作的方式就需要讓 binary 在 F(inheritable) 聲明「需要的權限」,並讓 process 無條件具備攜帶很多 P(inheritable) 才會成立。這會使得提權極其容易發生。

幫助了解 capability 的例子

情景一 - process 想在未來擁有特權,但現在無權使用

以網路控制權限為例,這種情況下,就可以將 Thread 設定成:

P(permitted)    = {}
P(inheritable)  = {CAP_NET_BIND_SERVICE}

被 exec 的 binary 設定為:

F(permitted)    = {}
F(inheritable)  = {CAP_NET_BIND_SERVICE}
F(effective)    = 1

最終結果:

P'(permitted) = P(inheritable) & F(inheritable) = {CAP_NET_BIND_SERVICE}
P'(effective) = F(effective) = 1 → P'(permitted)

比較特別的是,因為 file 的 effective 被設定為 1,所以繼承到的 permitted 會立即生效。

情景二:file 想要權限,但 process 不給

Thread 設定成:

P(permitted)    = {}
P(inheritable)  = {}

被 exec 的 binary 設定為:

F(permitted)    = {}
F(inheritable)  = {CAP_SYS_ADMIN}
F(effective)    = 1

最終結果:

P'(permitted) = {} & {CAP_SYS_ADMIN} = {}
P'(effective) = {}
情景三:file 帶權限,process 也有 bounding set,直接提權

Thread 設定成:

P(permitted)    = {}
P(inheritable)  = {}
P(bounding)     = {CAP_NET_ADMIN}

被 exec 的 binary 設定為:

F(permitted)    = {CAP_NET_ADMIN}
F(inheritable)  = {}
F(effective)    = 1

最終結果:

P'(permitted) = ({} & {}) | ({CAP_NET_ADMIN} & cap_bset) | {}  
              = {CAP_NET_ADMIN}
P'(effective) = {CAP_NET_ADMIN}

capability-dumb binary

是當 binary 具有 file capability (+ep) 時,若其自身「不會自行檢查權限才執行對應操作」、或針對權限不足時有額外失敗處理手段,那就容易造成漏洞發生。

為什麼是 +ep?當 file capability 的 Effective 被設為 1,那任何人來執行這個 binary,都會直接獲得對應的 Permitted 權限。如下,try_regain_cap 會認為當其被執行時,無論如何都會有 cap_mknod 權限。

$ sudo setcap "cap_mknod+ep" try_regain_cap
$ sudo ./contained -m . -u 0 -c try_regain_cap

但若當前的行程帶有 bounding,則 permitted 會被自動捨棄掉。try_regain_cap 本身不知道,可能會直接執行需要 cap_mknod 權限的行為,此時就會發生錯誤。

為了防止這種狀況,kernel 對於 capability-dumb binary 會有一層檢查:若行程在 execve 之後未能獲得全部的 F(permitted),那就會直接回傳 EPERM。要嘛拿到全部的權限、要嘛不要執行。

Mount

當建立 container 的時候,也會希望 container 內部所看到的檔案系統與 host 是隔離的。

mount_namespace

其實 mount 也運用到前面所提到的 namespace 技術,但 mount namespace 的概念有一些不一樣。先前討論 user namespace 時,新建的 namespace 中所使用的 uid, gid,和實際映射到 host 上面的不同;而 mount namespace 會完全相同。它比較像是建立了原先 mount tree 中所有 directory 名稱的「副本」,新 namespace 中同一個路徑仍然會指向同一個 fd。類似於 shallow copy shared pointer。而在新 namespace 中,若進行 mount()umount(),這些變動「預設」不會影響到 host 的掛載狀態。(但仍可能透過 mount propagation 傳播回 host 或其他 namespace)

如果單純使用 mount namespace 進行 touch, mkdir 等操作,還是會修改到 host 的檔案!

太酷了吧!但我不信邪,用實驗來驗證。

首先檢查目前 host 所處的 namespace:

$ ls -l /proc/$$/ns | grep mnt
lrwxrwxrwx 1 yucheng yucheng 0 May 24 16:53 mnt -> mnt:[4026532255]  

檢查一下目前的 mount 狀態,會得到一串又臭又長的掛載點列表:

$ mount
none on /usr/lib/modules/5.15.167.4-microsoft-standard-WSL2 type overlay (rw,nosuid,nodev,noatime,lowerdir=/modules,upperdir=/lib/modules/5.15.167.4-microsoft-standard-WSL2/rw/upper,workdir=/lib/modules/5.15.167.4-microsoft-standard-WSL2/rw/work)
none on /mnt/wsl type tmpfs (rw,relatime)
drivers on /usr/lib/wsl/drivers type 9p (ro,nosuid,nodev,noatime,dirsync,aname=drivers;fmask=222;dmask=222,mmap,access=client,msize=65536,trans=fd,rfd=8,wfd=8)
/dev/sdc on / type ext4 (rw,relatime,discard,errors=remount-ro,data=ordered)
none on /mnt/wslg type tmpfs (rw,relatime)

// ...ignore...

cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,rdma)
cgroup on /sys/fs/cgroup/misc type cgroup (rw,nosuid,nodev,noexec,relatime,misc)
none on /mnt/wslg/versions.txt type overlay (rw,relatime,lowerdir=/systemvhd,upperdir=/system/rw/upper,workdir=/system/rw/work)
none on /mnt/wslg/doc type overlay (rw,relatime,lowerdir=/systemvhd,upperdir=/system/rw/upper,workdir=/system/rw/work)
snapfuse on /snap/snapd/24505 type fuse.snapfuse (ro,nodev,relatime,user_id=0,group_id=0,allow_other)
snapfuse on /snap/ubuntu-desktop-installer/1286 type fuse.snapfuse (ro,nodev,relatime,user_id=0,group_id=0,allow_other)
snapfuse on /snap/ubuntu-desktop-installer/967 type fuse.snapfuse (ro,nodev,relatime,user_id=0,group_id=0,allow_other) 

之後使用 unshare 進入新的 mount namespace。可以看見 namespace id 已經改變:

$ sudo unshare -m
$ ls -l /proc/$$/ns | grep mnt
lrwxrwxrwx 1 root root 0 May 24 16:52 mnt -> mnt:[4026532261] 

之後在新的 namespace 中,把能取消掛載的全部 unmount,再把所有掛載點列出來看一次,會發現大多數的都不見了:

$ umount -a
umount: /dev: target is busy.
umount: /: target is busy.
$ mount
/dev/sdc on / type ext4 (rw,relatime,discard,errors=remount-ro,data=ordered)
none on /dev type devtmpfs (rw,nosuid,relatime,size=8118020k,nr_inodes=2029505,mode=755)
devpts on /dev/pts type devpts (rw,nosuid,noexec,noatime,gid=5,mode=620,ptmxmode=000)
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,noatime)
proc on /proc type proc (rw,nosuid,nodev,noexec,noatime)  

之後再開一個新的 terminal,再把掛載點列出一次。此時會發現,原先的掛載點都平安無事(此處不再把結果印出)。

事實上,以上實驗的結果還受到 mount propagation 的影響,將在 mount() 中解釋

接下來去印證看看,在新的 namespace 中 mkdir,這樣 host 看得到嗎?答案是看得到!

$ ls -l /proc/$$/ns | grep mnt
lrwxrwxrwx 1 root root 0 May 24 16:52 mnt -> mnt:[4026532261]
$ mkdir /hello_testing
$ ls /
Docker  boot  etc  home  lib  lib64  lost+found
mnt  proc  run  snap  sys  usr  bin  dev  hello_testing
init  lib32  libx32  media  opt  root  sbin  srv  tmp  var  
$ ls -l /proc/$$/ns | grep mnt
lrwxrwxrwx 1 yucheng yucheng 0 May 24 16:53 mnt -> mnt:[4026532255]
$ ls /
Docker  boot  etc  home  lib  lib64  lost+found
mnt  proc  run  snap  sys  usr  bin  dev  hello_testing
init  lib32  libx32  media  opt  root  sbin  srv  tmp  var 

mount()

這個系統呼叫的功能如其名,就是將檔案系統掛載到另一個路徑下。其中有一個比較需要注意的機制:mount propagation,參照官方文件關於 MS_PRIVATE, MS_SHARED 的說明。
先前的實驗中提及,在新的 mount namespace 中進行 mount(), umount() 不會影響到 host。事實上,這和掛載點本身的 mountflags 有關係。namespace 可以自行決定自己的該掛載點要不要和大家同步 mount 狀態,或是斷開連結。在 container 的實作中,通常會設定為 private,防止被其他 namespace 影響,也防止自己影響別人。
有四種 propagation type 可以使用,詳細可參照官方文件搜尋 "Changing the propagation type of an existing mount"。

而先前的實驗能成功的原因,在 unshare(1) 有提及:

unshare since util-linux version 2.27 automatically sets propagation to private in a new mount namespace to make sure that the new namespace is really unshared.

因為 unshare 之後 propagation 就被自動設定為 private 了,所以沒問題。

chroot()

這個系統呼叫可以讓當前的行程改變看待檔案目錄時的視角,比如:

chroot("/tmp");

access 的翻譯是「存取」,而非「訪問」(visit)

已修正!

這樣就會把 /tmp 當作存取檔案系統時的根目錄,後續若存取 /bin,實際上會存取到 /tmp/bin 這個路徑。
但這個系統呼叫有一些危險,因為他只有改變路徑解析的視角,事實上使用者還是有機會「逃出」這個 root,比如在設定 chroot 之後仍保留其他開著的 fd,就可以透過 fchdir() 逃走:

int fd = open("/", O_RDONLY);
chroot("/newroot");
fchdir(fd);    // 回到原 root
chroot(".");   // 再次 chroot -> 逃出成功

pivot_root()

這個系統呼叫可以真正將 / 的掛載樹替換掉,而非僅僅改變「視角」。由於 glibc 沒有提供 pivot_root() 的包裝,所以使用時需要以下列方式呼叫:

int syscall(SYS_pivot_root, const char *new_root, const char *put_old);

它做了兩件事情,拆開來說明比較好懂:

  1. 把現行的 / 換成 new_root 所指定的路徑
  2. 把原先的 old root(準備要被取代的)掛到 put_old 之下

需要注意的是,pivot_root() 只能夠在新建立的 mount namespace 中使用,與 host 相同 mount namespace 中不允許呼叫。理由也很顯而易見,如果 host 中 / 被換掉,那就天下大亂了。且呼叫時 put_old 必須是 new_root 下的子目錄,如此就算換了新 root,也保留了對上層目錄的存取方式。

接下來舉例說明其使用方式,假如在新的 mount namespace 中,路徑如下:

/
├── bin/
├── etc/
├── tmp/
├── newroot/        // 準備被指定的新 root
   ├── bin/
   ├── etc/
   └── oldroot/    // 空 directory,放舊的 root

接著執行 pivot_root("./newroot", "./newroot/oldroot") 進行重新指定 root,結果如下:

/                      // 原先的 newroot 變成 / 了!
├── bin/
├── etc/
├── oldroot/
   ├── bin/
   ├── etc/
   ├── tmp/
   ├── newroot/        // 神奇的是,newroot 在這邊還是存取得到,
                          形成遞迴的存取結構

在 container 的實作中,實際上不希望改到 host 檔案系統上的東西,所以會在 pivot_root() 之後,把 oldroot 整個 umount() 掉。

seccomp

根據 seccomp(2) 的說明,seccomp 的作用是:

operate on Secure Computing state of the process

其中 Secure Computing state 指的是 kernel 如何限制行程進行系統呼叫。若只依賴 capability 做權限管理,會漏掉一些「不需要 capability 就能執行的系統呼叫」,例如 clone(CLONE_NEWUSER)bpf()等,而這些系統呼叫若在 container 中被成功執行,往往可以造成提權;所以需要加上 seccomp 機制,針對每個系統呼叫做更細緻的使用限制。

實作時,會使用 libseccomp 所提供的 API 來使用這個機制。

seccomp_init()

scmp_filter_ctx seccomp_init(uint32_t def_action);

使用 seccomp 之前須初始化,其中 def_action 指的是「當遇到沒有被明確允許的系統呼叫」,對應的預設行為。

其中提供了多種預設行為,包含 SCMP_ACT_ALLOW(預設允許所有系統呼叫)、SCMP_ACT_KILL_PROCESS(直接中止整個行程)等等,可參閱 man page 說明。

seccomp_rule_add()

用來細項指定對於每個系統呼叫的限制。使用上,我認為它比較像過濾的概念,而非「鎖死」哪些系統呼叫完全使用。先看該系統呼叫的定義就能明白:

int seccomp_rule_add(
   scmp_filter_ctx ctx,
   uint32_t action,
   int syscall,
   unsigned int arg_cnt, 
   ...
);

ctx 是先前透過初始化得到的指標;action 用來指定「當和過濾規則相符的系統呼叫」觸發時對應的動作,可選擇 SCMP_ACT_KILL 直接中止執行緒、SCMP_ACT_ERRNO 對執行緒發送一個 errno 等等,可參閱 seccomp_rule_add(3)
重點是剩下的幾個參數:syscall 用來指定要限制的系統呼叫,arg_cnt 指的是將對幾個 syscall 的參數設定過濾規則,最後 ... 處則需要註明對於 syscall 每個參數的精細限制。因為系統呼叫最多只會有六個參數,可以使用SCMP_A{0-5}(),指定要對第幾個參數設定規則。

舉例來說,我想限制 clone 的使用,則可以寫:

// 拿第 0 個參數 (因為 SCMP_A0) 和 CLONE_NEWUSER 做 AND 運算
// 若結果等於 CLONE_NEWUSER 將觸發
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(clone), 1,
    SCMP_A0(SCMP_CMP_MASKED_EQ, CLONE_NEWUSER, CLONE_NEWUSER));

// 想同時限制第 0 個參數和第 2 個參數
// 注意 arg_cnt 變成 2
// 除先前所述規則外,如果第 2 個參數 == 0 也會觸發
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(clone), 2,
    SCMP_A0(SCMP_CMP_MASKED_EQ, CLONE_NEWUSER, CLONE_NEWUSER),
    SCMP_A2(SCMP_CMP_EQ, 0));

cgroups

以下實驗均以 cgroup v2 為例!

為了防止部分管理不佳的行程過度佔用系統資源,導致 DoS (Denial of Service) 的發生,Linux 提供了此機制來協助我們管理行程所使用的資源上限:如記憶體、CPU time、I/O 等。cgroups 的全名是 control groups,指的是將不同的行程分組,並規範他們總共能使用多少資源。

要使用這項管理機制,需透過核心掛載的 cgroup2 檔案系統寫入檔案、對不同資源進行限制。利用 mount 命令查看掛載點,可以看到掛載點在 /sys/fs/cgroup 底下:

$ mount | grep cgroup2
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate)

可以把該目錄下的檔案列出看看:

$ ls /sys/fs/cgroup/
cgroup.controllers      cgroup.threads         dev-mqueue.mount  memory.stat
cgroup.max.depth        cpu.pressure           init.scope        sys-fs-fuse-connections.mount
cgroup.max.descendants  cpu.stat               io.pressure       sys-kernel-debug.mount
cgroup.pressure         cpu.stat.local         io.stat           sys-kernel-tracing.mount
cgroup.procs            cpuset.cpus.effective  memory.numa_stat  system.slice
cgroup.stat             cpuset.mems.effective  memory.pressure   user.slice
cgroup.subtree_control  dev-hugepages.mount    memory.reclaim

也太多檔案了吧!先看 cgroup.procs,先前提及 cgroups 會將行程分組進行管理,而 cgroup.procs 就需要宣告有哪些行程 (PID) 屬於這個組別。

而組別的管理是「階層式」的,也就是組別之中可以包含其他組別。在 cgroups(7) 裡頭有提到:

The cgroups for a controller are arranged in a hierarchy. This hierarchy is defined by creating, removing, and renaming subdirectories within the cgroup filesystem.

如上所述,要在「群組裡面建立群組」的話,只需要在掛載的檔案系統中,直接建立新目錄,檔案系統會自己為管理需要建立所須的檔案。可以做實驗來觀察一下,若在現存的 /sys/fs/cgroup/ 直接建立新目錄:

$ sudo mkdir test-my-cgroup
$ ls test-my-cgroup/
cgroup.controllers      cgroup.procs            cpu.stat.local       memory.max        memory.stat          pids.events
cgroup.events           cgroup.stat             io.pressure          memory.min        memory.swap.current  pids.max
cgroup.freeze           cgroup.subtree_control  memory.current       memory.numa_stat  memory.swap.events   pids.peak
cgroup.kill             cgroup.threads          memory.events        memory.oom.group  memory.swap.high
cgroup.max.depth        cgroup.type             memory.events.local  memory.peak       memory.swap.max
cgroup.max.descendants  cpu.pressure            memory.high          memory.pressure   memory.swap.peak
cgroup.pressure         cpu.stat                memory.low           memory.reclaim    pids.current

由上可見,子代群組的相關檔案都被自動建立好了。

而階層關係中,子代群組能使用的資源也會受到親代群組的影響,參考 man page 所述:

At each level of the hierarchy, attributes (e.g., limits) can be defined. The limits, control, and accounting provided by cgroups generally have effect throughout the subhierarchy underneath the cgroup where the attributes are defined. Thus, for example, the limits placed on a cgroup at a higher level in the hierarchy cannot be exceeded by descendant cgroups.

舉例來說:

root (cpu.max = 2 CPU)
|─ parent (cpu.max = 1 CPU)
   |─ child (cpu.max = 4 CPU)

雖然 child 寫入 cpu.max 時能成功,但 child 實際能使用的只有 1 CPU。

此外,並非所有的 controller 都會被預設啟用,像是 cpu 選項就會預設關閉;如果你擁有一雙火眼金睛,應該會發現先前建立 test-my-cgroup 時,cpu.max 檔案沒有被自動建立。此時需要手動在親代組別的 cgroup.subtree_control 中指定使用 +cpu,就能正常運作。

$ ls test-my-cgroup | grep cpu
cpu.pressure
cpu.stat
cpu.stat.local

$ sudo sh -c 'echo +cpu > cgroup.subtree_control'

$ ls test-my-cgroup | grep cpu
cpu.idle
cpu.max
cpu.max.burst
cpu.pressure
cpu.stat
cpu.stat.local
cpu.weight
cpu.weight.nice

cgroup v1 v.s. cgroup v2

cgroup 其實還有比較舊的 v1 版本。先前提過的 v2 版本中,/sys/fs/cgroup/ 下所對應的每個目錄都對應到一個群組,而目錄內的檔案可以控制群組的所有資源(memory, cpu, I/O)。結構看起來會像這樣子:

/sys/fs/cgroup/
├── groupA/
│   ├── cgroup.procs
│   ├── cpu.max
│   ├── memory.max
│   |── io.max
|
├── groupB/
│   |── cgroup.procs
│   ├── cpu.max
│   ├── memory.max
│   |── io.max

如果在使用 v2 時去檢視 /proc/self/cgroup,會得到類似以下結果:

0::/groupA

可以發現第二的 column 是空的(兩個冒號中間),這是因為 cgroup v1 原先的設計所影響。v1 把不同 controller 放在不同的 hierarchy 中,因此,同一個 cgroup 的資源管理會四散在不同的目錄下,結構長得像這樣子:

/sys/fs/cgroup/
├── cpu/
│   ├── groupA/
│   │   └── cgroup.procs
│   └── groupB/
│       └── cgroup.procs
├── memory/
│   ├── groupA/
│   │   └── cgroup.procs
│   └── groupB/
│       └── cgroup.procs
├── blkio/
│   ├── groupA/
│   │   └── cgroup.procs
│   └── groupB/
│       └── cgroup.procs

再檢視 /proc/self/cgroup 看看,印出的內容會和使用 v2 時有所不同(隨意舉例):

5:cpu:/groupA
4:memory:/groupA
3:blkio:/groupD

第一欄為 hierarchy ID,第二欄為該 hierarchy 所管理的資源(controllers),最後一欄是當前行程在該 hierarchy 中的路徑;這個路徑是相對於該 hierarchy 的 root。

v1 這種結構的設計原先是為了提供更好的靈活性而生。但實際使用上,發現這樣的靈活性沒什麼用,反而增加了資源管理的複雜度,所以在 v2 中簡化了管理結構。

In cgroups v1, the ability to mount different controllers against different hierarchies was intended to allow great flexibility for application design. In practice, though, the flexibility turned out to be less useful than expected, and in many cases added complexity. Therefore, in cgroups v2, all available controllers are mounted against a single hierarchy. —— cgroups(7) man page

cgroup namespace

其作用為改變行程看待 cgroups 結構的視角,概念上有點像是 chroot,不過只針對 cgroup 作用。

Cgroup namespaces virtualize the view of a process's cgroups as seen via /proc/pid/cgroup and /proc/pid/mountinfo. —— cgroup_namespaces(7) man page

舉例來說,如果有一個行程受到 cgroup 管理,沒有啟用 cgroup namespace 的狀況下,他看到的 /proc/self/cgroup 會是這樣的:

$ cat /proc/self/cgroup
0::/user.slice/user-1000.slice/my-container.scope

如果啟用了 cgroup namespace,行程將看不到 host 的 cgroup 分組結構:

$ unshare -C -- bash
$ cat /proc/self/cgroup
0::/

這樣就算出現 container in container 的應用情境,每個 container 都仍能乾淨的管理自己那層的 cgroup 路徑。

Linux containers in 500 lines of codecode 閱讀

sudo ./contained -m ~/misc/busybox-img/ -u 0 -c /bin/sh

-m: 當作 rootfs 的目錄
-u: user namespace 中的 UID
-c: 要執行的指令及參數
注意:-c 後面的全部會被當成要執行的指令和參數

getopt()

https://blog.csdn.net/Mculover666/article/details/106646339

namespace: child process clone

int flags = CLONE_NEWNS
    | CLONE_NEWCGROUP
    | CLONE_NEWPID
    | CLONE_NEWIPC
    | CLONE_NEWNET
    | CLONE_NEWUTS;

這邊在 clone 時除了 time 和 user namespace,其他都用了新的,為 container 隔離環境打下基礎。user namespace 會在 child 建立後嘗試建立。

為甚麼漏了 time?

handle_child_uid_map(), userns()

child 被 clone 後,會檢查自身是否具有建立 user namespace 的能力,並用 socket 將結果回傳 parent;parent process 在 clone child 後就持續監聽,若 child process 建立 user namespace 成功,則需要做 UID 和 GID mapping。
將 host 的 UID 對應到 child 的 UID,USERNS_OFFSET = 10000 表示 child 的 UID 0 對應到 host 的 UID 10000,USERNS_COUNT 則是可用的 PID 總數。
這一部相當重要,對於一個新建立的 user namespace,若沒有進行 mapping,他基本上沒辦法做任何事情,因為權限檢查全都會失敗。要經過 mapping 後,才可以 setgid, setuid。

capabilities()

丟掉比較危險的 capability,透過清空 bounding sets 和 inheritable set,而存在於 ambient 的元素必須同時存在於 inheritable 和 permitted set 中,所以清除 inheritable 的動作會直接清掉 ambient。這樣可以有效清除所有不想要傳遞下去的權限。

mounts()

本函式會在 child process 中執行,此時已經在使用新的 mount namespace。為了在新的 namespace 中換 root 到 config->mount_dir,原作者將對應目錄掛載到一個 tmp 下,並使用隨機產生 tmp 目錄名,確保每個啟動的 container 會得到不同的臨時掛載路徑,順便準備一個給 old root 放置的地方(給 pivot_root() 使用),最後丟掉 old root 的掛載,以防存取到 new root 以上的檔案。

發現的錯誤

  • Resources 章節中:Set memory/$hostname/memory.kmem.limit_in_bytes, so that the contained process and its child processes can't total more than 1GB memory in userspace. -> kernel space

待確認細節

  • 資源限制 (cpu, memory) 應該如何制定?
  • 期望設計為連啟用容器的親代行程也能 rootless,目前需要 500 lines of container 和 barco 需要 root 的原因是,其使用了 cgroup v1,所以在寫入相關管理檔案時需要特權。cgroup v2 可以直接在 mount 時指定 nsdelegate,cgroups2 檔案系統會直接將相關管理檔案的 owner 下放給 nonprivileged user。這樣就不需要 root 也可以寫入 cgroup。
  • cgroup v2 建議核心版本 5.2 以上使用(參閱 rootless container Enabling cgroup v2
  • 另一個親代行程需要特權的地方是建立 user namespace 時需要寫入的 uid/pid map,根據官方文件(user_namespace(7)):
    ​​​​•  One of the following two cases applies:
    
    ​​​​  (a)  Either the writing process has the CAP_SETUID (CAP_SETGID)
    ​​​​       capability in the parent user namespace.
    
    ​​​​       •  No further restrictions apply: the process can make
    ​​​​          mappings to arbitrary user IDs (group IDs) in the
    ​​​​          parent user namespace.
    
    ​​​​  (b)  Or otherwise all of the following restrictions apply:
    
    ​​​​       •  The data written to uid_map (gid_map) must consist of a
    ​​​​          single line that maps the writing process's effective
    ​​​​          user ID (group ID) in the parent user namespace to a
    ​​​​          user ID (group ID) in the user namespace.
    
    ​​​​       •  The writing process must have the same effective user
    ​​​​          ID as the process that created the user namespace.
    
    ​​​​       •  In the case of gid_map, use of the setgroups(2) system
    ​​​​          call must first be denied by writing "deny" to the
    ​​​​          /proc/pid/setgroups file (see below) before writing to
    ​​​​          gid_map.
    
    也就是在沒有 CAP_SETUID 的狀況也可以寫入 uid/gid map,但需要在容器裡禁用 setgroups,這是否會影響到 alpine linux 或 tini 在容器內的運作?

    更新:用 deny setgroups 去規避 CAP_SETUID (CAP_SETGID) 的權限需求這條路有另一個限制,根據上方官方文件的 (b) 第一條,如果沒有 CAP_SETUID (CAP_SETGID),那就只能建立一個長度為 1 的 ID mapping。
    目前使用 newuidmap 的 binary 來使親代行程在沒有 root 權限的狀況下短暫提權,來寫入對應的 id map。

專案程式碼

https://github.com/Andrushika/light-container

其他自我問答

  • socketpair 怎麼用?address family 是什麼?
    address family 包含 AF_INET(IPv4)、AF_INET6(IPv6)、AF_UNIX (local IPC, 沒有網路傳輸,直接在 memory 溝通)、AF_PACKET (原始封包,直接處理 ethernet)、AF_NETLINK (kernel 和 user space 溝通用,linux 特有)
    socket type 則指定使用哪種 protocol (TCP, UDP)
  • uid map, gid map 的作用?
    用來映射 user namespace 到 host 的 uid, gid,使用者在 container 裡面看起來像 root (UID 0),但他在 host 上可能是 UID 10000。
  • child process PID mapping 使用的空間滿了怎麼辦;如果在 user namespace 使用 map 範圍外的 UID, GID,會發生甚麼?
    kernel 會拒絕存取,回傳 EPERM(Permission denied)
  • setresuid 的用法
    設定 real user ID, effective user ID, saved set-user-ID,實際上只有 effective 會被用來判斷,可用來暫時降權限。在降權之前設定 saved set-user-ID,之後就可以順利升回來。

    real user ID 和 effective user ID 為何要分開?