CVE-2021-3493復現

語言: CN / TW / HK

本文為看雪論壇精華文章

看雪論壇作者ID:xi@0ji233

漏洞成因

該漏洞是通過建立一個虛擬環境,在虛擬環境當中通過某軟體賦予某檔案高許可權,由於程式檢查不嚴密,該許可權逃逸到現實環境中也生效。

前置知識

overlayfs :虛擬的,堆疊檔案系統

capability:許可權管理機制

namespace:一種名稱空間

overlayfs

能把多個資料夾裡的檔案合併為到同一個資料夾當中,這麼聽起來這個檔案系統好像挺雞肋的,但是它支援了一個我們最喜歡用的軟體:docker。docker裡面分容器和映象的概念,一個映象可以派生出多個容器,跟虛擬機器差不多,一個映象可以建立多個虛擬機器。容器分公有資料和私有資料,docker比虛擬機器優勢的一點就是docker中的公有資料所有容器共享,這樣就能省磁碟空間,私有資料則可以各個容器獨佔,保證資料獨立。docker的實現機制就是通過 overlayfs 檔案系統實現的。

overlayfs 依賴並建立在其它的檔案系統之上(例如ext4fs和xfs等等),並不直接參與磁碟空間結構的劃分,僅僅將原來底層檔案系統中不同的目錄進行“合併”,然後向用戶呈現。

其中 lower dirA / lower dirB目錄和upper dir目錄為來自底層檔案系統的不同目錄,使用者可以自行指定,內部包含了使用者想要合併的檔案和目錄,merge dir目錄為掛載點。當檔案系統掛載後,在merge目錄下將會同時看到來自各lower和upper目錄下的內容,並且使用者也無法(無需)感知這些檔案分別哪些來自lower dir,哪些來自upper dir,使用者看見的只是一個普通的檔案系統根目錄而已(lower dir可以有多個也可以只有一個)。

overlayfs掛載

掛載一個overlay檔案系統,可以通過mount -t overlay -o <options> overlay <mount point>來實現。

<mount point>是最終overlay的掛載點。

其中overlay的options有如下:

lower dir=<dir>:指定使用者需要掛載的lower層目錄,lower層支援多個目錄,用“:”間隔,優先順序依次降低。最多支援500層。

upper dir=<dir>:指定使用者需要掛載的upper層目錄,upper層優先順序高於所有的lower層目錄。

work dir=<dir>:指定檔案系統掛載後用於存放臨時和間接檔案的工作基礎目錄。

下面將lower和upper進行overlay,掛載到merge目錄,臨時workdir為work目錄。

$mount -t overlay -o lowerdir=lower,upperdir=upper,workdir=work overlay merge

如下同樣將lower和upper進行overlay到merge,但是merge為只讀屬性。

$mount -t overlay -o lowerdir=upper:lower overlay merge

在使用如上mount進行overlayfs合併之後,遵循如下規則:

1、lower dir和upper dir兩個目錄存在同名檔案時,lower dir的檔案將會被隱藏,使用者只能看到upper dir的檔案。

2、lower dir低優先順序的同目錄同名檔案將會被隱藏。

3、如果存在同名目錄,那麼lower dir和upper dir目錄中的內容將會合並。

4、當用戶修改merge dir中來自upper dir的資料時,資料將直接寫入upper dir中原來目錄中,刪除檔案也同理。

5、當用戶修改merge dir中來自lower dir的資料時,lower dir中內容均不會發生任何改變。因為lower dir是隻讀的,使用者想修改來自lower dir資料時,overlayfs會首先拷貝一份lower dir中檔案副本到upper dir中。後續修改或刪除將會在upper dir下的副本中進行,lower dir中原檔案將會被隱藏。

docker如何使用overlayfs

在docker當中,我們為了方便理解,假設只有三個目錄:upper dir,lower dir和merge dir。我們的映象處於lower dir當中,初始情況下,我們通過映象創建出來一個容器,lower dir 中就是一個映象,upper dir 中為空,我們建立多個容器得到的都是和映象一模一樣的系統。

當我嘗試檢視容器中的某個檔案,根據規則1,因為 upper dir 為空,我們看的的內容是 lower dir 中的內容,也就是映象的內容;當我嘗試修改容器中的檔案內容時,根據規則5,lower dir 中的內容只讀,因此拷貝一份到 upper dir 中,根據規則1,我們之後將只能看到該檔案 upper dir 中的內容,修改完成會將結果儲存在 upper dir 當中,之後再次修改這個檔案,將只在upper dir 當中進行。但是在我們的視角當中,我們跟操作一個完整的作業系統並沒有很大的區別。並且多個容器大部分資料是共享的,因此比較節省磁碟空間。

demo

我們新建四個資料夾:upper,lower,work 和 merge。

$mount -t overlay overlay -o lowerdir=./lower,upperdir=./upper,workdir=./work ./merge    

mount 命令用於掛載操作,第一個 overlay 指定掛載型別為 overlay 第二個 overlay 指定掛載點,-o 選項指定上層目錄,下層目錄,工作目錄,最後掛載到 merge 目錄下。

掛載完成之後我們在 lower 和 upper 中分別建立 1.txt 和 2.txt。我們使用 ls -lR 來檢視目錄。

我們可以發現, merge 目錄中也出現了 1.txt 和 2.txt。

我們修改 upper 和 lower 中檔案對應的內容,可以發現,merge 目錄中也會有相同的改變,這非常符合 overlayfs 的規則。

我們嘗試直接在 merge 目錄中修改在 upper 目錄中出現的檔案再觀察一下變化。

可以發現我們在 merge 目錄中修改 upper 目錄中出現的檔案,對應也修改了 upper 目錄的主體檔案。

我們嘗試在 merge 目錄中修改只在 lower 目錄出現的檔案再觀察一下變化。

我們發現,lower 目錄中對應的 1.txt 並沒有發生改變,反而是 upper 目錄多了一個 1.txt 檔案,並且內容與我們填寫的一致。

那麼這個 1.txt 就可以理解為docker中的映象,2.txt 就是我容器中不同於映象的檔案。

capability

首先介紹幾個概念:uid,ruid,euid,suid。

uid(ruid)

標識使用者身份, 比如常見的 root就是0,我們安裝完作業系統獲得的第一個賬號就是1000,當登入完成之後,這個使用者的ruid就是確定的了。

euid

euid是使用者的有效id,用於系統決定對系統資源的訪問許可權,通常情況下,euid=ruid。我們都知道:只有程序的建立者和root使用者才有權利對該程序進行操作(kill,或者掛起,又或者是 fork)。於是,記錄一個程序的建立者(也就是屬主)就顯得非常必要,程序的 uid 通常就是程序建立者的 uid,若建立者為另一個程序(fork),那麼這個程序的 uid 會被繼承,除非子程序被設定了 suid。

suid

用於對外許可權的開放。跟ruid及euid是用一個使用者繫結不同,它是跟檔案而不是跟使用者繫結,在執行這個檔案時,使用者會暫時獲得屬主的身份。

引入

程序執行之後,會獲得和執行者一樣的許可權,它們同樣受到了自身的許可權訪問控制。事實上這樣的管理是比較安全的,我如果想自己無法直接訪問這個檔案,那麼我通過建立程序訪問檔案同樣會沒有許可權。但是如果這樣管理則不能滿足一些需要,比如密碼檔案 /etc/shadow,這個檔案的許可權是 r--------,屬主和陣列均為 root,那就意味著,除了 root 使用者沒有人可以檢視或者修改這個檔案,但是裡面同時也存了我自己的密碼,如果我不管怎樣都獲得不了 root 許可權,那意味著我自己都修改不了我自己的密碼,那這顯然不太合理。

於是乎就出現了 suid(Set User ID execution),我們都知道在 linux 當中,我們想修改自己的密碼是使用 passwd 命令,那我們檢視 passwd 的許可權發現它被設定了 suid 選項。它允許我在執行這個程式的時候短暫地獲得 root 許可權,這個程序擁有 root 許可權之後,我們就能修改 /etc/shadow 檔案,修改完成之後,程序直接退出。

這麼一看確實挺方便了,但是會帶來很大的安全問題:假設, passwd 檔案在編寫的時候,存在漏洞,若在執行 passwd 的過程中,能通過漏洞建立一個 shell 程序,那麼這個 shell 程序也會是 root 許可權,簡而言之,SUID 機制增大了系統的安全攻擊面。

為了對 root 許可權進行更細粒度的控制,實現按需授權,Linux 引入了另一種機制叫 capability。

capability是什麼

Capabilities 機制是在 Linux 核心 2.2 之後引入的一個許可權管理機制,原理就是把超級使用者 root(uid=0) 的特權劃分為不同的功能組,每個功能組都可以獨立啟用和禁用。其本質上就是將核心呼叫分門別類,具有相似功能的核心呼叫被分到同一組中。

這樣一來,我許可權檢查就變成了:如果非 root 使用者,那麼檢查程序是否有對應的操作許可權,決定是否可以進行該操作。同樣,這個許可權可以在執行的時候賦予:根據程序建立者或者 setuid 獲得,也可以從父程序繼承。假如我給 nginx 可執行檔案賦予了 CAP_NET_BIND_SERVICE capabilities ,那麼它就能以普通使用者的身份執行並監聽一個1024以內的埠。

