關注我們
(本文閱讀時間: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。
我們 .NET 團隊使命的一個關鍵部分是縱深防禦。每個人都需要考慮安全問題,然而,我們的業務是通過單一的變化或功能來關閉整個攻擊類别。大約十年前我們剛開始釋出容器映像時,就可以做出這種改變。許多年來,我們一直被要求提供 non-root 指導和 non-root 容器映像。老實說,我們并不清楚該如何處理這個問題,很大程度上是因為我們現在使用的模式在我們剛開始的時候并不存在。在安全容器托管方面,沒有一個上司者可以讓我們學習。正是與 Canonical 在 chiseled 鏡像方面的合作經驗,使我們發現并形成了這種方法。
我們希望這一舉措能夠使整個 .NET 容器生态系統切換到 non-root 托管。我們緻力于使 .NET 應用程式在雲中更具有高性能和安全性。