天天看点

使用 Rootless Linux 容器保护您的 .NET 云应用

关注我们

(本文阅读时间:18分钟)

从 .NET 8 起,我们所有的 Linux 容镜像都将包含一个 non-root 用户。只需要一行代码就能以 non-root 用户身份托管您的 .NET 容器。这个平台级的变化将会使您的应用程序更加安全,并使 .NET 成为最安全的开发者生态系统之一。这是一个小的变化,但对深层防御(defense in depth)影响巨大。

这一变化的灵感来源于我们早期在 Ubuntu Chiseled 容器中启用 .NET 的项目。Chiseled(又称 "distroless")镜像旨在像设备一样,因此 non-root 是这些镜像最简单的设计选择。我们意识到,我们可以将 Chiseled 容器的 non-root 功能应用于我们发布的所有容器镜像。通过这样做,我们提高了 .NET 容器镜像的安全标准。

这篇文章是关于 non-root 容器的好处,创建它们的工作流程以及工作原理。在后续的文章中,我们也将讨论如何在 Kubernetes 中更好地使用这些镜像。另外,如果想要更简单的选项,可查看 .NET SDK 的内置容器支持。

  • .NET SDK 的内置容器支持:https://devblogs.microsoft.com/dotnet/announcing-builtin-container-support-for-the-dotnet-sdk/

最小特权

将容器托管为 non-root 符合最小特权原则。这是由操作系统提供的免费保安。如果以 root 身份运行应用,那应用进程可以在容器中执行任何操作,例如修改文件、安装包或者运行任意可执行文件。如果您的应用程序受到攻击,这将是一个隐患。但是如果以 non-root 身份运行应用,您的应用进程将无法执行太多操作,从而极大地限制了攻击者的恶意操作。

non-root 容器也可以认为是对安全供应链的贡献。通常,人们都是从阻止不良依赖项更新或排查组件来源的角度来探讨安全供应链。non-root 容器在这两者之后。如果在您的进程中出现了不良依赖项( 很有可能遇到这种情况),那么 non-root 容器可能是最好的最后防线。Kubernetes hardening 最佳做法要求以 non-root 用户运行容器,也是出于这个原因。

浅识 app

我们所有的 Linux 镜像从 .NET 8 开始将包含一个 app 用户。app 用户将能够运行您的应用程序,但不能删除或更改容器镜像中的任何文件(除非您明确允许这样做)。这个命名也是一目了然,app 用户除了运行您的应用程序外,几乎不能做任何事情。

这个 app 用户实际上并不是新的。它和我们用于 Ubuntu Chiseled 镜像的那个程序是一样的。这是个关键的设计点。从 .NET 8 开始,我们所有的 Linux 容器镜像都将包含 app 用户。这意味着您可以在我们提供的镜像之间进行切换,并且 user 和 uid 是一样的。

接下来我将描述 docker CLI 的全新体验。

$ docker run --rm mcr.microsoft.com/dotnet/aspnet:8.0-preview cat /etc/passwd | tail -n 1              app:x:64198:64198::/home/app:/bin/sh           

这是镜像中 /etc/passwd file 的最后一行。这是 Linux 用于管理用户的文件。

根据行业指导我们选择了一个相对较高的 uid,接近 2^16。我们还决定此用户应当有一个主目录。

$ docker run --rm -u app mcr.microsoft.com/dotnet/aspnet:8.0-preview bash -c "cd && pwd"              /home/app           

我们看了一下,发现 Node.js,Ubuntu 23.04+和 Chainguard 都在同一个计划中。Nice!

$ docker run --rm node cat /etc/passwd | tail -n 1              node:x:1000:1000::/home/node:/bin/bash              $ docker run ubuntu:lunar cat /etc/passwd | tail -n 1              ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash              $ cat out/layers/ruby/etc/passwd | tail -n 1              nonroot:x:65532:65532:Account created by apko:/home/nonroot:/bin/sh           

最后一个是护链镜像(Chainguard)。这些镜像的结构不同,因此使用了不同的模式。每个人都可以创建自己的用户。关键是避免重叠,尤其是 UIDs 重叠。

容器镜像中有很多用户,但没有一个适用于这个用例。减少用户的数量固然很好,但不太可能,这也是使用 distroless/Chiseled 镜像的好处之一。

