本文發表於一年多前。舊文章可能包含過時內容。請檢查頁面中的資訊自發布以來是否已變得不正確。

使用 seccomp 通知器發現可疑的系統呼叫

在生產環境中除錯軟體是我們在容器化環境中必須面對的最大挑戰之一。能夠理解可用安全選項的影響,尤其是在配置部署時,是增強 Kubernetes 預設安全性的關鍵方面之一。我們手頭已經有了所有的日誌、追蹤和指標資料,但我們如何將它們提供的資訊整合為人類可讀且可操作的內容呢?

Seccomp 是一種標準機制,透過干預其系統呼叫來保護基於 Linux 的 Kubernetes 應用程式免受惡意行為的侵害。這使我們能夠將應用程式限制在一組已定義的可操作項上,例如修改檔案或響應 HTTP 請求。將所需系統呼叫集的知識(例如,修改本地檔案)與實際原始碼聯絡起來同樣並非易事。Kubernetes 的 Seccomp 配置檔案必須用 JSON 編寫,並且可以理解為具有超級能力的特定於體系結構的允許列表,例如:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "defaultErrnoRet": 38,
  "defaultErrno": "ENOSYS",
  "syscalls": [
    {
      "names": ["chmod", "chown", "open", "write"],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

上述配置檔案透過指定 defaultActionSCMP_ACT_ERRNO 來預設報錯。這意味著我們必須透過 SCMP_ACT_ALLOW 允許一組系統呼叫,否則應用程式將根本無法執行任何操作。好的,很酷,為了能夠允許檔案操作,我們只需要新增一堆檔案相關的系統呼叫,比如 openwrite,可能還需要能夠透過 chmodchown 更改許可權,對嗎?基本上是這樣,但這種簡單的方法存在一些問題:

Seccomp 配置檔案需要包含啟動應用程式所需的最小系統呼叫集。這還包括一些來自底層 開放容器倡議 (OCI) 容器執行時的系統呼叫,例如 runccrun。除此之外,我們只能保證特定版本的執行時和我們的應用程式所需的系統呼叫,因為程式碼部分在不同版本之間可能會發生變化。同樣的情況也適用於應用程式的終止以及我們部署的目標體系結構。像在容器內執行命令這樣的功能也需要另一組系統呼叫。更不用說,有多個版本的系統呼叫做著略有不同的事情,而且 seccomp 配置檔案能夠修改它們的引數。對於開發人員來說,他們自己編寫的程式碼部分使用了哪些系統呼叫也並不總是清晰可見的,因為他們依賴於程式語言的抽象或框架。

那麼我們如何知道需要哪些系統呼叫呢?在開發生命週期中,誰應該建立和維護這些配置檔案?

嗯,記錄和分發 seccomp 配置檔案是 安全配置檔案 Operator 的問題領域之一,它已經在解決這個問題。該 Operator 能夠將 seccompSELinux 甚至 AppArmor 的配置檔案記錄到一個 自定義資源定義 (CRD) 中,將它們同步到每個節點,並使其可供使用。

建立安全配置檔案的最大挑戰是捕獲所有執行系統呼叫的程式碼路徑。我們可以透過在執行端到端測試套件時實現應用程式 100% 的邏輯覆蓋來實現這一點。你明白前面這句話的問題所在了:這太理想化了,永遠無法實現,即使不考慮應用程式開發和部署過程中的所有變動因素。

在 seccomp 配置檔案的允許列表中遺漏一個系統呼叫可能會對應用程式產生巨大的負面影響。這不僅僅是我們會遇到可以輕易檢測到的崩潰。它還可能導致邏輯路徑發生輕微變化,改變業務邏輯,使應用程式的部分功能無法使用,降低效能,甚至暴露安全漏洞。我們根本無法看到其全部影響,特別是因為透過 SCMP_ACT_ERRNO 阻止的系統呼叫不會在系統上提供任何額外的 audit 日誌記錄。

這是否意味著我們無計可施了?夢想一個每個人都使用預設 seccomp 配置檔案的 Kubernetes 是否不現實?我們是否應該停止追求 Kubernetes 的最高安全性,並接受它並非天生就該預設安全?

絕對不是。技術會隨著時間的推移而發展,有很多 Kubernetes 幕後的工作者在間接交付功能來解決這類問題。其中提到的一個功能就是 seccomp 通知程式,它可以用來在 Kubernetes 中發現可疑的系統呼叫。

seccomp 通知功能包含一組在 Linux 5.9 中引入的變更。它使核心能夠將與 seccomp 相關的事件通訊到使用者空間。這使得應用程式可以根據系統呼叫採取行動,併為各種可能的用例打開了大門。我們不僅需要正確的核心版本,還需要至少 runc v1.1.0(或 crun v0.19)才能使通知程式正常工作。Kubernetes 容器執行時 CRI-Ov1.26.0 版本中獲得了對 seccomp 通知程式的支援。這個新功能使我們能夠識別應用程式中可能存在的惡意系統呼叫,從而可以驗證配置檔案的的一致性和完整性。讓我們來試試看。

首先,我們需要執行最新的 CRI-O main 版本,因為在撰寫本文時 v1.26.0 尚未釋出。你可以透過從原始碼編譯,或透過get-script使用預構建的二進位制包來做到這一點。CRI-O 的 seccomp 通知程式功能受一個註解保護,該註解必須被明確允許,例如使用這樣的配置嵌入:

> cat /etc/crio/crio.conf.d/02-runtimes.conf
[crio.runtime]
default_runtime = "runc"

[crio.runtime.runtimes.runc]
allowed_annotations = [ "io.kubernetes.cri-o.seccompNotifierAction" ]

如果 CRI-O 正在執行,那麼它應該也表明 seccomp 通知程式可用:

> sudo ./bin/crio --enable-metrics
INFO[…] Starting seccomp notifier watcher
INFO[…] Serving metrics on :9090 via HTTP

我們還啟用了指標,因為它們提供了關於通知程式的額外遙測資料。現在我們需要一個正在執行的 Kubernetes 叢集用於演示。在這個演示中,我們主要採用 hack/local-up-cluster.sh 方法在本地啟動一個單節點 Kubernetes 叢集。

如果一切都已啟動並執行,那麼我們需要定義一個 seccomp 配置檔案以供測試。但我們不必建立自己的配置檔案,我們可以直接使用每個容器執行時附帶的 RuntimeDefault 配置檔案。例如,CRI-O 的 RuntimeDefault 配置檔案可以在 containers/common 庫中找到。

現在我們需要一個測試容器,可以是一個簡單的 nginx pod,如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: nginx
  annotations:
    io.kubernetes.cri-o.seccompNotifierAction: "stop"
spec:
  restartPolicy: Never
  containers:
    - name: nginx
      image: nginx:1.23.2
      securityContext:
        seccompProfile:
          type: RuntimeDefault

請注意註解 io.kubernetes.cri-o.seccompNotifierAction,它為該工作負載啟用了 seccomp 通知程式。該註解的值可以是 stop(停止工作負載),也可以是任何其他值(除了記錄日誌和上報指標外不執行任何操作)。由於會終止,我們還使用了 restartPolicy: Never,以避免在失敗時自動重新建立容器。

讓我們執行這個 pod 並檢查它是否工作:

> kubectl apply -f nginx.yaml
> kubectl get pods -o wide
NAME    READY   STATUS    RESTARTS   AGE     IP          NODE        NOMINATED NODE   READINESS GATES
nginx   1/1     Running   0          3m39s   10.85.0.3   127.0.0.1   <none>           <none>

我們還可以測試 Web 伺服器本身是否按預期工作:

> curl 10.85.0.3
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>

在一切啟動並執行的同時,CRI-O 也表明它已經啟動了 seccomp 通知程式:

…
INFO[…] Injecting seccomp notifier into seccomp profile of container 662a3bb0fdc7dd1bf5a88a8aa8ef9eba6296b593146d988b4a9b85822422febb
…

如果我們現在在容器內執行一個被禁止的系統呼叫,那麼我們可以預期工作負載將被終止。讓我們透過在容器的名稱空間中執行 chroot 來嘗試一下:

> kubectl exec -it nginx -- bash
root@nginx:/# chroot /tmp
chroot: cannot change root directory to '/tmp': Function not implemented
root@nginx:/# command terminated with exit code 137

exec 會話被終止了,所以看起來容器已經不再運行了:

> kubectl get pods
NAME    READY   STATUS           RESTARTS   AGE
nginx   0/1     seccomp killed   0          96s

好的,容器被 seccomp 殺死了,我們能得到更多關於發生了什麼的資訊嗎?

> kubectl describe pod nginx
Name:             nginx
Containers:
  nginx:
    State:          Terminated
      Reason:       seccomp killed
      Message:      Used forbidden syscalls: chroot (1x)
      Exit Code:    137
      Started:      Mon, 14 Nov 2022 12:19:46 +0100
      Finished:     Mon, 14 Nov 2022 12:20:26 +0100

CRI-O 的 seccomp 通知程式功能正確地設定了終止原因和訊息,包括哪個被禁止的系統呼叫被使用了多少次(1x)。多少次?是的,通知程式在最後一次看到系統呼叫後會給應用程式最多 5 秒鐘的時間,然後才開始終止。這意味著透過避免耗時的反覆試錯,可以在一次測試中捕獲多個被禁止的系統呼叫。

> kubectl exec -it nginx -- chroot /tmp
chroot: cannot change root directory to '/tmp': Function not implemented
command terminated with exit code 125
> kubectl exec -it nginx -- chroot /tmp
chroot: cannot change root directory to '/tmp': Function not implemented
command terminated with exit code 125
> kubectl exec -it nginx -- swapoff -a
command terminated with exit code 32
> kubectl exec -it nginx -- swapoff -a
command terminated with exit code 32
> kubectl describe pod nginx | grep Message
      Message:      Used forbidden syscalls: chroot (2x), swapoff (2x)

CRI-O 指標也會反映這一點:

> curl -sf localhost:9090/metrics | grep seccomp_notifier
# HELP container_runtime_crio_containers_seccomp_notifier_count_total Amount of containers stopped because they used a forbidden syscalls by their name
# TYPE container_runtime_crio_containers_seccomp_notifier_count_total counter
container_runtime_crio_containers_seccomp_notifier_count_total{name="…",syscalls="chroot (1x)"} 1
container_runtime_crio_containers_seccomp_notifier_count_total{name="…",syscalls="chroot (2x), swapoff (2x)"} 1

它具體是如何工作的?CRI-O 使用選定的 seccomp 配置檔案,並注入 SCMP_ACT_NOTIFY 操作,而不是 SCMP_ACT_ERRNOSCMP_ACT_KILLSCMP_ACT_KILL_PROCESSSCMP_ACT_KILL_THREAD。它還設定了一個本地監聽器路徑,底層的 OCI 執行時(runc 或 crun)將使用該路徑來建立 seccomp 通知程式套接字。一旦套接字和 CRI-O 之間的連線建立,CRI-O 將會收到每個被 seccomp 干預的系統呼叫的通知。CRI-O 儲存這些系統呼叫,留出一點超時時間讓它們到達,然後在選擇的 seccompNotifierAction=stop 的情況下終止容器。不幸的是,seccomp 通知程式無法就 defaultAction 發出通知,這意味著需要有一個系統呼叫列表來測試自定義配置檔案。CRI-O 也在日誌中指出了這個限制:

INFO[…] The seccomp profile default action SCMP_ACT_ERRNO cannot be overridden to SCMP_ACT_NOTIFY,
        which means that syscalls using that default action can't be traced by the notifier

總而言之,CRI-O 中的 seccomp 通知程式實現可用於驗證你的應用程式在使用 RuntimeDefault 或任何其他自定義配置檔案時是否行為正確。可以基於指標建立警報,以圍繞此功能建立長期執行的測試場景。讓 seccomp 更易於理解和使用將增加其採用率,並幫助我們邁向一個預設更安全的 Kubernetes!

感謝您閱讀這篇博文。如果您想了解更多關於 seccomp 通知程式的資訊,請檢視以下資源: