Visual Studio 2026 Insiders is here!

The world’s most popular IDE just got an upgrade.

April 30th, 2024
heart6 reactions

Secure your container build and publish with .NET 8

Richard Lander
Product Manager

.NET 8 raises the bar for container security for .NET container images and SDK tools. The SDK produces application images that align withindustry best practices and standards, by default. We also offer additional security hardening withChiseled 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
  • Useroot (or other users)

This post is a continuation ofStreamline your container build and publish with .NET 8, published earlier this month. It builds onSecure your .NET cloud apps with rootless Linux Containers andRunning 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, fromEscape from Docker and Kubernetes containers to root on host (in reference toCVE-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 asroot. 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 excellentdefense in depth measure that may protect against unknown future vulnerabilities.

Container ecosystem:root by default

Base images are configured as theroot user by default.

$ docker run --rm alpine whoamiroot$ docker run --rm debian whoamiroot$ docker run --rm ubuntu whoamiroot$ docker run --rm mcr.microsoft.com/dotnet/runtime-deps:8.0 whoamiroot

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 usingroot, 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 withroot permission.

In contrast,Ubuntu Chiseled andChainguard 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: SeeHardened 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 inStreamline 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:PublishContainerMSBuild 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-appHello app, using Debian GNU/Linux 12 (bookworm) on Arm64

We seeHello app, as expected.

We can also runwhoami 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 usingroot, in contrast to the base images we looked at.

Runningwhoami 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-app1654

The SDK sets the user via UID because that’s required by Kubernetes to enforce itsrunAsNonRoot property.

We can look a bit more under the covers to see where the1654 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 calledapp and give it a UID > 1000 to avoidreserved ranges.1654 is1000 + the ASCII values of each of the characters indotnet. 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 funnon-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 theUSER instruction.

I’ll show you what that looks like, using thissample 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_UIDENTRYPOINT ["./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: Arm64OSDescription: Debian GNU/Linux 12 (bookworm)FrameworkDescription: .NET 8.0.4UserName: appHostName : 8da0d81720f8ProcessorCount: 8TotalAvailableMemoryBytes: 4113563648 (3.83 GiB)

As you can see, the application is running as theapp 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-chiseled1654

We have a differentsample Dockerfile that relies on the user being set in these images.

$ cat Dockerfile.chiseled | tail -n 4FROM mcr.microsoft.com/dotnet/runtime:8.0-jammy-chiseledWORKDIR /appCOPY --from=build /app .ENTRYPOINT ["./dotnetapp"]

As you can see, theUSER 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: Arm64OSDescription: Ubuntu 22.04.4 LTSFrameworkDescription: .NET 8.0.4UserName: appHostName : 293212d2eabaProcessorCount: 8TotalAvailableMemoryBytes: 4113563648 (3.83 GiB)

Again, the application is running as theapp 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 anddotnet publish creates non-root images by default.

Kubernetes

Kubernetes has arunAsNonRoot mechanism that is part of itssecurity standards. When set (totrue), Kubernetes will fail loading a pod manifest if a container image isroot.

I think ofrunAsNonRoot 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-app1654

Kubernetes doesn’t usedocker inspect but the idea is the same. It looks at this sameUser value, determines if the value is a UID, and if so does avalue > 0 check. If that expression resolves to true, then therunAsNonRoot check passes. For contextroot has a UID of0, so this check is the analog ofuser != root.

Let’s take a quick look at how non-root works with Kubernetes. There is much more detail inRunning non-root .NET containers with Kubernetes if you want to learn more.

Here’s an example of how to setrunAsNonRoot 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 innon-root.yaml.

It’s really only interesting to see what happens ifrunAsNonRoot is set totrue and we try to load an image that uses theroot users.

At the time of writing, themcr.microsoft.com/dotnet/samples:aspnetapp-chiseled image (used above) is configured as non-root and themcr.microsoft.com/dotnet/samples:aspnetapp isroot. I’ll change theimage value in the manifest tomcr.microsoft.com/dotnet/samples:aspnetapp and then see if the load fails.

$ kubectl apply -f non-root.yamldeployment.apps/dotnet-non-root createdservice/dotnet-non-root created$ kubectl get poNAME                            READY   STATUS                       RESTARTS   AGEdotnet-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 toroot

There may be cases where the user needs to be set toroot. That’s straightforward to do.

It is possible (using Docker) to run a command asroot in a running container withdocker exec -u. The command will be often bebash, but we’ll usewhoami since it offers a better demonstration.

$ docker exec 5d56a4a1cb97 whoamiapp$ docker exec -u root 5d56a4a1cb97 whoamiroot

Note thatkubectl 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 myappHello root, using Debian GNU/Linux 12 (bookworm) on X64

Last, a specific user can be used when building the image, withContainerUser.

$ 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 myappHello root, using Debian GNU/Linux 12 (bookworm) on X64

TheContainerUser 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 myappdocker: 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 myappHello app, using Debian GNU/Linux 12 (bookworm) on X64

As you can see, both theroot andapp 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, withdotnet 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.

Author

Richard Lander
Product Manager

Richard Lander is a Program Manager on the .NET team. He works on making .NET work great in memory-limited Docker containers, on Arm hardware like the Raspberry Pi, and enabling GPIO programming and IoT scenarios. He is part of the design team that defines new .NET runtime capabilities and features. Favourite fantasy: Dune and Doctor Who. He grew up in Canada and New Zealand.

2 comments

Discussion is closed.Login to edit/delete existing comments.

Sort by :

Stay informed

Get notified when new posts are published.
Follow this blog
facebooklinkedinyoutubetwitchStackoverflow