Guest Richard Lander Posted May 26, 2024 Posted May 26, 2024 .NET 8 raises the bar for container security for .NET container images and SDK tools. The SDK produces application images that align with industry best practices and standards, by default. We also offer additional security hardening with Chiseled images for an extra layer of protection. [iCODE]dotnet publish[/iCODE] will generate a container image for you and configure it as non-root by default. It’s easy with .NET to quickly improve the security posture of your production apps. In this post, you will learn how to: Produce non-root container images Configure Kubernetes pods to require non-root images Inspect images and containers Use [iCODE]root[/iCODE] (or other users) This post is a continuation of Streamline your container build and publish with .NET 8, published earlier this month. It builds on Secure your .NET cloud apps with rootless Linux Containers and Running non-root .NET containers with Kubernetes, published last year. [HEADING=1]Threat model[/HEADING] It’s good to start any security related conversation with a clear view of the threats at play. There are two primary threats to consider: Container breakout — An attacker is able to break out of the container and execute operations within the host. Remote Code Execution (RCE) — An attacker is able to cause an app to execute operations within the container. Not specific to containers. Despite Docker not being marketed as sandboxing software, its default setup is meant to secure host resources from being accessed by processes inside of a container. That’s a great framing of the situation, from Escape from Docker and Kubernetes containers to root on host (in reference to CVE-2019-5736). The author is saying that we’re collectively relying a lot on the “default setup” of the various container solutions we use, implying that container breakout is a real threat. From the same post, under “Mitigations”: Use a low privileged user inside the container Here, the author is effectively saying that you need to do your part to more safely rely on the pseudo-sandboxing nature of container solutions. If you don’t and another container breakout vulnerability is discovered, then part of the burden falls to developers hosting their apps as [iCODE]root[/iCODE]. Put another way, “caveat emptor.” The security and vulnerability landscape can be tough to navigate at the best of times. Keeping dependencies up to date is the first and most critical mitigation to these risks, for both container host and guest. Non-root hosting is an excellent defense in depth measure that may protect against unknown future vulnerabilities. [HEADING=1]Container ecosystem: [iCODE]root[/iCODE] by default[/HEADING] Base images are configured as the [iCODE]root[/iCODE] user by default. $ docker run --rm alpine whoami root $ docker run --rm debian whoami root $ docker run --rm ubuntu whoami root $ docker run --rm mcr.microsoft.com/dotnet/runtime-deps:8.0 whoami root After just explaining that images should be configured as non-root as an important security measure, I’m demonstrating that most base images are published using [iCODE]root[/iCODE], including the ones we publish. Why? Usability trumps security for general-purpose base images and always will. It’s important that package management and other privileged operations are straightforward and that higher-level images can choose the user they want. However, it remains true that an attacker with an active RCE vulnerability will be able to do anything they want with [iCODE]root[/iCODE] permission. In contrast, Ubuntu Chiseled and Chainguard base images are appliance-like, taking a different approach than general purpose images. They trade usability and compatibility for security. We endorse this design point. Note: See Hardened Container Images: Images for a Secure Supply Chain. That’s a lot of context about base images and a great segue to application images, which (we think) should be built with a security-first philosophy. [HEADING=1].NET ecosystem: Non-root by default[/HEADING] [iCODE]dotnet publish[/iCODE] produces non-root images by default. Let’s take a look with a simple console app. I’m going to skip a number of steps that are covered in Streamline your container build and publish with .NET 8. This is the source code for the app. using System.Runtime.InteropServices; Console.WriteLine($"Hello {Environment.UserName}, using {RuntimeInformation.OSDescription} on {RuntimeInformation.OSArchitecture}"); It is very easy to produce a container image. $ dotnet publish -t:PublishContainer MSBuild version 17.9.8+b34f75857 for .NET Determining projects to restore... All projects are up-to-date for restore. my-app -> /Users/rich/my-app/bin/Release/net8.0/my-app.dll my-app -> /Users/rich/my-app/bin/Release/net8.0/publish/ Building image 'my-app' with tags 'latest' on top of base image 'mcr.microsoft.com/dotnet/runtime:8.0'. Pushed image 'my-app:latest' to local registry via 'docker'. The app will say hello to the user that starts the process, as you can see from the source code. $ docker run --rm my-app Hello app, using Debian GNU/Linux 12 (bookworm) on Arm64 We see [iCODE]Hello app[/iCODE], as expected. We can also run [iCODE]whoami[/iCODE] just like was done with the base images. $ docker run --rm --entrypoint bash my-app -c "whoami" app As can be seen, this image is not using [iCODE]root[/iCODE], in contrast to the base images we looked at. Running [iCODE]whoami[/iCODE] requires launching the image. Kubernetes doesn’t do that; it looks at container image metadata to determine the user. Let’s look at container metadata. $ docker inspect --format='{{.Config.User}}' my-app 1654 The SDK sets the user via UID because that’s required by Kubernetes to enforce its [iCODE]runAsNonRoot[/iCODE] property. We can look a bit more under the covers to see where the [iCODE]1654[/iCODE] value comes from. $ docker run --rm --entrypoint bash my-app -c "cat /etc/passwd | tail -n 1" app:x:1654:1654::/home/app:/bin/sh $ docker inspect --format='{{.Config.Env}}' my-app [PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin APP_UID=1654 ASPNETCORE_HTTP_PORTS=8080 DOTNET_RUNNING_IN_CONTAINER=true DOTNET_VERSION=8.0.4] $ docker run --rm --entrypoint bash my-app -c "echo \$APP_UID" 1654 We define a user called [iCODE]app[/iCODE] and give it a UID > 1000 to avoid reserved ranges. [iCODE]1654[/iCODE] is [iCODE]1000[/iCODE] + the ASCII values of each of the characters in [iCODE]dotnet[/iCODE]. We also set an environment variable — [iCODE]APP_UID[/iCODE] — with this same value. That avoids anyone needing to remember or use this value (without the environment variable) for common scenarios. In a previous post, I included a set of fun non-root in action demos. You can look at that post to go deeper. [HEADING=1]Non-root Dockerfiles[/HEADING] The model with Dockerfiles is similar, but requires one extra step, setting the [iCODE]USER[/iCODE] instruction. I’ll show you what that looks like, using this sample Dockerfile. That Dockerfile uses the environment variable we just looked at to define the user. This is the pattern we intend everyone to use, to switch to a non-root user with Dockerfiles. Again, this pattern avoids magic numbers being plastered everywhere and works best with Kubernetes. $ cat Dockerfile | tail -n 2 USER $APP_UID ENTRYPOINT ["./dotnetapp"] Note: Lots of developers will have already made their own user. Continuing with your own user or switching to the built-in one are both fine options. We can then build and run an image. $ docker build --pull -t my-app . $ docker run --rm my-app 42 42 ,d ,d 42 42 42 ,adPPYb,42 ,adPPYba, MM42MMM 8b,dPPYba, ,adPPYba, MM42MMM a8" `Y42 a8" "8a 42 42P' `"8a a8P_____42 42 8b 42 8b d8 42 42 42 8PP!!!!!!! 42 "8a, ,d42 "8a, ,a8" 42, 42 42 "8b, ,aa 42, `"8bbdP"Y8 `"YbbdP"' "Y428 42 42 `"Ybbd8"' "Y428 OSArchitecture: Arm64 OSDescription: Debian GNU/Linux 12 (bookworm) FrameworkDescription: .NET 8.0.4 UserName: app HostName : 8da0d81720f8 ProcessorCount: 8 TotalAvailableMemoryBytes: 4113563648 (3.83 GiB) As you can see, the application is running as the [iCODE]app[/iCODE] user. The switch to enable non-root hosting (in Dockerfiles) is just a one line change. [HEADING=1]Ubuntu Chiseled images[/HEADING] Ubuntu Chiseled images are appliance-like, providing a locked-down default experience. They are compatible with regular Ubuntu, however, they have sharp edges where whole sections of the operating system have been cut out. Notably, they are configured as non-root. That means that you don’t even have to configure the user since it is already set. You can inspect a chiseled image to see the user is set. $ docker inspect --format='{{.Config.User}}' mcr.microsoft.com/dotnet/runtime:8.0-jammy-chiseled 1654 We have a different sample Dockerfile that relies on the user being set in these images. $ cat Dockerfile.chiseled | tail -n 4 FROM mcr.microsoft.com/dotnet/runtime:8.0-jammy-chiseled WORKDIR /app COPY --from=build /app . ENTRYPOINT ["./dotnetapp"] As you can see, the [iCODE]USER[/iCODE] is not set in this Dockerfile. Let’s build and run it. $ docker build --pull -t my-app -f Dockerfile.chiseled . $ docker run --rm my-app 42 42 ,d ,d 42 42 42 ,adPPYb,42 ,adPPYba, MM42MMM 8b,dPPYba, ,adPPYba, MM42MMM a8" `Y42 a8" "8a 42 42P' `"8a a8P_____42 42 8b 42 8b d8 42 42 42 8PP!!!!!!! 42 "8a, ,d42 "8a, ,a8" 42, 42 42 "8b, ,aa 42, `"8bbdP"Y8 `"YbbdP"' "Y428 42 42 `"Ybbd8"' "Y428 OSArchitecture: Arm64 OSDescription: Ubuntu 22.04.4 LTS FrameworkDescription: .NET 8.0.4 UserName: app HostName : 293212d2eaba ProcessorCount: 8 TotalAvailableMemoryBytes: 4113563648 (3.83 GiB) Again, the application is running as the [iCODE]app[/iCODE] user. If you use chiseled images, you get better results and there is less to do in your Dockerfile. You can just as easily use Chiseled images with SDK publish. [iCODE]dotnet publish -t:PublishContainer -p:ContainerFamily=jammy-chiseled[/iCODE] This command will produce a non-root image, both because our Chiseled images are configured as non-root and [iCODE]dotnet publish[/iCODE] creates non-root images by default. [HEADING=1]Kubernetes[/HEADING] Kubernetes has a [iCODE]runAsNonRoot[/iCODE] mechanism that is part of its security standards. When set (to [iCODE]true[/iCODE]), Kubernetes will fail loading a pod manifest if a container image is [iCODE]root[/iCODE]. I think of [iCODE]runAsNonRoot[/iCODE] as a “roles and responsibilities” type feature. It is the role of the container image to set the user. It is the responsibility of the orchestrator to validate that the user is set as expected, as non-root. Recall the “like Kubernetes does it” look at container metadata earlier. $ docker inspect --format='{{.Config.User}}' my-app 1654 Kubernetes doesn’t use [iCODE]docker inspect[/iCODE] but the idea is the same. It looks at this same [iCODE]User[/iCODE] value, determines if the value is a UID, and if so does a [iCODE]value > 0[/iCODE] check. If that expression resolves to true, then the [iCODE]runAsNonRoot[/iCODE] check passes. For context [iCODE]root[/iCODE] has a UID of [iCODE]0[/iCODE], so this check is the analog of [iCODE]user != root[/iCODE]. Let’s take a quick look at how non-root works with Kubernetes. There is much more detail in Running non-root .NET containers with Kubernetes if you want to learn more. Here’s an example of how to set [iCODE]runAsNonRoot[/iCODE] in a Pod manifest. spec: securityContext: runAsNonRoot: true containers: - name: aspnetapp image: mcr.microsoft.com/dotnet/samples:aspnetapp-chiseled ports: - containerPort: 8080 In this example, every container listed (even though there is only one in the example) must be non-root. [iCODE]securityContext[/iCODE] can also be set on a container. You can see these settings in broader context in non-root.yaml. It’s really only interesting to see what happens if [iCODE]runAsNonRoot[/iCODE] is set to [iCODE]true[/iCODE] and we try to load an image that uses the [iCODE]root[/iCODE] users. At the time of writing, the [iCODE]mcr.microsoft.com/dotnet/samples:aspnetapp-chiseled[/iCODE] image (used above) is configured as non-root and the [iCODE]mcr.microsoft.com/dotnet/samples:aspnetapp[/iCODE] is [iCODE]root[/iCODE]. I’ll change the [iCODE]image[/iCODE] value in the manifest to [iCODE]mcr.microsoft.com/dotnet/samples:aspnetapp[/iCODE] and then see if the load fails. $ kubectl apply -f non-root.yaml deployment.apps/dotnet-non-root created service/dotnet-non-root created $ kubectl get po NAME READY STATUS RESTARTS AGE dotnet-non-root-6df9cb77d8-74t96 0/1 CreateContainerConfigError 0 5s As you can see, the load fails. Digging a little deeper, we can see the cause. $ kubectl describe po | grep Error Reason: CreateContainerConfigError Warning Failed 7s (x2 over 8s) kubelet Error: container has runAsNonRoot and image will run as root (pod: "dotnet-non-root-6df9cb77d8-74t96_default(d4df0889-4a69-481a-adc4-56f41fb41c63)", container: aspnetapp) Error: container has runAsNonRoot and image will run as root That matches expectations. Good. [HEADING=1]Change the user to [iCODE]root[/iCODE][/HEADING] There may be cases where the user needs to be set to [iCODE]root[/iCODE]. That’s straightforward to do. It is possible (using Docker) to run a command as [iCODE]root[/iCODE] in a running container with [iCODE]docker exec -u[/iCODE]. The command will be often be [iCODE]bash[/iCODE], but we’ll use [iCODE]whoami[/iCODE] since it offers a better demonstration. $ docker exec 5d56a4a1cb97 whoami app $ docker exec -u root 5d56a4a1cb97 whoami root Note that [iCODE]kubectl exec[/iCODE] doesn’t offer a [iCODE]-u[/iCODE] argument (for good reason). Similarly, a container can be started with a specific user, overriding the user set in image metadata. $ docker run --rm -it -u root myapp Hello root, using Debian GNU/Linux 12 (bookworm) on X64 Last, a specific user can be used when building the image, with [iCODE]ContainerUser[/iCODE]. $ dotnet publish -t:PublishContainer -p:ContainerUser=root Building image 'myapp' with tags 'latest' on top of base image 'mcr.microsoft.com/dotnet/runtime:8.0'. Pushed image 'myapp:latest' to local registry via 'docker'. $ docker run --rm -it myapp Hello root, using Debian GNU/Linux 12 (bookworm) on X64 The [iCODE]ContainerUser[/iCODE] specified needs to exist. $ dotnet publish -t:PublishContainer -p:ContainerUser=rich Building image 'myapp' with tags 'latest' on top of base image 'mcr.microsoft.com/dotnet/runtime:8.0'. Pushed image 'myapp:latest' to local registry via 'docker'. $ docker run --rm -it myapp docker: Error response from daemon: unable to find user rich: no matching entries in passwd file. You can, however, use a valid UID. $ dotnet publish -t:PublishContainer -p:ContainerUser=1654 Building image 'myapp' with tags 'latest' on top of base image 'mcr.microsoft.com/dotnet/runtime:8.0'. Pushed image 'myapp:latest' to local registry via 'docker'. $ docker run --rm myapp Hello app, using Debian GNU/Linux 12 (bookworm) on X64 As you can see, both the [iCODE]root[/iCODE] and [iCODE]app[/iCODE] users are defined in the container images we publish. $ docker run --rm mcr.microsoft.com/dotnet/runtime-deps bash -c "cat /etc/passwd | head -n 1" root:x:0:0:root:/root:/bin/bash $ docker run --rm mcr.microsoft.com/dotnet/runtime-deps bash -c "cat /etc/passwd | tail -n 1" app:x:1654:1654::/home/app:/bin/sh [HEADING=1]Closing[/HEADING] The user for production apps is a key part of any security plan. Unfortunately, it is easy to miss since everything works without specifying the user. In fact, it works too well. One could say this is the root of the problem. Adding a user to a Dockerfile is easy. Creating end-to-end workflows that reliably establish the desired security outcomes is a lot harder. As you can see, it is now straightforward to produce non-root container images, with [iCODE]dotnet publish[/iCODE] or with Dockerfiles. The images will work correctly with Kubernetes security features, which is critical in enforcing your desired security policies. There will always be additional security settings that are needed. Non-root hosting is one of the most impactful changes you can make. The post Secure your container build and publish with .NET 8 appeared first on .NET Blog. Continue reading... Quote
Recommended Posts
Join the conversation
You can post now and register later. If you have an account, sign in now to post with your account.