Minimising your attack surface by building highly specialised docker images — example for .NET Core 2.1 applications

By Paulo Gomes

Photo by Andrew Ruiz on Unsplash

Containers are great for security. They bring to life a dream of running applications in a ‘safe’ sandboxed environment, in which you can apply lots of different security controls, whilst keeping its ‘operational system’ (or base image) to a single purpose. This theory would allow us to remove everything else that isn’t required for the main application to run — right?

Docker images are meant to be ephemeral. So, once it’s created, it should not change at all. We will use this concept to our advantage and remove even the package manager from the image :). The result will be a smaller attack surface, with only a user that can have access to the interactive shell. The filesystem permissions will be reinforced, and all the executables that are not required to run the .NET Core application will be removed.

Please note that hardening your container image is just part of a secure solution. Make sure you also invest time securing your release pipeline, your cluster configuration, and the runtime execution settings (seccomp, SELinux, etc) of your containers.

The entire hardening script will be executed as the last thing within your image building process. The reason for this is to hopefully ensure that the process will tighten potentially unsafe changes made previously.

This will be the last opportunity for you to update the packages within the image, so let’s start with it.

apt update
apt upgrade -y

Create a user to run the container. Remove all other default users, apart from root. Also ensure that only appuser has interactive login shell.

# Add user to run container: appuser
useradd -d /app -s /sbin/nologo -u 1000 appuser
sed -i -r 's/^appuser:!:/appuser:x:/' /etc/shadow
# Remove unnecessary user accounts.
sed -i -r '/^(appuser|root)/!d' /etc/group
sed -i -r '/^(appuser|root)/!d' /etc/passwd
sed -i -r '/^(appuser|root)/!d' /etc/shadow
# Remove interactive login shell for everybody but appuser.
sed -i -r '/^appuser:/! s#^(.*):[^:]*$#\1:/sbin/nologin#' /etc/passwd

Given that this hardening script takes place at the very last step within the image building process, it’s ideal that the permissions are reinforced here.

find $sysdirs -xdev -type d \
-exec chown root:root {} \; \
-exec chmod 0755 {} \;

The white-listed files below can easily be amended. Make sure you only leave things you need, to have the container operational.

# Remove all but a handful of admin commands.
find /sbin /usr/sbin ! -type d \
-a ! -name nologin \
-a ! -name dotnet \
# Remove all but a handful of executable commands.
find /bin /usr/bin ! -type d \
-a ! -name cd \
-a ! -name ls \
-a ! -name sh \
-a ! -name bash \
-a ! -name dir \
-a ! -name rm \
-a ! -name dotnet \
-a ! -name find \
-a ! -name test \

Once the hardening script is executed, playing with a container based on the generated image leads to pretty predictable results. Nothing really can be done. File contents are not accessible. Commands such as cUrl and wget are not available, so content can’t be downloaded from the internet or sent to it by the command line:

Below is a full example, using multi-staged docker images to build a templated .NET web app. Pushes it to a dotnet core 2. 1runtime image, which is then hardened before the dotnet application is executed.

Please note, this approach works like a charm in ‘normal’ linux distros. But if your base image is based in busybox (i.e. Alpine), this won’t work in the same way. In such cases, you will have to re-compile the busybox to only have the commands you want in your base image, or have no linux commands available — but don’t worry, this won’t break your dotnet app ;).

Having a slim, single-purposed docker image means that even if compromised, there would be very few things an attacker could do through the command line.

This does not, however, limit the ability of the application (yours or dotnet) for misbehaving. Nor does it make it harder for an attacker to exploit it — after all, long gone are the days in which we had code access security (CAS) in dotnet.

Other attacks are still feasible through libraries and dependencies in your own application. Shifting your threat vectors to the application, and its release pipeline, which should be another important part to be secured.

  • Ensure you have an automated approach to roll out security patches in both your application dependencies and the packages of your base image Operational System.
  • Scan your dependencies against known-vulnerabilities and track for new CVEs being disclosed. Some tools make it really easy, like Snyk.
  • Add an extra layer of protection with a RASP tool (i.e. Aquasec, gVisor) in your cluster. That allows you to audit and contain anomalous behaviour, before it does anything bad.
  • When deploying your images on Kubernetes, enforce your images to have their filesystems mounted as read-only:
readOnlyRootFilesystem: true

This post was influenced by a gist I stumbled across a while ago. Thank you, Paul, for the inspiration.

Paulo Gomes is a Principal Application Security Engineer at Outside of work, he will be either trying to learn something new or just planning for the next holiday.