Windows 容器已经具有了 non-admin 功能,和 ContainerUser 用户。我们选择不添加 app 到 Windows 容器镜像。您应该遵循 Windows 团队关于如何最好地保护 Windows 容器镜像的指导。

使用 app

“Non-root-capable”:使用单行 USER 指令将您的容器配置为 non-root 用户。

Docker 和 Kubernetes 可以轻松指定要用于容器的用户。这是一个单行指令。根据我们的定义,“non-root-capable”意味着您可以使用一行指令切换到 non-root。这是非常强大的,因为单行指令的易用性消除了任何不安全运行的理由。

注意:aspnetapp 自始至终用作您的 app 的替代品。

您可以使用 通过 CLI 设置用户

-u              $ docker run --rm -u app mcr.microsoft.com/dotnet/runtime-deps:8.0-preview whoami              app           

通过 CLI 指定用户很好,但更多的是用于测试或诊断方案。生产 apps 时最好在 Dockerfile 中使用 username 或 uid 定义 USER。

作为 user:

USER app           

作为 UID:

USER 64198           

我们正在为 UID 添加环境变量。这将启用以下模式。

USER $APP_UID           

我们认为这种模式是最好的做法,因为它可以清楚地表明您正在使用哪个用户,避免重复的魔数(magic numbers),并且使用 UID,如果您使用的是 Kubernetes,所有这些都可以很好地工作。

如果您不执行任何的操作,那一切都将跟之前一样,您的镜像将继续以 root 身份运行。我们希望您采取额外的步骤,以 app 用户身份运行您的容器。您可能想知道为什么我们默认情况下没有切换到 non-root 用户。

切换到端口8080

这个项目最大的症结是我们暴露的端口。事实上,这是一个非常棘手的问题,以至于我们不得不做出重大改变。

我们决定对今后所有容器镜像的端口进行标准化。这个决定是基于我们早期使用 Chiseled 镜像的经验,它已经在端口8080上侦听,现在所有的图像都匹配了。

但是,ASP.NET core 应用(使用我们的 .NET 7 和更早版本的容器镜像)侦听端口 80。问题是端口 80 是一个需要权限的特权端口(至少在某些地方)。本质上这与 non-root 容器不兼容。

您可以在我们的镜像中看到端口的配置方式。

对于 .NET 8:

$ docker run --rm mcr.microsoft.com/dotnet/aspnet:8.0-preview bash -c "export | grep ASPNETCORE"              declare -x ASPNETCORE_HTTP_PORTS="8080"           

对于 .NET 7 及更早版本:

$ docker run --rm mcr.microsoft.com/dotnet/aspnet:7.0 bash -c "export | grep ASPNETCORE"              declare -x ASPNETCORE_URLS="http://+:80"           

接下来,您将需要更改端口映射。

您可以通过 CLI 来执行此操作。您需要在映射的右边设置8080。左边的可以匹配,也可以是另一个值。

docker run --rm -it -p 8080:8080 aspnetapp           

一些用户可能希望继续使用端口 80和 root 。没问题,您仍然可以这样做。

您可以在 Dockerfile 中或通过 CLI 重新定义 ASPNETCORE_HTTP_PORTS。

对于 Dockerfile:

ENV ASPNETCORE_HTTP_PORTS=80           

对于 Docker CLI:

docker run --rm -e ASPNETCORE_HTTP_PORTS=80 -p 8000:80 aspnetapp           

.NET 8 Windows 容器镜像也使用端口8080。

>docker run --rm mcr.microsoft.com/dotnet/aspnet:8.0-preview-nanoserver-ltsc2022 cmd /c "set | findstr ASPNETCORE"              ASPNETCORE_HTTP_PORTS=8080           

ASPNETCORE_HTTP_PORTS 是一个新的环境变量,用于指定 ASP.NET core(实际上是 Kestrel)要侦听的端口(或多个端口)。它采用一个以分号分隔的端口值的列表。.NET 8 图像使用这个新的环境变量,而不是 ASPNETCORE_URLS(在 .NET 6和7图像中使用)。ASPNETCORE_URLS 仍然是一个有用的高级功能。它可以在一个配置中同时指定原始的 HTTP 和 TLS 端口,并覆盖 ASPNETCORE_HTTP_PORTS 和 ASPNETCORE_HTTPS_PORTS。

Non-root 运用

让我们从几个不同的角度看一下 non-root 是什么样子的,这样您就可以更好地了解实际情况。我在 WSL22 中使用 Ubuntu 10.2。

我们添加了一个 Dockerfile,以便您可以亲自尝试这个方案。它将容器配置为始终以 app 运行。它使用的是我们的 aspnetapp 示例。

$ pwd              /home/rich/git/dotnet-docker/samples/aspnetapp              $ cat Dockerfile.alpine-non-root | tail -n 2              USER app              ENTRYPOINT ["./aspnetapp"]              $ docker build --pull -t aspnetapp -f Dockerfile.alpine-non-root .           

让我们看看我们是否可以观察用户的行为,该用户已经在 Dockerfile 中进行了设置。

$ docker run --rm -d -p 8000:8080 aspnetapp              5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad              $ curl http://localhost:8000/Environment              {"runtimeVersion":".NET 8.0.0-preview.2.23128.3","osVersion":"Linux 5.15.90.1-microsoft-standard-WSL2 #1 SMP Fri Jan 27 02:56:13 UTC 2023","osArchitecture":"X64","user":"app","processorCount":16,"totalAvailableMemoryBytes":67429986304,"memoryLimit":9223372036854771712,"memoryUsage":30220288}              $ docker exec 5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad ls -l              total 188              -rw-r--r-- 1 root root 127 Jan 20 17:14 appsettings.Development.json              -rw-r--r-- 1 root root 151 Oct 19 21:59 appsettings.json              -rwxr-xr-x 1 root root 78320 Mar 16 16:51 aspnetapp              -rw-r--r-- 1 root root 463 Mar 16 16:51 aspnetapp.deps.json              -rw-r--r-- 1 root root 51200 Mar 16 16:51 aspnetapp.dll              -rw-r--r-- 1 root root 35316 Mar 16 16:51 aspnetapp.pdb              -rw-r--r-- 1 root root 469 Mar 16 16:51 aspnetapp.runtimeconfig.json              drwxr-xr-x 5 root root 4096 Mar 16 16:51 wwwroot              $ docker exec 5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad ps              PID USER TIME COMMAND              1 app 0:00 ./aspnetapp              53 app 0:00 ps           

请注意从上面的环境端点返回的 JSON 内容中的用户属性。

您可以看到该应用程序是以 app 运行的,并且文件归 root 所有。这意味着应用程序文件正在受到保护,不会被此用户更改。这种分离是我们继续以 root 身份发布镜像的原因之一。如果我们以 app 发布他们,那么默认情况下您的应用二进制文件将不会受到 app 用户的保护。如果我们以 app 发布镜像,您仍然可以实现这种分离,但您的 Docker 文件(也包括我们的)将会因为大量的用户切换而变得混乱,这没有任何好处。

在我们看来,基础镜像制作者应该完全以 root 身份发布平台镜像。这是唯一好的通用模型。

应用程序的二进制文件是由 root 拥有的,因为它们是由构建 /SDK 阶段产生的,而这个阶段是以 root 用户身份运行的。这并不是因为在 Docker 文件中最后的 COPY 之后,用户被改变为 app。请注意,COPY 有语义。

参考 Dockerfile:

所有新的文件和目录都是以 UID 和 GID 为0创建的,除非可选的 -chown 标志指定一个给定的用户名、组名或 UID/GID 组合,以要求对复制的内容拥有特定的所有权。

让我们在这个容器上尝试一些 rootful 操作,在同一个容器上使用 docker exec。

$ docker exec 5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad rm aspnetapp.pdb              rm: can't remove 'aspnetapp.pdb': Permission denied              $ docker exec 5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad touch /file              touch: /file: Permission denied              $ docker exec 5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad which dotnet              /usr/bin/dotnet              $ docker exec 5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad rm /usr/bin/dotnet              rm: can't remove '/usr/bin/dotnet': Permission denied              $ docker exec 5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad apk add curl              ERROR: Unable to lock database: Permission denied              ERROR: Failed to open apk database: Permission denied           

结果:权限被拒绝。这正是我们想要的结果。让我们再试一次,但提升到 root。

$ docker exec -u root 5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad ash -c "rm aspnetapp.pdb && ls aspnetapp.pdb"              ls: aspnetapp.pdb: No such file or directory              $ docker exec -u root 5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad ash -c "touch /file && ls /file"              /file              $ docker exec -u root 5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad ash -c "rm /usr/bin/dotnet && ls /usr/bin/dotnet"              ls: /usr/bin/dotnet: No such file or directory              $ docker exec -u root 5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad apk add curl              fetch https://dl-cdn.alpinelinux.org/alpine/v3.17/main/x86_64/APKINDEX.tar.gz              fetch https://dl-cdn.alpinelinux.org/alpine/v3.17/community/x86_64/APKINDEX.tar.gz              (1/4) Installing brotli-libs (1.0.9-r9)              (2/4) Installing nghttp2-libs (1.51.0-r0)              (3/4) Installing libcurl (7.87.0-r2)              (4/4) Installing curl (7.87.0-r2)              Executing busybox-1.35.0-r29.trigger              OK: 14 MiB in 28 packages           

您可以看到 root 可以做更多的事情,事实上,它可以做任何它想做的。安装 curl 后,攻击者可以从他们的任何网络服务器开始执行脚本。在 Alpine linux 的环境下,它与 wget 一起发布,它删除了这个链中的一个步骤。

请注意,我在这里使用的是 Alpine,所以使用 ash 而不是 bash 作为 shell,但这不会改变演示的任何内容。

当然,解决方法是删除 root 用户以避免这些风险。但是,事实上,除非您采用我们的 Chiseled 镜像,否则删除 root 用户会产生未定义的行为。最好的选择是以非 non-root 用户身份运行,它可以通过定义好的机制消除一整类的攻击。

使用 docker exec -u root 可能看起来很吓人。如果攻击者可以在您运行的容器上运行 docker exec -u root,那意味着他们已经有了进入主机的权限。

sudo 并不包括在我们的镜像中,而且永远也不会包括。

$ docker exec 5bde77feebdf76ff370815f41a8989a880d51a4037c91e2ac8c6f2c269b759ad sudo              OCI runtime exec failed: exec failed: unable to start container process: exec: "sudo": executable file not found in $PATH: unknown           

在 Azure 容器服务中托管

在 Azure 容器服务中采用这种模式非常简单。需要考虑两个方面:端口和用户。

有些容器服务提供了比 Kubernetes 更高级的体验,需要不同的配置选项。

  • Azure 应用服务要求 WEBSITES_PORT 使用80端口以外的端口。它可以通过 CLI 或在门户中设置。
  • Azure 容器应用允许在创建资源的过程中更改端口。
  • Azure 容器实例允许在创建资源的过程中更改端口。

这些服务都没有提供明显的方式来改变用户。如果您在 Docker 文件中设置了用户(这是最佳的做法),那么就不需要该功能了。

后续步骤

下一步是研究 non-root 可能具有挑战性的情况,例如诊断场景。一些例子使用 docker exec -u root。这在本地环境下很好用,但是 kubectl exec 不提供用户参数。我们也将在之后的文章中更加深入地研究 non-root 的 Kubernetes 工作流程。

我们还将继续与容器托管服务合作,以确保 .NET 开发人员能够轻松地转移到 .NET 8 容器镜像,特别是那些提供更高级别的体验,如 Azure App Service。

使用 Rootless Linux 容器保护您的 .NET 云应用

我们 .NET 团队使命的一个关键部分是纵深防御。每个人都需要考虑安全问题,然而,我们的业务是通过单一的变化或功能来关闭整个攻击类别。大约十年前我们刚开始发布容器映像时,就可以做出这种改变。许多年来,我们一直被要求提供 non-root 指导和 non-root 容器映像。老实说,我们并不清楚该如何处理这个问题,很大程度上是因为我们现在使用的模式在我们刚开始的时候并不存在。在安全容器托管方面,没有一个领导者可以让我们学习。正是与 Canonical 在 chiseled 镜像方面的合作经验,使我们发现并形成了这种方法。

我们希望这一举措能够使整个 .NET 容器生态系统切换到 non-root 托管。我们致力于使 .NET 应用程序在云中更具有高性能和安全性。