程序的capability

每一個程序,具有 5 個 capabilities 集合,每一個集合使用 64 位掩碼來表示,顯示為 16 進位制格式。這 5 個 capabilities 集合分別是:

  • Permitted

  • Effective

  • Inheritable

  • Bounding

  • Ambient

這5個集合的具體含義如下:

Permitted

在程序執行時,該可執行檔案的 Permitted 集合中的 capabilites 自動被加入到程序的 Permitted 集合中。程序可以通過系統呼叫 capset() 來從 Effective 或 Inheritable 集合中新增或刪除 capability,前提是新增或刪除的 capability 必須包含在 Permitted 集合中。

Effective

核心檢查執行緒是否可以進行特權操作時,檢查的物件便是 Effective 集合。如之前所說,Permitted 集合定義了上限,執行緒可以刪除 Effective 集合中的某 capability,隨後在需要時,再從 Permitted 集合中恢復該 capability,以此達到臨時禁用 capability 的功能。

比如我可能一個程式可能中間需要使用者來操作,但是呢,我不希望它有過高的許可權,那麼我在交給使用者操作的時候,我把一些許可權較高的capability 禁用了,如果使用者通過漏洞獲取持久許可權那將也不能夠獲取較高的許可權。

Inheritable

當執行exec() 系統呼叫時,能夠被新的可執行檔案繼承的 capabilities,被包含在 Inheritable 集合中。這裡需要說明一下,包含在該集合中的 capabilities 並不會自動繼承給新的可執行檔案,即不會新增到子程序的 Effective 集合或 Inheritable,它只會影響新執行緒的 Permitted 集合。

Bounding

Bounding 集合,它定義了能被繼承的許可權的上限,是 Inheritable 集合的超集,如果某個 capability 不在 Bounding 集合中,即使它在 Permitted 集合中,該執行緒也不能將該 capability 新增到它的 Inheritable 集合中。

Bounding 集合的 capabilities 在執行 fork() 系統呼叫時會傳遞給子程序的 Bounding 集合,並且在執行 execve 系統呼叫後保持不變。

  • 當執行緒執行時,不能向 Bounding 集合中新增 capabilities。

  • 一旦某個 capability 被從 Bounding 集合中刪除,便不能再添加回來。

  • 將某個 capability 從 Bounding 集合中刪除後,如果之前 Inherited 集合包含該 capability,將繼續保留。但如果後續從 Inheritable 集合中刪除了該 capability,便不能再添加回來。

Ambient

Linux 4.3 核心新增了一個 capabilities 集合叫 Ambient ,用來彌補 Inheritable 的不足。Ambient 具有如下特性:

  • Permitted 和 Inheritable 未設定的 capabilities,Ambient 也不能設定。

  • 當 Permitted 和 Inheritable 關閉某許可權後,Ambient 也隨之關閉對應許可權。這樣就確保了降低許可權後子程序也會降低許可權。

  • 非特權使用者如果在 Permitted 集合中有一個 capability,那麼可以新增到 Ambient 集合中,這樣它的子程序便可以在 Ambient、Permitted 和 Effective 集合中獲取這個 capability。

檔案的capability

檔案的 capabilities 被儲存在檔案的擴充套件屬性中。如果想修改這些屬性,需要具有 CAP_SETFCAP 的 capability。檔案與程序的 capabilities 共同決定了通過 execve() 執行該檔案後的執行緒的 capabilities。

檔案的 capabilities 功能,需要檔案系統的支援。如果檔案系統使用了 nouuid 選項進行掛載,那麼檔案的 capabilities 將會被忽略。

類似於程序的 capabilities,檔案的 capabilities 包含了 3 個集合:

  • Permitted

  • Inheritable

  • Effective

這3個集合的具體含義如下:

Permitted

這個集合中包含的 capabilities,在檔案被執行時,會與程序的 Bounding 集合計算交集,然後新增到該程序的 Permitted 集合中。

Inheritable

這個集合與執行緒的 Inheritable 集合的交集,會被新增到執行完 execve() 後的執行緒的 Permitted 集合中。

Effective

這不是一個集合,僅僅是一個標誌位。如果設定開啟,那麼在執行完 execve() 後,執行緒 Permitted 集合中的 capabilities 會自動新增到它的 Effective 集合中。對於一些舊的可執行檔案,由於其不會呼叫 capabilities 相關函式設定自身的 Effective 集合,所以可以將可執行檔案的 Effective bit 開啟,從而可以將 Permitted 集合中的 capabilities 自動新增到 Effective 集合中。

常見的capability

共40個

