5/5 - (1 vote)

Microservices and containers for deploying them are standard in large companies. For developers and DevOps engineers, this is a convenient approach: it gives more opportunities and speeds up processes.

But for information security professionals, microservice architecture does not look so rosy. The more containers, the higher the risks. Moreover, most vulnerabilities are inherited from base images.

My name is Sasha Rakhmanny, I am a developer in the information security team at Lamoda Tech. In this article, I’ll compare different .NET base images in terms of component security and performance.

I’ve put together a cheat sheet to help you choose a base image for deploying your application, and also tell you how to reduce the number of packages and vulnerabilities in containers.

Working with vulnerabilities in containers

Let’s imagine that during the development of an application we encountered a problem: during the build stage, a critical vulnerability was discovered in the Docker container. The assembly stopped at the artifact scanning stage.

We went to look at the list of packages with vulnerabilities – and found out that at the moment there are neither new versions nor hotfixes for them to fix problems.

What to do next?

As we can see, it was worth thinking about the risks in advance. Choosing the right base image for your Docker container is key. The security, updates and stability of the application depend on it.

There are several methods to deal with this problem:

  1. Get rid of unused packages. One way to reduce the attack surface is to remove all unnecessary packages from containers during the build phase that may contain vulnerabilities.
  2. Technical protection. Implementation of Web Application Firewall, network segmentation and other solutions that will help protect the system from known vulnerabilities.
  3. Taking risks. In some cases, risks can be accepted if the potential damage from the vulnerability is lower than the cost of fixing it. Knowing about the risk, we can connect additional sensors and set up alerts for events in the monitoring system.
  4. Use of distroless (stripped-down) images. Using minimalistic container images with a limited set of components significantly reduces the number of potential vulnerabilities.

In this article I will focus on the last point. I’ll compare several basic images: I’ll show how the number of packages installed in them affects the number of identified vulnerabilities and performance, and I’ll compare the results.

I suggest comparing images using a test application as an example.

Test application

We use the standard Weather API template as a test application.

In Program.cs we will add the following method to display basic information about the environment:

app.MapGet("/Info", () => new
{
    Username = Environment.UserName,
    OS = Environment.OSVersion,
    IsDevelopment = app.Environment.IsDevelopment(),
    IsProduction = app.Environment.IsProduction(),
    IsStaging = app.Environment.IsStaging(),
    Hosts = File.ReadAllTextAsync("/etc/hosts"),
    Hash = DateTime.UtcNow.ToString("O").GetHashCode()
})
    .WithName("Get Env Information")
    .WithOpenApi();

Container SDK

The official image mcr.microsoft.com/dotnet/sdk:8.0 at the time of writing is built on the basis of a Debian 12 container with the .NET SDK installed and includes:

  • .NET CLI
  • .NET runtime
  • ASP.NET Core

There are also options with Alpine 3.18-3.19, Ubuntu 22.04, CBL-Mariner 2.0.

Microsoft’s documentation recommends using this image only for the development or build phases.

Let’s write a Dockerfile for our demo application using the base image mcr.microsoft.com/dotnet/sdk:8.0 .

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["WebAppDemo/WebAppDemo.csproj", "WebAppDemo/"]
RUN dotnet restore "./WebAppDemo/WebAppDemo.csproj"
COPY . .
WORKDIR "/src/WebAppDemo"
RUN dotnet build "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/build
RUN dotnet publish "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
ENTRYPOINT ["dotnet", "/app/publish/WebAppDemo.dll"]

Let’s assemble and launch the container:

docker build -f WebAppDemo\Dockerfile -t webappdemo:sdk .
docker run -p 8080:8080 --name webapp-sdk -d webappdemo:sdk

Checking the work:

curl http://localhost:8080/info

{"username":"root","os":{"platform":4,"servicePack":"","version":"5.10.102.1","versionString":"Unix 5.10.102.1"},"isDevelopment":false,"isProduction":true,"isStaging":false,"hosts":"127.0.0.1\tlocalhost\n::1\tlocalhost ip6-localhost ip6-loopback\nfe00::0\tip6-localnet\nff00::0\tip6-mcastprefix\nff02::1\tip6-allnodes\nff02::2\tip6-allrouters\n172.17.0.3\t8553cab4aeb7\n","hash":1359993159}

Checking the image size:

docker inspect -f "{{ .Size }}" webappdemo:sdk

Let’s check the number of installed packages in the image:

syft webappdemo:sdk | grep deb

We will also check the number of vulnerabilities:

trivy image webappdemo:sdk

What happened:

Image size

Number of packages

Number of vulnerabilities

Shell

Package Manager

992 MB

119

Total: 104 (UNKNOWN: 0, LOW: 80, MEDIUM: 10, HIGH: 13, CRITICAL: 1)

+

+

To check performance, let’s write a test using Benchmark DotNet:

[IterationCount(200)]
public class RestBenchmark
{
    private HttpClient _client;
    [GlobalSetup]
    public void GlobalSetup()
    {
        _client = new HttpClient();
    }
    [Benchmark]
    public async Task GetInfo()
    {
        var response = await _client.GetAsync("http://localhost:8080/info");
    }
}

And let’s do it by measuring the metrics:

Mean

Container Max Mem Usage

Container Max CPU Usage

434.0 us

307.5 MB

63%

It is worth noting that at the time of writing, the developer has not released a fix for any vulnerability. So a simple apt-upgrade will not help us here.

Pros:

  • Suitable for development and assembly. The SDK image allows you to perform various additional operations, such as taking dumps of a running application via dotnet dump or running unit tests, which can be useful as part of the development and debugging process.

Minuses:

  • Large image size. The SDK image is large due to the included development tools and dependencies. This results in increased loading times and large disk space usage.
  • There are many installed packages with vulnerabilities. Since the image includes many packages and dependencies, this also means that it may have a large number of vulnerabilities. It requires constant monitoring and updating to ensure security.

Multistage with ASP.NET

Microsoft’s recommended method is to use a multi-stage build . The application is built in the mcr.microsoft.com/dotnet/sdk:8.0 container , and at the last stage we copy the artifacts to a new layer based on mcr.microsoft.com/dotnet/aspnet:8.0 with the command COPY --from=publish /app/publish.

This image contains the libraries necessary to run asp core, as well as a shell and a package manager. The base image of aspnet:8.0 is built on Debian 12, there are also versions with Alpine 3.18-3.19, Ubuntu 22.04, CBL-Mariner 2.0.

A Dockerfile for a multi-stage build might look like this:

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["WebAppDemo/WebAppDemo.csproj", "WebAppDemo/"]
RUN dotnet restore "./WebAppDemo/WebAppDemo.csproj"
COPY . .
WORKDIR "/src/WebAppDemo"
RUN dotnet build "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WebAppDemo.dll"]

Additional recommendation – we can create a special user from whom we will run applications without root rights by adding the line:

USER app

Let’s assemble the container, check the work and see how the composition of the image has changed:

Image size

Number of packages

Number of vulnerabilities

Shell

Package Manager

221 MB

92

Total: 70 (UNKNOWN: 0, LOW: 59, MEDIUM: 8, HIGH: 2, CRITICAL: 1)

+

+

Mean

Container Max Mem Usage

Container Max CPU Usage

391.2 us

287 MB

64%

Already better! We have reduced the image size, the number of installed packages, and the number of unpatched vulnerabilities.

Pros:

  • Reducing the image size. Using a separate base image for assembly and execution allows you to include only the necessary components in the final image, which ultimately reduces its size. This saves space and speeds up the deployment process.
  • Fewer vulnerabilities. Using a separate runtime image allows you to include in the final image only the set of packages that are needed to run and run your application.

Minuses :

  • Difficulty in debugging and testing. The execution container lacks the .NET SDK components required for testing and debugging.
  • There are still a large number of packages that are often not needed by the application. And accordingly, a large number of vulnerabilities.

Multistage Chiseled

In the fall of 2023, Microsoft introduced a new type of execution container – .NET Chiseled. These containers are based on Ubuntu 22.04 and are designed to reduce the size and improve the security of images by removing everything except the necessary components. They also offer CVE reduction by reducing the number of components.

Let’s build our application using aspnet:8.0-jammy-chiseled and check the result.

What we immediately notice:

  • No package manager.
  • No sh.
  • No root user.

Dockerfile:

FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled AS base
USER app
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["WebAppDemo/WebAppDemo.csproj", "WebAppDemo/"]
RUN dotnet restore "./WebAppDemo/WebAppDemo.csproj"
COPY . .
WORKDIR "/src/WebAppDemo"
RUN dotnet build "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WebAppDemo.dll"]

Image size

Number of packages

Number of vulnerabilities

Shell

Package Manager

113 MB

7

Total: 6 (UNKNOWN: 0, LOW: 5, MEDIUM: 1, HIGH: 0, CRITICAL: 0)

Mean

Container Max Mem Usage

Container Max CPU Usage

388.7 us

265 MB

66%

Pros:

  • Reduced image size. .NET Chiseled containers are based on Ubuntu 22.04 and contain only the required components, resulting in a significant reduction in size compared to full images.
  • Improved security. By removing all unnecessary components, .NET Chiseled containers provide improved security because it reduces the attack surface and the number of potential vulnerabilities.
  • Lack of package manager, sh and root user. Removing the package manager, sh, and using a non-root user increases the security of the image: it reduces the likelihood that attackers will gain access to system resources and perform malicious actions.

