.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.
dotnet publish
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
root
(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.
Threat model
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 root
. 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.
Container ecosystem: root
by default
Base images are configured as the root
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 root
, 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 root
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.
.NET ecosystem: Non-root by default
dotnet publish
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 Hello app
, as expected.
We can also run whoami
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 root
, in contrast to the base images we looked at.
Running whoami
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 runAsNonRoot
property.
We can look a bit more under the covers to see where the 1654
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 app
and give it a UID > 1000 to avoid reserved ranges. 1654
is 1000
+ the ASCII values of each of the characters in dotnet
. We also set an environment variable — APP_UID
— 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.
Non-root Dockerfiles
The model with Dockerfiles is similar, but requires one extra step, setting the USER
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 app
user.
The switch to enable non-root hosting (in Dockerfiles) is just a one line change.
Ubuntu Chiseled images
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 USER
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 app
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.
dotnet publish -t:PublishContainer -p:ContainerFamily=jammy-chiseled
This command will produce a non-root image, both because our Chiseled images are configured as non-root and dotnet publish
creates non-root images by default.
Kubernetes
Kubernetes has a runAsNonRoot
mechanism that is part of its security standards. When set (to true
), Kubernetes will fail loading a pod manifest if a container image is root
.
I think of runAsNonRoot
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 docker inspect
but the idea is the same. It looks at this same User
value, determines if the value is a UID, and if so does a value > 0
check. If that expression resolves to true, then the runAsNonRoot
check passes. For context root
has a UID of 0
, so this check is the analog of user != root
.
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 runAsNonRoot
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. securityContext
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 runAsNonRoot
is set to true
and we try to load an image that uses the root
users.
At the time of writing, the mcr.microsoft.com/dotnet/samples:aspnetapp-chiseled
image (used above) is configured as non-root and the mcr.microsoft.com/dotnet/samples:aspnetapp
is root
. I’ll change the image
value in the manifest to mcr.microsoft.com/dotnet/samples:aspnetapp
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.
Change the user to root
There may be cases where the user needs to be set to root
. That’s straightforward to do.
It is possible (using Docker) to run a command as root
in a running container with docker exec -u
. The command will be often be bash
, but we’ll use whoami
since it offers a better demonstration.
$ docker exec 5d56a4a1cb97 whoami
app
$ docker exec -u root 5d56a4a1cb97 whoami
root
Note that kubectl exec
doesn’t offer a -u
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 ContainerUser
.
$ 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 ContainerUser
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 root
and app
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
Closing
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 dotnet publish
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.
Groan!
😀
Ha ha!
My colleagues would tell you not to encourage this behavior!