使用 Rootless Linux 容器保護您的 .NET 雲應用

微軟技術棧發表於2023-04-04

從 .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 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 應用程式在雲中更具有高效能和安全性。

相關文章