本文发表于一年多前。旧文章可能包含过时内容。请检查页面中的信息自发布以来是否已变得不正确。

使用 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 通知程序的信息,请查看以下资源: