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

Songjie Cai

本文翻译于Richard Lander的这篇英文文章:Secure your .NET cloud apps with rootless Linux Containers – .NET Blog (microsoft.com)

从 .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 的内置容器支持。

最小特权

将容器托管为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 1app: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 whoamiapp

通过 CLI 指定用户很好,但更多的是用于测试或诊断方案。生产apps时最好在 Dockerfile 中使用username或 uid 定义 USER。 作为user:USER app 作为UID:USER 64198 我们正在为 UID 添加环境变量。这将启用以下模式。

USER $APP_UID 我们认为这种模式是最好的做法,因为它可以清楚地表明您正在使用哪个用户,避免重复的魔数(magic numbers),并且使用 UID,如果您使用的是 Kubernetes,所有这些都可以很好地工作。我们很快就会有一篇关于 Kubernetes 的non-root托管的文章,敬请期待。 如果您不执行任何的操作,那一切都将跟之前一样,你的镜像将继续以 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呢?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。 总结 我们 .NET 团队使命的一个关键部分是纵深防御。每个人都需要考虑安全问题,然而,我们的业务是通过单一的变化或功能来关闭整个攻击类别。诚然,大约十年前我们刚开始发布容器映像时,就可以做出这种改变。许多年来,我们一直被要求提供non-root指导和non-root容器映像。老实说,我们并不清楚该如何处理这个问题,很大程度上是因为我们现在使用的模式在我们刚开始的时候并不存在。在安全容器托管方面,没有一个领导者可以让我们学习。正是与Canonical在chiseled 镜像方面的合作经验,使我们发现并形成了这种方法。 我们希望这一举措能够使整个 .NET 容器生态系统切换到非根托管。 我们致力于使.net应用程序在云中更具有高性能和安全性。

0 comments

Leave a comment

Feedback usabilla icon