CVE-2021-3493復現

語言: CN / TW / HK

本文為看雪論壇精華文章

看雪論壇作者ID:[email protected]

漏洞成因

該漏洞是通過創建一個虛擬環境,在虛擬環境當中通過某軟件賦予某文件高權限,由於程序檢查不嚴密,該權限逃逸到現實環境中也生效。

前置知識

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 ( http://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。

權限設置

這裏我們來查看對應版本的代碼,鏈接貼上( http://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。

//http://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:[email protected]

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

*本文由看雪論壇 [email protected] 原創,轉載請註明來自看雪社區

#

往期推薦

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

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

3. WhatsApp私信協議實現記錄

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

5. 實戰DLL注入

6. 某車聯網APP加固分析

球分享

球點贊

球在看

點擊“閲讀原文”,瞭解更多!