Minuses:

  • Limited functionality. Using Chiseled can be limiting, especially if the application requires additional components or libraries that have been removed from the image.

Installing dependencies and migrating to runtime

There are times when dependent packages need to be installed for an application to function properly.

In the case of a regular image, we would add a command to the Dockerfile, for example:

RUN apt update && apt install -y libgcrypt20 && rm -rf /var/lib/apt/lists/*

But if we try to do this with the 8.0-jammy-chiseled image, we will get an error related to the lack of apt.

There are several options to solve this problem.

The first is to download, unpack and move the files into the final image, example from Microsoft documentation:

# build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0-jammy AS build
...
RUN wget -O somefile.tar.gz <URL> \
    && tar -oxzf aspnetcore.tar.gz -C /somefile-extracted
...

# final stage/image
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-jammy-chiseled
...
COPY --from=build /somefile-extracted .
...

The second option is to install the desired package via APT and copy the files at the last stage.

If the package we need installs many files in different directories or has dependencies itself, we can use the Chisel utility https://github.com/canonical/chisel . Chisel is a tool for working with Debian packages that allows you to create minimal, complementary, and loosely coupled sets of files based on package metadata and content. These filesets, or “slices”, are a collection of Debian packages with their own content and dependencies on other internal and external slices.

Chisel allows you to extract specialized slices of an Ubuntu distribution, which is ideal for creating smaller, but fully functional container images. In addition, the utility allows you to set the root directory using the –root switch, which will help us a lot.

Let’s see what our Dockerfile now looks like with the base image aspnet:8.0-jammy-chiseled, in which we will install libgcrypt20.

FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled AS base
USER app
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["WebAppDemo/WebAppDemo.csproj", "WebAppDemo/"]
RUN dotnet restore "./WebAppDemo/WebAppDemo.csproj"
COPY . .
WORKDIR "/src/WebAppDemo"
RUN dotnet build "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM ubuntu:22.04  AS dependency
RUN apt update && \
    apt install -y ca-certificates wget && \
    mkdir /rootfs && \
    wget -O chisel.tar.gz https://github.com/canonical/chisel/releases/download/v0.9.1/chisel_v0.9.1_linux_amd64.tar.gz \
    && tar -oxzf chisel.tar.gz -C / &&\
    ./chisel cut --root /rootfs libgcrypt20_libs

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
COPY --from=dependency /rootfs /
ENTRYPOINT ["dotnet", "WebAppDemo.dll"]

Pay attention to the dependency phase. We are using the ubuntu:22.04 base image since Aspnet:8.0-jammy-chiseled is built on top of it. Using a newer ubuntu image at this point may cause package compatibility issues.

We create a separate directory rootfs, into which all packages with dependencies will be installed. After that, we download and run the chisel utility, indicating where and which packages need to be installed. A list of available packages can be found at https://github.com/canonical/chisel-releases .

In the last step, we copy the entire contents of /rootfs to the root of the last layer.

Let’s check how the contents of the image have changed compared to the previous build:

Image size

Number of packages

Number of vulnerabilities

Shell

Package Manager

119 MB

7

Total: 6 (UNKNOWN: 0, LOW: 5, MEDIUM: 1, HIGH: 0, CRITICAL: 0)

Runtime-deps + self-package + trimmed

The image mcr.microsoft.com/dotnet/runtime-deps:8.0-jammy-chiseled is a type of distroless container image that contains only the minimum set of packages needed to run .NET applications without the framework installed (self-contained), removing everything the rest.

FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-jammy-chiseled AS base
USER app
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["WebAppDemo/WebAppDemo.csproj", "WebAppDemo/"]
RUN dotnet restore "./WebAppDemo/WebAppDemo.csproj"
COPY . .
WORKDIR "/src/WebAppDemo"
RUN dotnet build "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/publish -p:PublishTrimmed=true --runtime linux-x64 --self-contained true

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["/app/WebAppDemo"]

It’s worth paying attention to the publication parameters:

  • -p:PublishTrimmed=true does not include unused libraries in the build. Problems may occur when using this option. For example, when loading assemblies dynamically through reflection, such applications must be thoroughly tested.
  • –runtime linux-x64 is the target platform.
  • –self-contained true includes .NET platform components in the application, the application can be run on the system without .NET runtime.

Image size

Number of packages

Number of vulnerabilities

Shell

Package Manager

44 MB

7

Total: 6 (UNKNOWN: 0, LOW: 5, MEDIUM: 1, HIGH: 0, CRITICAL: 0)

Mean

Container Max Mem Usage

Container Max CPU Usage

540.7 us

370 MB

76%

Pros:

  • Smaller size. This is a shortened version of the image that saves disk space and allows you to load and deploy the container faster.
  • Reduced number of dependencies. Because the image contains only the minimum set of packages needed to run .NET applications, this reduces the number of packages with possible vulnerabilities.
  • A more secure application. Using PublishTrimmed=true allows you to exclude unused libraries from the build, which reduces the attack surface and reduces the possibility of exploiting vulnerabilities.

Minuses:

  • Possible problems with dynamic loading of assemblies. Using the PublishTrimmed=true option may cause problems when loading assemblies dynamically via reflection. This requires thorough testing of the application to detect and correct possible problems.

Alpine

Alpine Linux is a lightweight Linux distribution that is often used as a base image for Docker containers.

Let’s look at the advantages of Alpine compared to a standard Debian-based image:

  • Size. Alpine is much smaller in size, making it an ideal choice for containers. It contains only a minimal set of libraries and utilities, which helps reduce the size of the image and speed up its loading.
  • Safety. Alpine has a very small attack surface. This means there are fewer vulnerabilities that can be exploited by attackers. This is especially important for containers, which may be susceptible to external attacks.
  • Efficiency: Alpine uses musl libc instead of glibc, which reduces memory consumption and improves performance. This is especially useful for scalable applications.

The Dockerfile for alpine looks like this:

FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS base
USER app
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["WebAppDemo/WebAppDemo.csproj", "WebAppDemo/"]
RUN dotnet restore "./WebAppDemo/WebAppDemo.csproj"
COPY . .
WORKDIR "/src/WebAppDemo"
RUN dotnet build "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WebAppDemo.dll"]

Image size

Number of packages

Number of vulnerabilities

Shell

Package Manager

110 MB

17

Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)

+

+

Mean

Container Max Mem Usage

Container Max CPU Usage

415.3 us

292 MB

65%

CBL-Mariner distroless (Azure Linux)

CBL-Mariner is a native Linux distribution developed by Microsoft for the Azure cloud infrastructure.

Let’s change the Dockerfile to use this base image:

FROM mcr.microsoft.com/dotnet/aspnet:8.0-cbl-mariner-distroless AS base
USER app
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["WebAppDemo/WebAppDemo.csproj", "WebAppDemo/"]
RUN dotnet restore "./WebAppDemo/WebAppDemo.csproj"
COPY . .
WORKDIR "/src/WebAppDemo"
RUN dotnet build "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./WebAppDemo.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WebAppDemo.dll"]

Results:

Image size

Number of packages

Number of vulnerabilities

Shell

Package Manager

121 MB

10

Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)

Mean

Container Max Mem Usage

Container Max CPU Usage

392.5 us

280 MB

64%

Native AOT + alpine

This technology remains crude and not ready for use in production for several reasons:

  • Using Native AOT involves changing the application’s source code, such as explicitly specifying the AppJsonSerializerContext and listing each dto for it.
  • Instead of WebApplication.CreateBuilder, WebApplication.CreateSlimBuilder is used, which does not support MVC, Blazor, SignalR, or many types of authentication.
  • There is no possibility to dynamically include libraries during execution (required trimmed option).
  • After several attempts, I was unable to launch a Native AOT application with an alpine or scratch base image, and using runtime-deps chiseled, with which the application was launched, does not reduce the number of dependencies compared to self-package + trimmed.

conclusions

The choice of base image determines which vulnerabilities the .NET application will inherit from it. The fewer packages, the less risk. In addition, lightweight images with a minimal set of components also increase speed.

In the vast majority of cases, in a production environment, we do not need either a package manager, shell access inside the container, or additional diagnostic utilities. On the contrary, in a development and testing environment we need these tools.

Therefore, there are several recommendations for building and deploying .NET applications as Docker images:

  • When building, always use the latest official base images, as they are regularly rebuilt by the vendor to take into account fixes.
  • In a production environment, use the final image with the fewest dependencies.
  • Check container images at the build stage for critical vulnerabilities.

Which base image should I use to deploy my application?

Situation

sdk

aspnet

aspnet chiseled

runtime-deps chiseled + trimmed

alpine

CBL-Mariner distroless

Application development

Yes

Maybe

No

No

Maybe

No

Unit testing

Yes

No

No

No

No

No

Integration testing

Maybe

Yes

Yes

Yes

Yes

Yes

Pre-Prod/QA

No

Yes

Maybe

Maybe

Maybe

Maybe

Prod

No

Maybe

Yes

Maybe

Maybe

Yes

As we can see from the performance tests, the base images of Asp.Net , Asp.Net Chiseled show approximately the same performance metrics, slightly slower than the SDK and Alpine.

The runtime-deps Chiseled + Self-packaged option showed the worst performance indicators. Although the Apline image does not contain vulnerabilities (Trivy did not find anything, but the grype utility showed 14 medium-level vulnerabilities), it contains a shell and an apk, which increases the surface of a possible attack.