Linux Security Module (LSM) 是一個基於勾點的架構,用於在 Linux 核心中實作安全性原則及強制存取控制。直到前不久,期望實作安全性原則的使用者還只有兩個選項:即設定 AppArmor 或 SELinux 等現有 LSM 模組,或編寫自訂核心模組。
Linux 5.7 推出了第三種方法︰LSM 延伸柏克萊封包篩選 (eBPF)(簡稱為 LSM BPF)。使用 LSM BPF,開發人員無需設定或載入核心模組即可編寫精細原則。LSM BPF 程式在載入時進行驗證,然後在呼叫路徑中連線 LSM 勾點時執行。
我們來解決一個實際問題
現代作業系統提供的設施允許對核心資源進行「分割」。例如,FreeBSD 有「jail」,Solaris 有「zone」。Linux 則有所不同,它提供一組看似獨立的設施,每個設施都允許隔離特定的資源。這些設施被稱為「命名空間」,並且已經在核心中不斷發展了很多年。它們是 Docker、lxc 或 firejail 等熱門工具的基礎。許多命名空間毫無爭議,比如 UTS 命名空間,它允許主機系統隱藏其主機名稱和時間。另一些則既複雜又直白——眾所週知,NET 和 NS (mount) 命名空間令人很難理解。最後,還有這個非常特殊、非常奇怪的 USER 命名空間。
USER 命名空間非常特殊,因為它允許擁有者在其內部以「root」身分進行操作。雖然其工作原理不在本部落格貼文所討論的範圍之內,但可以說,正是因為有它作為基礎,才會有 Docker 這樣不以真正的 root 身分操作的工具,以及無根容器這樣的物件。
由於其性質,允許無權限使用者存取 USER 命名空間總是會帶來很大的安全風險。權限提升就是這樣一種風險。
權限提升是常見的作業系統攻擊面。使用者獲得權限的一種方法就是透過 unshare syscall 將其命名空間對應至根命名空間,並指定 CLONE_NEWUSER 旗標。這告訴 unshare 建立一個具有完整權限的新使用者命名空間,並將新使用者及群組 ID 對應至之前的命名空間。您可以使用 unshare(1) 程式將 root 對應至原始命名空間:
在大多數情况下,使用 unshare 並無危害,並且預定以較低的權限執行。但是,此 syscall 已被發現用於提升權限。
$ id
uid=1000(fred) gid=1000(fred) groups=1000(fred) …
$ unshare -rU
# id
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
# cat /proc/self/uid_map
0 1000 1
Syscalls clone 和 clone3 很是值得研究,因為它們還具有 CLONE_NEWUSER 的能力。然而,在這篇貼文中,我們將重點討論 unshare。
Debian 透過這個「add sysctl to disallow unprivileged CLONE_NEWUSER by default」修補程式解決了這個問題,但它不是主流。另一個類似的修補程式「sysctl: allow CLONE_NEWUSER to be disabled」試圖成為主流,但遭到了排擠。一種批評意見是針對特定的應用程式無法切換此功能。在《Controlling access to user namespaces》(控制對使用者命名空間的存取權)這篇文章中,作者寫道「...the current patches do not appear to have an easy path into the mainline(現有修補程式似乎很難成為主流)。」我們可以看到,這些修補程式最終並沒有包含在 vanilla 核心中。
我們的解決方案 — LSM BPF
由於似乎無法選擇使用上游程式碼來限制 USER 命名空間,我們决定使用 LSM BPF 來規避這些問題。使用這種方法,不僅無需修改核心,還可以體現複雜的規則來保護存取。
追蹤適當的候選勾點
首先,我們來追蹤目標 syscall。我們可以在 include/linux/syscalls.h 檔案中找到原型。這在其中並不太容易進行追蹤,但這一行:
為我們提供了接下來在 kernel/fork.c 中的何處進行尋找的線索。在那裡對 ksys_unshare() 進行了呼叫。透過該函式進行挖掘,我們找到了對 unshare_userns() 的呼叫。這看起來很有希望成功。
/* kernel/fork.c */
到目前為止,我們已經確定了 syscall 實作,但下一個問題是我們可以使用哪些勾點?因為我們透過手冊頁知道 unshare 用於變動工作,所以我們在 include/linux/lsm_hooks.h 中查看基於工作的勾點。回到函式 unshare_userns(),我們看到對 prepare_creds() 進行了呼叫。這對 cred_prepare 勾點來說非常熟悉。為了透過 prepare_creds() 驗證我們有自己的配對,我們看到對安全性勾點 security_prepare_creds() 的呼叫,其最終呼叫了勾點:
無需詳細探究細節,我們也知道這是一個很好用的勾點,因為 prepare_creds() 剛好在 create_user_ns() (位於 unshare_userns() 中)前進行了呼叫,後者是我們嘗試封鎖的操作。
…
rc = call_int_hook(cred_prepare, 0, new, old, gfp);
…
LSM BPF 解決方案
我們將使用 eBPF compile once-run everywhere (CO-RE) 方法進行編譯。透過這種方法,我們可以在一個架構上進行編譯,在另一個架構中進行載入。但我們將專門以 x86_64 為目標。適用於 ARM64 的 LSM BPF 仍在開發中,以下程式碼將無法在該架構上執行。關注 BPF 郵寄名單以追蹤進度。
此解決方案在具有以下設定且版本 >= 5.15 的核心上進行了測試:
可能需要開機選項 lsm=bpf
(若是 CONFIG_LSM
在名單中不包含「bpf」的話)。
BPF_EVENTS
BPF_JIT
BPF_JIT_ALWAYS_ON
BPF_LSM
BPF_SYSCALL
BPF_UNPRIV_DEFAULT_OFF
DEBUG_INFO_BTF
DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT
DYNAMIC_FTRACE
FUNCTION_TRACER
HAVE_DYNAMIC_FTRACE
我們從前序編碼開始:
deny_unshare.bpf.c:
接下來,我們透過以下方式為 CO-RE 重新配置建立必要的結構:
#include <linux/bpf.h>
#include <linux/capability.h>
#include <linux/errno.h>
#include <linux/sched.h>
#include <linux/types.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#define X86_64_UNSHARE_SYSCALL 272
#define UNSHARE_SYSCALL X86_64_UNSHARE_SYSCALL
deny_unshare.bpf.c:
我們不需要完全充實結構;我們只需要提供程式運作所需的絕對最少資訊即可。CO-RE 將採取任何必要的動作來為您的核心執行重新配置。因此,編寫 LSM BPF 程式就變得特別簡單!
…
typedef unsigned int gfp_t;
struct pt_regs {
long unsigned int di;
long unsigned int orig_ax;
} __attribute__((preserve_access_index));
typedef struct kernel_cap_struct {
__u32 cap[_LINUX_CAPABILITY_U32S_3];
} __attribute__((preserve_access_index)) kernel_cap_t;
struct cred {
kernel_cap_t cap_effective;
} __attribute__((preserve_access_index));
struct task_struct {
unsigned int flags;
const struct cred *cred;
} __attribute__((preserve_access_index));
char LICENSE[] SEC("license") = "GPL";
…
deny_unshare.bpf.c:
建立程式是第一步,第二步是載入程式並將其連接到所需的勾點。可以採取幾種方法來完成這一步:Cilium ebpf 專案、Rust 繫結以及 ebpf.io 專案橫向頁面上的其他幾個專案。我們將使用原生 libbpf。
SEC("lsm/cred_prepare")
int BPF_PROG(handle_cred_prepare, struct cred *new, const struct cred *old,
gfp_t gfp, int ret)
{
struct pt_regs *regs;
struct task_struct *task;
kernel_cap_t caps;
int syscall;
unsigned long flags;
// If previous hooks already denied, go ahead and deny this one
if (ret) {
return ret;
}
task = bpf_get_current_task_btf();
regs = (struct pt_regs *) bpf_task_pt_regs(task);
// In x86_64 orig_ax has the syscall interrupt stored here
syscall = regs->orig_ax;
caps = task->cred->cap_effective;
// Only process UNSHARE syscall, ignore all others
if (syscall != UNSHARE_SYSCALL) {
return 0;
}
// PT_REGS_PARM1_CORE pulls the first parameter passed into the unshare syscall
flags = PT_REGS_PARM1_CORE(regs);
// Ignore any unshare that does not have CLONE_NEWUSER
if (!(flags & CLONE_NEWUSER)) {
return 0;
}
// Allow tasks with CAP_SYS_ADMIN to unshare (already root)
if (caps.cap[CAP_TO_INDEX(CAP_SYS_ADMIN)] & CAP_TO_MASK(CAP_SYS_ADMIN)) {
return 0;
}
return -EPERM;
}
deny_unshare.c:
最後,我們使用下列 Makefile 進行編譯:
#include <bpf/libbpf.h>
#include <unistd.h>
#include "deny_unshare.skel.h"
static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args)
{
return vfprintf(stderr, format, args);
}
int main(int argc, char *argv[])
{
struct deny_unshare_bpf *skel;
int err;
libbpf_set_strict_mode(LIBBPF_STRICT_ALL);
libbpf_set_print(libbpf_print_fn);
// Loads and verifies the BPF program
skel = deny_unshare_bpf__open_and_load();
if (!skel) {
fprintf(stderr, "failed to load and verify BPF skeleton\n");
goto cleanup;
}
// Attaches the loaded BPF program to the LSM hook
err = deny_unshare_bpf__attach(skel);
if (err) {
fprintf(stderr, "failed to attach BPF skeleton\n");
goto cleanup;
}
printf("LSM loaded! ctrl+c to exit.\n");
// The BPF link is not pinned, therefore exiting will remove program
for (;;) {
fprintf(stderr, ".");
sleep(1);
}
cleanup:
deny_unshare_bpf__destroy(skel);
return err;
}
Makefile:
結果
CLANG ?= clang-13
LLVM_STRIP ?= llvm-strip-13
ARCH := x86
INCLUDES := -I/usr/include -I/usr/include/x86_64-linux-gnu
LIBS_DIR := -L/usr/lib/lib64 -L/usr/lib/x86_64-linux-gnu
LIBS := -lbpf -lelf
.PHONY: all clean run
all: deny_unshare.skel.h deny_unshare.bpf.o deny_unshare
run: all
sudo ./deny_unshare
clean:
rm -f *.o
rm -f deny_unshare.skel.h
#
# BPF is kernel code. We need to pass -D__KERNEL__ to refer to fields present
# in the kernel version of pt_regs struct. uAPI version of pt_regs (from ptrace)
# has different field naming.
# See: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=fd56e0058412fb542db0e9556f425747cf3f8366
#
deny_unshare.bpf.o: deny_unshare.bpf.c
$(CLANG) -g -O2 -Wall -target bpf -D__KERNEL__ -D__TARGET_ARCH_$(ARCH) $(INCLUDES) -c $< -o $@
$(LLVM_STRIP) -g $@ # Removes debug information
deny_unshare.skel.h: deny_unshare.bpf.o
sudo bpftool gen skeleton $< > $@
deny_unshare: deny_unshare.c deny_unshare.skel.h
$(CC) -g -Wall -c $< -o $@.o
$(CC) -g -o $@ $(LIBS_DIR) $@.o $(LIBS)
.DELETE_ON_ERROR:
在新的終端機視窗中執行:
在另一個終端機視窗中,我們被成功封锁!
$ make run
…
LSM loaded! ctrl+c to exit.
該原則還有一個額外功能,可以永遠允許權限通過:
$ unshare -rU
unshare: unshare failed: Cannot allocate memory
$ id
uid=1000(fred) gid=1000(fred) groups=1000(fred) …
在無權限情况下,syscall 會提前中止。在有權限情况下,會對效能產生什麼影響呢?
$ sudo unshare -rU
# id
uid=0(root) gid=0(root) groups=0(root)
測量效能
我們將使用一行 unshare 來對應使用者命名空間,並在其中執行一個命令進行測量:
透過解析 syscall unshare 進入/結束的 CPU 週期數,我們將以 root 使用者身分進行下列測量:
$ unshare -frU --kill-child -- bash -c "exit 0"
在無原則情况下執行命令
在有原則情况下執行命令
我們將使用 ftrace 來記錄測量值:
此時,我們專門為 unshare 啟用了 syscall 進入和結束追蹤。現在我們設定進入/結束呼叫的時間解析來計算 CPU 週期數:
$ sudo su
# cd /sys/kernel/debug/tracing
# echo 1 > events/syscalls/sys_enter_unshare/enable ; echo 1 > events/syscalls/sys_exit_unshare/enable
接下來,我們開始進行測量:
# echo 'x86-tsc' > trace_clock
在新的終端機視窗中執行原則,然後執行下一個 syscall:
# unshare -frU --kill-child -- bash -c "exit 0" &
[1] 92014
現在我們對兩個呼叫進行比較:
# unshare -frU --kill-child -- bash -c "exit 0" &
[2] 92019
unshare-92014 使用了 63294 個週期。
# cat trace
# tracer: nop
#
# entries-in-buffer/entries-written: 4/4 #P:8
#
# _-----=> irqs-off
# / _----=> need-resched
# | / _---=> hardirq/softirq
# || / _--=> preempt-depth
# ||| / _-=> migrate-disable
# |||| / delay
# TASK-PID CPU# ||||| TIMESTAMP FUNCTION
# | | | ||||| | |
unshare-92014 [002] ..... 762950852559027: sys_unshare(unshare_flags: 10000000)
unshare-92014 [002] ..... 762950852622321: sys_unshare -> 0x0
unshare-92019 [007] ..... 762975980681895: sys_unshare(unshare_flags: 10000000)
unshare-92019 [007] ..... 762975980752033: sys_unshare -> 0x0
unshare-92019 使用了 70138 個週期。
在兩次測量之間有 6844 (~10%) 個週期的差值。結果還不錯!
這些數字是針對單個 syscall 的,並且程式碼呼叫頻率越高,這些數字加起來的總和就會越大。unshare 通常在建立工作時進行呼叫,而不是在程式正常執行期間反覆呼叫。您的使用案例需要仔細考量和測量。
結尾
我們瞭解了 LSM BPF 是什麼,如何使用 unshare 將使用者對應至 root,以及如何透過在 eBPF 中實作解決方案來解决實際問題。追蹤適當的勾點並不容易,不僅需要有相關經驗,還需要大量的核心程式碼。所幸,這是最難的部分。因為原則是用 C 語言編寫的,所以我們可以根據我們的問題對原則進行細微地調整。這意味著可以使用允許清單來延伸此原則,以允許某些程式或使用者繼續使用無權限的 unshare。最後,我們瞭解了此程式對效能的影響,發現用於封锁攻擊手段的開支是非常值得的。
「Cannot allocate memory」(無法配置記憶體)不是拒絕權限的明確錯誤訊息。我們提出了一個修補程式,來傳播 cred_prepare 中的錯誤碼以連結呼叫堆疊。最終我們得出結論:新勾點更適合解決此問題。敬請期待!