訂閱以接收新文章的通知:

使用 eBPF Linux Security Module 即時修補 Linux 核心內的安全性漏洞

2022-06-29

閱讀時間:5 分鐘
本貼文還提供以下語言版本:English简体中文

Linux Security Module (LSM) 是一個基於勾點的架構,用於在 Linux 核心中實作安全性原則及強制存取控制。直到前不久,期望實作安全性原則的使用者還只有兩個選項:即設定 AppArmor 或 SELinux 等現有 LSM 模組,或編寫自訂核心模組。

Live-patching security vulnerabilities inside the Linux kernel with eBPF Linux Security Module

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 cloneclone3 很是值得研究,因為它們還具有 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"
  1. 在無原則情况下執行命令

  2. 在有原則情况下執行命令

我們將使用 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 中的錯誤碼以連結呼叫堆疊。最終我們得出結論:新勾點更適合解決此問題。敬請期待!

我們保護整個企業網路,協助客戶有效地建置網際網路規模的應用程式,加速任何網站或網際網路應用程式抵禦 DDoS 攻擊,阻止駭客入侵,並且可以協助您實現 Zero Trust

從任何裝置造訪 1.1.1.1,即可開始使用我們的免費應用程式,讓您的網際網路更快速、更安全。

若要進一步瞭解我們協助打造更好的網際網路的使命,請從這裡開始。如果您正在尋找新的職業方向,請查看我們的職缺
Linux安全性Deep DiveProgramming

在 X 上進行關注

Cloudflare|@cloudflare

相關貼文

2024年10月25日 下午1:00

Elephants in tunnels: how Hyperdrive connects to databases inside your VPC networks

Hyperdrive (Cloudflare’s globally distributed SQL connection pooler and cache) recently added support for directing database traffic from Workers across Cloudflare Tunnels. We dive deep on what it took to add this feature....