比如我們熟知的 ping 命令,它所用到的底層是使用 socket 實現的,而 socket 是 root 使用者才有許可權使用的。在 Ubuntu 18.04LTS 的發行版當中,我們看看它是怎麼解決這個許可權問題的。

它設定了 s 許可權位,意味著我執行 ping 的時候, ping 這個 process uid 為 0,也就是 root 使用者。

若我取消設定它的 s許可權位,它將不再具有 ping 的功能。

原因就如上所示,底層的 socket 並不允許普通使用者執行。

而當我把自己許可權提升之後又能夠使用 ping 命令了,是因為 root 使用者執行讀寫和某些底層操作時不檢查許可權。

在這裡我們只需要使用 setcap 命令將 ping 加上 socket 許可權就可以讓我們執行的時候獲得 socket 許可權,正常使用 ping 命令,這麼做的好處就是假如我的 ping 命令有漏洞存在,那麼當別人藉著 ping 命令來提權我的計算機時會發現它獲得的 shell 只擁有 socket 這麼一個特權操作,其它的操作與普通使用者並沒有區別,這樣極大地降低了安全風險,而如果我使用 s 許可權位,那麼別人通過這個獲取漏洞之後將能直接獲得 root 許可權能操作計算機的一切資源。

在新增完許可權之後,我們發現又可以使用 ping 命令了,這是因為我們通過 setcap 讓 /bin/ping 重新擁有了 socket 許可權。

在這個地方我們對 capability 也不再深入下去了。

namespace

引用一下 wiki 對 namespace 的定義:

Namespaces are a feature of the Linux kernel that partitions kernel resources such that one set of processes sees one set of resources while another set of processes sees a different set of resources. The feature works by having the same namespace for a set of resources and processes, but those namespaces refer to distinct resources.

直觀翻譯就是:

namespace 是 Linux 核心的一項特性,它可以對核心資源進行分割槽,使得一組程序可以看到一組資源;而另一組程序可以看到另一組不同的資源。該功能的原理是為一組資源和程序使用相同的 namespace,但是這些 namespace 實際上引用的是不同的資源。

簡單來說 namespace 是由 Linux 核心提供的,用於程序間資源隔離的一種技術。將全域性的系統資源包裝在一個抽象裡,讓程序(看起來)擁有獨立的全域性資源例項。同時 Linux 也預設提供了多種 namespace,用於對多種不同資源進行隔離。

Linux 從 2.4 版本加入了 namespace 機制到 3.8 版本實現了 User namespace。

Cgroup namespace 是程序的 cgroups 的虛擬化檢視,通過 /proc/[pid]/cgroup 和 /proc/[pid]/mountinfo 展示。

有了namespace之後,PID,IPC,Network等系統資源不再是全域性性的,而是屬於特定的Namespace。每個Namespace裡面的資源對其他Namespace都是透明的。要建立新的Namespace,只需要在呼叫clone時指定相應的flag。

以上為自己蒐集的資料整理,以下為自己個人解讀。

電腦開機的時候,系統會建立7個 init 的 namespace,一個程序只能切必須屬於七個特定不同的 namespace,那麼這個就是我們預設的 namespace。使用 ls -l /proc/$$/ns 可以檢視本程序的 namespace 在這裡 $$ 變量表示自己的程序號。

在這之前我一直有一個疑問,就是為什麼我普通使用者 -map-root-user 會導致我沒有 root 的操作許可權而 root 使用者建立的 namespace 即使是普通使用者也有操作許可權。比如如下兩個例子。

unshare 命令用於取消子程序的共享 namespace,通過--user --map-root-user 選項可以新建一個 user namespace 並使新建程序的使用者為 root 使用者。

此時出現了 root 使用者無法操作 /etc/shadow 的場面,但是我們無論是 id 還是 whoami 看上去都跟真的 root 一樣,確沒有操作許可權,確實也是比較奇怪的。但是,又合情合理,因為我普通使用者我不通過 su 或者是 sudo 命令去正常提權那都是利用漏洞。

然後再來看另一個例子:

雖然看起來我是普通使用者,但是實際上我有 root的許可權。

因此我在這裡一直不理解 namespace 的組織形式,直到我看到一篇部落格上面畫著樹狀圖,我才猛然頓悟。

namespace 是樹狀圖的一種形式,然後檔案系統中在標記屬主的時候會標記一個 namespace 欄位,標識由哪一個 namespace 的使用者建立的,然後再檢查許可權的時候若當前使用者不屬於當前 namespace 那麼就會向上尋找,直到找到對應的 namespace,然後檢查是誰建立的。然後對應的許可權就是那個 namespace 的建立者的。如果是這樣的話,那麼就能解釋通了,我之前疑惑的點不在於為什麼我沒有操作許可權而是它怎麼判斷的我沒有操作許可權,因為沒有操作許可權屬於正常現象,如果我的想法不對也請師傅們指正,這只是一個我認為比較合理能解釋得通的解釋。

漏洞利用步驟

我們先建立好 overlayfs 的那幾個資料夾,準備掛載,然後在其中的 upper 目錄中寫上我們的 exp 並編譯好。

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<fcntl.h>
int main(){
setuid(0);
setgid(0);
execve("/bin/bash",0,0);
}

exp 非常簡單,就是 setuid 和 setgid 為 0,也就是 root。

然後我們再建立一個 user namepsace 和 mount namespace 。

在 ./merge 當中,我們為剛剛編譯的 exp 設定 setuid 的許可權。

然後再開一個終端,我們發現 upper 目錄中的 exp 同樣具有了 setuid 的許可權,說明我們的許可權逃逸成功了。

我們執行 exp 成功獲得了真實的 root 許可權。

核心程式碼分析

namespace結構

首先我第一步呢,就是去求證了一下我上面的猜想是否正確,在 github ( https://github.com/torvalds/linux/blob/64222515138e43da1fcf288f0289ef1020427b87/include/linux/user_namespace.h )上找到對應的 namespace 的程式碼,這裡不用管什麼版本了,大體變化是不會很大的,我們先來看 user_namespace 結構體的定義:

我們很清楚地能看到裡面的一個定義:user_namespace *parent,這裡也能說明,它是存在父子關係的,和我們之前的猜測大體是一樣的,並且會標註 o wner 和 group,這裡應該是建立這個 namespace 的屬主和屬組。

我們同時也看到還有一個 level 變數,這裡我大概猜測一下,是 namespace 的深度,也就是往後迭代了多少次,這個學過演算法設計應該還是好理解的,我在建立樹的時候,我們一般也會標記深度方便去查詢,我猜測在這裡我們需要的就是進行許可權檢查,如果 namespace 雙方為父子關係,那麼我們直接看父親的許可權即可,然而實際情況比較複雜,首先誰是父親誰是兒子就很難判斷,所以我跟上深度能很容易知道誰是父親誰是兒子,如果不是父子關係,那麼我們可以查 LCA 找到最近公共祖先,看看兩個 namespace 的建立者許可權如何。

我們找到對應的 user_namespace.c 檔案,看看建立一個 namespace 的時候發生了什麼。這裡推薦給大家讀核心程式碼的一些思路:大部分的程式碼都會寫一個完全不帶安全檢查的函式,比如我建立一個 namespace,那麼我們一定能找到只實現建立 namespace 的一個函式,這個函式通常會在進行了一系列安全檢查之後才允許被呼叫,包括我們平時做一些網站開發之類的也一樣,我們會寫一個定向只做某些事情的介面,但是介面不會直接被呼叫而是會進行一系列安全檢查,諸如非法資料判斷和許可權問題,我們預設傳進去的引數都是合法的,它常規的三部曲就是:檢查,執行,善後。那麼言歸正傳,看到程式碼。

part1

引數應該是一個父程序,因為它在第一行寫了 parent_ns=new->user_ns,parent_ns 我們很容易知道是父 namespace,而這裡傳進去的是一個 cred 結構體,結構體中有一個 user_ns 應該是 user_namespace。下面兩行設定了 euid 和 egid,那麼很清晰了,owner 和 group 就是建立這個 namespace 的屬主和屬組。

下面有一個如果父程序的 user namepsace 層數超過 32 那麼直接 goto fail,那就是說這裡不允許這棵樹建立超過32的深度。

後面執行一個 inc_user_namespaces 函式並判斷是否執行成功,我們往下深挖一下程式碼,這裡因為程式碼比較短,就貼這裡了。

struct ucounts *inc_ucount(struct user_namespace *ns, kuid_t uid,
enum ucount_type type)//in kernel/ucount.c
{
struct ucounts *ucounts, *iter, *bad;
struct user_namespace *tns;
ucounts = alloc_ucounts(ns, uid);
for (iter = ucounts; iter; iter = tns->ucounts) {
long max;
tns = iter->ns;
max = READ_ONCE(tns->ucount_max[type]);
if (!atomic_long_inc_below(&iter->ucount[type], max))
goto fail;
}
return ucounts;
fail:
bad = iter;
for (iter = ucounts; iter != bad; iter = iter->ns->ucounts)
atomic_long_dec(&iter->ucount[type]);

put_ucounts(ucounts);
return NULL;
}
static struct ucounts *inc_user_namespaces(struct user_namespace *ns, kuid_t uid)
{
return inc_ucount(ns, uid, UCOUNT_USER_NAMESPACES);
}

不難看出來,這裡應該只是分配一個 ucounts 結構體的記憶體,我猜測 ucounts 應該是 namespace 的衍生類,因為我們看到 inc_user_namespace 增加 user namespace 實際就是呼叫增加 ucounts 的一個方法,並且估計其它的 namespace 也需要通過這個呼叫來分配記憶體,並且我們觀察列舉類也能發現有我們所有 namespace 的一個定義。

enum ucount_type {
UCOUNT_USER_NAMESPACES,
UCOUNT_PID_NAMESPACES,
UCOUNT_UTS_NAMESPACES,
UCOUNT_IPC_NAMESPACES,
UCOUNT_NET_NAMESPACES,
UCOUNT_MNT_NAMESPACES,
UCOUNT_CGROUP_NAMESPACES,
UCOUNT_TIME_NAMESPACES,
#ifdef CONFIG_INOTIFY_USER
UCOUNT_INOTIFY_INSTANCES,
UCOUNT_INOTIFY_WATCHES,
#endif
#ifdef CONFIG_FANOTIFY
UCOUNT_FANOTIFY_GROUPS,
UCOUNT_FANOTIFY_MARKS,
#endif
UCOUNT_RLIMIT_NPROC,
UCOUNT_RLIMIT_MSGQUEUE,
UCOUNT_RLIMIT_SIGPENDING,
UCOUNT_RLIMIT_MEMLOCK,
UCOUNT_COUNTS,
};//in user_namespace.h

但是去看了 ucounts 結構體的定義發現裡面就定義了一個 user_namespace 的指標和一個連結串列,佇列,以及標識了一個 uid。這個 ucounts 可能只是一個用於做某些標記的東西,我們暫且不管把先。

後面有一個 current_chrooted 函式,我們同樣看看它的定義:

bool current_chrooted(void)
{
/* Does the current process have a non-standard root */
struct path ns_root;
struct path fs_root;
bool chrooted;

/* Find the namespace root */
ns_root.mnt = &current->nsproxy->mnt_ns->root->mnt;
ns_root.dentry = ns_root.mnt->mnt_root;
path_get(&ns_root);
while (d_mountpoint(ns_root.dentry) && follow_down_one(&ns_root))
;

get_fs_root(current->fs, &fs_root);

chrooted = !path_equal(&fs_root, &ns_root);

path_put(&fs_root);
path_put(&ns_root);

return chrooted;
}

根據註釋以及關鍵的語句 chrooted = !path_equal(&fs_root, &ns_root); 我們大概也能猜測出來,它應該就是判斷 namespace 的根目錄是否於檔案系統一致,一致才允許你建立這個 namespace。

part2

然後在這裡需要判斷一下屬主和陣列是否映對映到了父 namespace 上。

後面的話基本上和我們復現的漏洞無關了,我們這麼來了解了一下構成形式,namespace 確實是樹狀圖形式,而且下面我們很清楚地能看到 ns->level=parent_ns->level+1。

許可權設定

這裡我們來檢視對應版本的程式碼,連結貼上( https://elixir.bootlin.com/linux/v4.14.291/source/fs/xattr.c )。

先來看到 416 行,對 setxattr 函式進行分析,這裡解釋一下 setxattr 的一個名字由來(自己意淫的,非官方說法,經供參考),set 就是設定, x 其實它可以代表 extended 擴充套件的,attr 就是屬性了,連起來就是設定擴充套件屬性,這裡的擴充套件屬性就是指 capability。其實我感覺吧, x 好像能表示一切 ex 開頭的單詞,比如我們經常見到的三個許可權位,用 x 標識 execute。

/*
* Extended attribute SET operations
*/
static long
setxattr(struct dentry *d, const char __user *name, const void __user *value,
size_t size, int flags)
{
int error;
void *kvalue = NULL;
char kname[XATTR_NAME_MAX + 1];

if (flags & ~(XATTR_CREATE|XATTR_REPLACE))
return -EINVAL;

error = strncpy_from_user(kname, name, sizeof(kname));
if (error == 0 || error == sizeof(kname))
error = -ERANGE;
if (error < 0)
return error;

if (size) {
if (size > XATTR_SIZE_MAX)
return -E2BIG;
kvalue = kvmalloc(size, GFP_KERNEL);
if (!kvalue)
return -ENOMEM;
if (copy_from_user(kvalue, value, size)) {
error = -EFAULT;
goto out;
}
if ((strcmp(kname, XATTR_NAME_POSIX_ACL_ACCESS) == 0) ||
(strcmp(kname, XATTR_NAME_POSIX_ACL_DEFAULT) == 0))
posix_acl_fix_xattr_from_user(kvalue, size);
else if (strcmp(kname, XATTR_NAME_CAPS) == 0) {
error = cap_convert_nscap(d, &kvalue, size);
if (error < 0)
goto out;
size = error;
}
}

error = vfs_setxattr(d, kname, kvalue, size, flags);
out:
kvfree(kvalue);

return error;
}

乍一看邏輯有點小複雜,主要是很多的巨集定義和很多沒見過的函式,也不太能夠望文生義,於是我找到了Linux手冊對於 setxattr 的說明:

$man 2 setxattr

setxattr() sets the value of the extended attribute identified by name and associated with the given path in the filesystem. The size argument specifies the size (in bytes) of value; a zero-length value is permitted.

貌似介紹的也比較籠統,還是靠自己試試吧。

part1

flags & ~(XATTR_CREATE|XATTR_REPLACE),這其實是很常見的掩碼寫法,差不多意思就是 flag 標誌只在 create 和 replace 位上設定,如果設定了其它位則退出。

strncpy_from_user(kname, name, sizeof(kname)) 對傳入的 name 引數進行拷貝,拷貝到了 kname 也就是核心棧當中。第一個判斷應該是判斷空字串和防止溢位,因為如果 sizeof(kname) 位元組都被佔滿了那麼這個字串還會跟下面連續的字串相連,造成一些錯誤。

這裡出現了我的知識盲區,這裡也來解釋一下,在核心裡面,看見全大寫字母的變數基本都不是變數,都是巨集定義。而我實在不知道字串常量有直接拼接的做法:

#include<stdio.h>
#define s1 "123"
#define s2 "456"
#define s3 s1 s2
int main(){
puts(s3);
}
/*output:
123456
*/

我還以為是巨集定義的特殊寫法呢,這裡mark一下。

這裡給出這些巨集定義的最終結果:

#define XATTR_NAME_POSIX_ACL_ACCESS "system.posix_acl_access"
#define XATTR_NAME_POSIX_ACL_DEFAULT "system.posix_acl_default"
#define XATTR_NAME_CAPS "security.capability"

那麼第一個 if 我們 duck 不必關心,我們主要關心第二個跟 capability 相關的分支。

我們具體邏輯也不進一步分析了,我們就看看這個函式給的註釋:

This function will then take care to map the inode according to @mnt_userns before checking permissions.

我們也不難看出來,在檢查許可權之前就是會對檔案系統和 user namespace 進行對映,這個函式叫 cap_convert_nscap,那其實就是對 capability 的 userns 進行一個映射了(應該是這個意思。

就是可能,它會在不同的 namespace 上嘛,比如這個資料夾是其中一個 user namespace 建立的,不可能我換一個 namespace 去檢測許可權也是相同的手法,肯定是要把許可權對映一下的,對映到同一個 namespace 上才能進行許可權檢查。

經過一系列檢查之後,走到了 vfs_setxattr,也就是虛擬檔案系統的擴充套件屬性設定。

int
vfs_setxattr(struct dentry *dentry, const char *name, const void *value,
size_t size, int flags)
{
struct inode *inode = dentry->d_inode;
int error;

error = xattr_permission(inode, name, MAY_WRITE);
if (error)
return error;

inode_lock(inode);
error = security_inode_setxattr(dentry, name, value, size, flags);
if (error)
goto out;

error = __vfs_setxattr_noperm(dentry, name, value, size, flags);

out:
inode_unlock(inode);
return error;
}

第一條不深入挖下去了,就是判斷有沒有寫的許可權,然後上鎖,防止發生競爭,然後進行 security_inode_setxattr 函式進行進一步的許可權校驗,最後執行 __vfs_setxattr_noperm 函式,它的字尾 noperm 就是還沒有進行許可權檢查的 __vfs_setxattr 與我們之前說的分析思路是一致的。在這個函式裡面有一個大 if 判斷檔案是否有許可權,最終呼叫一個 __vfs_setxattr 去真實設定 xattr。

因此我們可以發現,在呼叫設定檔案擴充套件屬性時候,會有一系列的檢查,比如你是否是 root,你對檔案操作是否有許可權之類的,因為即使你是 root 也得看看那個檔案系統的許可權是否歸你所有,有可能是其它 user_namespace 的使用者建立的,那麼你有可能也是沒有許可權的,這個地方是不會出現越權行為的。

然後我們看到 overlayfs 的設定檔案擴充套件屬性。

int ovl_xattr_set(struct dentry *dentry, struct inode *inode, const char *name,
const void *value, size_t size, int flags)
{
int err;
struct dentry *upperdentry = ovl_i_dentry_upper(inode);
struct dentry *realdentry = upperdentry ?: ovl_dentry_lower(dentry);
const struct cred *old_cred;

err = ovl_want_write(dentry);
if (err)
goto out;

if (!value && !upperdentry) {
err = vfs_getxattr(realdentry, name, NULL, 0);
if (err < 0)
goto out_drop_write;
}

if (!upperdentry) {
err = ovl_copy_up(dentry);
if (err)
goto out_drop_write;

realdentry = ovl_dentry_upper(dentry);
}

old_cred = ovl_override_creds(dentry->d_sb);
if (value)
err = vfs_setxattr(realdentry, name, value, size, flags);
else {
WARN_ON(flags != XATTR_REPLACE);
err = vfs_removexattr(realdentry, name);
}
revert_creds(old_cred);

out_drop_write:
ovl_drop_write(dentry);
out:
return err;
}///fs/overlayfs/inode.c

其它的我們不看,我們解釋比較容易理解的。

if (!upperdentry) {
err = ovl_copy_up(dentry);
if (err)
goto out_drop_write;

realdentry = ovl_dentry_upper(dentry);
}

這個地方其實就是我們說的,如果檔案在 lower 當中,那麼拷貝一份到 upper 當中去,然後把新的檔案節點指向 upper。

if (value)
err = vfs_setxattr(realdentry, name, value, size, flags);
else {
WARN_ON(flags != XATTR_REPLACE);
err = vfs_removexattr(realdentry, name);
}

然後直接呼叫 vfs_setxattr 函數了,我們知道在 vfs_setxattr 之前有一個入口,也就是 setxattr 這個地方會有一個呼叫,呼叫 cap_convert_nscap 函式去檢查 user namespace 是否一致。而這裡直接呼叫 vfs_setxattr 這個函式就繞過了 namespace 的檢查。

所以我們之前的利用步驟就是先建立了一個 namespace,然後掛載了一個 overlayfs,在 merge 資料夾中是我們建立的 fs namespace,因此我們建立的 root 使用者對這個 fs namespace 有設定 capability 的操作許可權,這個其實沒有問題,因為我即使運行了這個 a.out 也不會有真正的 root 許可權,有的只是我們建立的這個 user namespace 的 root 許可權,而這個許可權實際是 init 的 user 建立的,因此實際操作還是獲得不了真實的 root,但是問題就是 overlayfs 的這個特性:我們修改了 merge 中的 a.out 會反向修改之前在 upper 中的 a.out,因此我們給它 setuid 的許可權導致了 upper/a.out 也有 setuid 的許可權,而 upper/a.out 是在實際的 init user namespace 建立的,因此它有了 init user namespace 的 setuid 許可權。我們執行 upper/a.out 直接獲取真實 root 許可權。

修復方案

這個其實我個人認為應該是 overlayfs 的問題,在修改的時候應該檢查 user namespace 才對,但是它修改了 xattr.c 中的 vfs_setxattr 函式,這個函式重新用了一個 cap_convert_nscap 函式檢查 namespace。

//https://elixir.bootlin.com/linux/v5.19.6/source/fs/xattr.c
int
vfs_setxattr(struct user_namespace *mnt_userns, struct dentry *dentry,
const char *name, const void *value, size_t size, int flags)
{
struct inode *inode = dentry->d_inode;
struct inode *delegated_inode = NULL;
const void *orig_value = value;
int error;

if (size && strcmp(name, XATTR_NAME_CAPS) == 0) {
error = cap_convert_nscap(mnt_userns, dentry, &value, size);//這裡是新增的namespace檢查
if (error < 0)
return error;
size = error;
}

retry_deleg:
inode_lock(inode);
error = __vfs_setxattr_locked(mnt_userns, dentry, name, value, size,
flags, &delegated_inode);
inode_unlock(inode);

if (delegated_inode) {
error = break_deleg_wait(&delegated_inode);
if (!error)
goto retry_deleg;
}
if (value != orig_value)
kfree(value);

return error;
}

當然這樣也能完成漏洞的修復,不過我認為在其它檔案系統中這裡檢查了兩次就比較沒有必要,也是比較困惑的點吧,也可能防止其它檔案系統呼叫這個函式也沒有檢查,大概是這樣的。

看雪ID:xi@0ji233

https://bbs.pediy.com/user-home-919002.htm

*本文由看雪論壇 xi@0ji233 原創,轉載請註明來自看雪社群

#

往期推薦

1. 四級分頁下的頁表自對映與基址隨機化原理介紹

2. Android 10屬性系統原理,檢測與定製原始碼反檢測

3. WhatsApp私信協議實現記錄

4. Android4.4和8.0 DexClassLoader載入流程分析之尋找脫殼點

5. 實戰DLL注入

6. 某車聯網APP加固分析

球分享

球點贊

球在看

點選“閱讀原文”,瞭解更多!