USER 命名空間為我們最喜歡的工具(例如 docker 和 podman)的功能提供了技術支援。我們早在去年 6 月就撰寫了有關 Linux 命名空間的文章,並像這樣解釋它們:
大多數命名空間毫無爭議,比如 UTS 命名空間,它可讓主機系統隱藏主機名稱和時間。另一些則既複雜又簡單——眾所週知,NET 和 NS (mount) 命名空間就令人很難理解。最後,還有這個非常特殊、非常奇怪的 USER 命名空間。USER 命名空間非常特殊,因為它允許通常無權限的擁有者在其內部以「root」身分進行操作。正是因為有它作為基礎,才會有 Docker 這樣不以真正的 root 身分操作的工具,以及無根容器這樣的物件。
由於其性質允許無權限使用者存取 USER 命名空間,總是會帶來很大的安全風險。在它的幫助下,無權限使用者可以實際執行通常需要 root 的程式碼。這種程式碼通常未經充分測試,且錯誤很多。今天,我們將研究一個這樣的案例,即利用 USER 命名空間惡意探索核心錯誤,從而導致無權限阻斷服務攻擊。
進入 Linux 流量控制佇列原則
2019 年,我們探索了利用 Linux 流量控制的佇列原則 (qdisc),透過 Hierarchy Token Bucket (HTB) classful qdisc 策略為我們的一個服務排定封包。Linux 流量控制是一個使用者設定的系統,用於排定和篩選網路封包。佇列原則是用於排定封包的策略。特別是,我們希望從介面篩選和排定某些封包,並將其他封包放入 noqueue qdisc 中。
noqueue 是一種特殊情況的 qdisc,排定到其中的封包應捨棄。在實踐中,情況並非如此。Linux 會處理 noqueue,讓封包通過且不被捨棄(大多數情況下)。文檔也是這樣說明的。它還指出「無法將 noqueue 佇列原則指派給實體裝置或類別。」那麼,當我們將 noqueue 指派給一個類別時會發生什麼呢?
我們來撰寫一些殼層命令,實際展示一下這個問題:
首先,我們需要以 root 身分登入(它會為我們提供 CAP_NET_ADMIN),以便能夠設定流量控制。
然後,我們將網路介面指派給一個變數。您可使用
ip a
找到它們。虛擬介面可以透過呼叫ls /sys/devices/virtual/net
找到。這些將與ip a
的輸出相符。我們的介面目前已指派給 pfifo_fast qdisc,因此我們將它取代為 HTB classful qdisc,並為其指派控制代碼
1:
。我們可以將其視為樹狀結構中的根節點。「預設值 1」會對此進行設定,以便直接透過此 qdisc 路由傳送未分類的流量,而該 qdisc 會回復到 pfifo_fast 佇列。(稍後會對此進行詳細介紹)下面我們將一個類別添加到 root qdisc
1:
,將其指派給根 1:1:1
的第一個分葉節點 1,並為它提供一些合理的設定預設值。最後,我們將 noqueue qdisc 添加到我們在階層中的第一個分葉節點:
1:1
。這實際上意味著路由到這裡的流量將被排定至 noqueue
1. $ sudo -i
2. # dev=enp0s5
3. # tc qdisc replace dev $dev root handle 1: htb default 1
4. # tc class add dev $dev parent 1: classid 1:1 htb rate 10mbit
5. # tc qdisc add dev $dev parent 1:1 handle 10: noqueue
假設我們的設定順利執行,我們將收到類似於此核心恐慌的內容:
我們知道 root 使用者負責在介面上設定 qdisc,那麼,如果 root 會損毀核心,那又如何?我們只是不將 noqueue qdisc 套用至 HTB qdisc 的類別 id:
BUG: kernel NULL pointer dereference, address: 0000000000000000
#PF: supervisor instruction fetch in kernel mode
...
Call Trace:
<TASK>
htb_enqueue+0x1c8/0x370
dev_qdisc_enqueue+0x15/0x90
__dev_queue_xmit+0x798/0xd00
...
</TASK>
在這裡,我們利用了 HTB 的預設案例,即指派類別 id 1:2 來限速 (A),並隱含地未將 qdisc 設為另一個類別,例如 id 1:1 (B)。排入到 (A) 佇列的封包會被篩選至 HTB_DIRECT,而排入到 (B) 佇列的封包則會被篩選至 pfifo_fast 中。
# dev=enp0s5
# tc qdisc replace dev $dev root handle 1: htb default 1
# tc class add dev $dev parent 1: classid 1:2 htb rate 10mbit // A
// B is missing, so anything not filtered into 1:2 will be pfifio_fast
因為對程式碼庫的這一部分並不熟悉,所以我們通知了郵件清單並建立了票證。當時這個錯誤對我們似乎並不是那麼重要。
快進到 2022 年,我們正在推動 USER 命名空間建立強化。我們使用新的 LSM 勾點 (userns_create) 擴充了 Linux LSM 架構,以利用 eBPF LSM 為我們提供保護,並鼓勵其他人也這樣做。最近,在梳理票證待處理項目時,我們重新考慮了這個錯誤。我們問自己,「我們可以利用 USER 命名空間來觸發錯誤嗎?」簡短回答就是,是的!
錯誤示範
惡意探索可以使用任何假定 struct Qdisc.enqueue 函數不是 NULL 的 classful qdisc 來執行(稍後會對此進行詳細介紹),但在本案例中,我們只是用 HTB 進行示範。
我們使用「lo」界面來示範這個錯誤可以透過虛擬界面觸發。這對於容器而言非常重要,因為在大多數情況下都是為其饋送虛擬介面,而不是實體介面。正因為如此,我們可以使用容器以無權限使用者身分損毀主機,進而執行阻斷服務攻擊。
$ unshare -rU –net
$ dev=lo
$ tc qdisc replace dev $dev root handle 1: htb default 1
$ tc class add dev $dev parent 1: classid 1:1 htb rate 10mbit
$ tc qdisc add dev $dev parent 1:1 handle 10: noqueue
$ ping -I $dev -w 1 -c 1 1.1.1.1
這種做法為什麼會奏效呢?
為了更好地理解這個問題,我們需要回顧一下原來的修補程式系列,但具體而言,是這個引入了錯誤的 commit。在本系列之前,在介面上實現 noqueue 依賴於一種駭客攻擊,如果裝置具有 tx_queue_len = 0,則該駭客攻擊會將裝置 qdisc 設為 noqueue。而 commit d66d6c3152e8 (“net: sched: register noqueue qdisc”) 明確允許使用 tc 命令添加 noqueue 而不需要繞過該限制,從而規避了這種情況。
核心檢查我們是否處於 noqueue 情況下的方法是檢查 qdisc 是否具有 NULL enqueue() 函數。回想一下,noqueue 在實踐中不一定會捨棄封包?在該檢查失敗的情況下,下列邏輯會處理 noqueue 功能。為了讓檢查失敗,作者不得不_欺騙_從 noop_enqueue () 到 NULL 的重新指派,方法是在初始化中使 enqueue = NULL 並在執行階段中呼叫(遠離前面的 register_qdisc())。
這就是 classful qdiscs 發揮作用的地方了。對加入佇列函數的檢查不再是 NULL。在此呼叫路徑中,現在將其設為 HTB(在我們的範例中),因此允許將 struct skb 加入佇列,方法是呼叫函數 htb_enqueue()。進入那裡後,HTB 會執行查詢來提取指派給分葉節點的 qdisc,最後嘗試將 struct skb 排入到所選的 qdisc,該 qdisc 最終會到達此函數:
include/net/sch_generic.h
我們可以看到,加入佇列流程與實體/虛擬介面完全無關。將佇列加入介面時,會執行權限和驗證檢查,這就是 classful qdics 假定佇列不是 NULL 的原因。瞭解這些資訊後,我們有幾種解決方案可以考慮。
static inline int qdisc_enqueue(struct sk_buff *skb, struct Qdisc *sch,
struct sk_buff **to_free)
{
qdisc_calculate_pkt_len(skb, sch);
return sch->enqueue(skb, sch, to_free); // sch->enqueue == NULL
}
解決方案
我們有幾種解決方案,從我們認為最好的到最差的都有:
遵循 tc-noqueue 文件,不允許將 noqueue 指派給 classful qdisc
不檢查 NULL,而是檢查 struct noqueue_qdisc_ops,並將 noqueue 重設為 noop_enqueue
對於每個 classful qdisc,檢查 NULL 和後援
雖然我們最終選擇了第一個選項:「不允許 qdisc 類別為 noqueue」,但第三個選項在程式碼中建立了大量變換,並且不能完全解決問題。未來的 qdiscs 實作可能會忘記這項重要的檢查以及維護人員。但是,放棄第二個選項的原因似乎更有趣。
我們未遵循該方法的原因是,我們需要先回答以下問題:
為什麼允許 classful qdiscs 為 noqueue?
這與文件互相矛盾。該文件確實包含一些在實踐中沒有完全遵循的先例,但我們需要對其進行更新來反映目前的狀態。這樣做是很好,但除了移除 NULL 取值錯誤,並沒有解決行為變更問題。
如果我們允許 qdiscs 為 noqueue,會發生什麼行為變更?
這很難回答,因為我們需要確定該行為應該是什麼。目前,當針對介面套用 noqueue 作為 root qdisc 時,採取的做法就是基本上允許處理封包。宣告類別的後援則是另外一個問題。它們可能都有各自的後援規則,那我們如何知道什麼是正確的後援呢?有時在 HTB 中,後援是使用 HTB_DIRECT 通過,有時是 pfifo_fast。那其他類別呢?也許相反,我們應該回復到預設的 noqueue 行為,因為它是用於 root qdiscs 的?
我們認為,走這條路只會讓佇列變得更混亂、更複雜。我們還可以提出一個論點:將此類變更視為功能新增,而不一定是錯誤修正。無需贅言,現在遵循目前的文件來防止漏洞似乎是更吸引人的方法,而其他問題可以稍後解決。
重點
首先,盡快套用此修補程式。然後考慮透過下列設定強化系統上的 USER 命名空間:設定 sysctl -w
kernel.unprivileged_userns_clone
=0
(該設定僅允許 root在 Debian 核心中建立 USER 命名空間)、sysctl -w
user.max_user_namespaces
=[number]
(針對流程階層),也可以考慮將這兩個修補程式向後移植:[security_create_user_ns()](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=7cd4c5c2101cb092db00f61f69d24380cf7a0ee8)
和 SELinux 實作(現在位於 Linux 6.1.x 中),讓您可以使用 eBPF 或 SELinux 來保護系統。如果您確定不使用 USER 命名空間,在極端情況下,則可以考慮使用 CONFIG_USERNS=n
關閉該功能。這只是利用命名空間來執行攻擊的一個例子,將來肯定會出現更多不同嚴重程度的此類攻擊。
特別感謝 Ignat Korchagin 和 Jakub Sitnicki 幫忙檢閱程式碼,並協助在實踐中示範錯誤。