本文发表已超过一年。过期文章可能包含过时内容。请检查页面信息自发布以来是否已失效。

使用 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 配置文件是 Security Profiles Operator 的问题领域之一,它已经在解决这个问题了。该 operator 能够将 seccompSELinux 甚至 AppArmor 配置文件记录到 自定义资源定义 (CRD) 中,将它们协调到每个节点并使其可供使用。

创建安全配置文件的最大挑战在于捕获执行系统调用的所有代码路径。我们可以在运行端到端测试套件时,通过实现应用的 100% 逻辑覆盖率来达成这一目标。你可能已经意识到前述说法存在的问题:这太理想化了,永远无法完全实现,即使不考虑应用开发和部署过程中的所有动态因素。

seccomp 配置文件允许列表中缺少系统调用会对应用产生巨大的负面影响。我们不仅可能遇到很容易检测到的崩溃,还可能导致它们轻微改变逻辑路径、改变业务逻辑、使部分应用无法使用、降低性能甚至暴露安全漏洞。我们根本无法看到由此产生的全部影响,尤其因为通过 SCMP_ACT_ERRNO 阻止的系统调用不会在系统中提供任何额外的审计日志。

这是否意味着我们无能为力了?梦想着每个人都使用默认 seccomp 配置文件的 Kubernetes 是否不切实际?我们是否应该停止追求 Kubernetes 的最大安全性,并接受它并非默认安全这一事实?

绝对不是。技术随着时间发展,有许多人在 Kubernetes 的幕后努力,间接地提供解决这些问题的功能。其中提到的一个特性就是seccomp 通知器,它可用于查找 Kubernetes 中的可疑系统调用。

seccomp 通知特性由 Linux 5.9 中引入的一系列更改组成。它使内核能够将 seccomp 相关事件通信到用户空间。这允许应用基于系统调用采取行动,并开启了广泛的可能用例。我们不仅需要正确的内核版本,而且至少需要 runc v1.1.0(或 crun v0.19)才能使通知器正常工作。Kubernetes 容器运行时 CRI-O 在 v1.26.0 中获得了对 seccomp 通知器的支持。这个新特性使我们能够识别应用中可能的恶意系统调用,从而可以验证配置文件的一致性和完整性。我们来试一试。

首先,我们需要运行最新 main 版本的 CRI-O,因为在撰写本文时 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 通知器的信息,请参考以下资源: