A container ship

There is no doubt that the inception of Docker changed the way we deploy and run services in production.

Before Docker’s rise in popularity, services would be installed on a host. Whether it was a physical or virtual host was more often than not a capacity question.

When virtual machines hit the streets, they promised the security benefit of “isolation”. This made it cost effective to run a single service per virtual machine, with many virtual machines per physical host.

Before virtual machines, it was common to run as many services on the same host as we could fit. This cut down the cost of buying and running a new physical host for each service. Having many services running on the same host, however, created security holes. It created situations where a vulnerability in one service could allow access to another service on the same host. By isolating services onto a single virtual machine, we could eliminate those scenarios.

Eventually, however, there was a realization. While a virtual machine does offer some isolation, it still needs to be secured like a physical host. An attacker gaining privileged access to a virtual machine was just as bad as accessing a physical host, even if services were isolated within their own virtual machines.

Like virtualization, Docker brings a similar layer of isolation, allowing you to deploy an application within a very small OS runtime—a runtime smaller than a virtual host. This reduces the exposure of the runtime environment, which lends itself to the security principle of “only what is needed to run.”

However, as with the isolation provided by virtualization, that doesn’t mean we can ignore security best practices. In this article we are going to talk about one of the most well-understood security practices. Specifically, we will discuss running applications as a non-privileged user, but within a container.

It’s easy to forget

Running applications or services as a non-privileged user is commonplace. It has been practiced for years. When the runtime for an application is a full OS installation, it is easy to understand the risks of running as root.

With Docker, however, it’s not as straightforward to understand or even see because Docker abstracts away much of the runtime environment from end users. Thus it is easy to forget basic security concepts, such as ensuring the runtime user has the fewest privileges possible. To better understand this, let’s take a look at the following Dockerfile.

FROM ubuntu:latest  
RUN apt-get update --fix-missing && \  
   apt-get install -y redis-server && \  
   rm -rf /var/lib/apt/lists/*  
EXPOSE 6379  
CMD redis-server

This produces a bare-bones Redis container. Let’s run this container overriding the CMD instruction with the whoami command.

$ docker run --rm example whoami  
root

When executed, the whoami command will return the user executing it. In the example above, it returned root. This is because within our Dockerfile we never specified a “user” to run as. This means that our service ran as the current user for the Ubuntu image. That user happens to be root.

As with the Ubuntu base image, most base images have the current user set to root. The reason for this is simple: base images are usually used as a “base” from which to build other Docker containers.

As in our Redis example, most users take these base images and then install packages on top of them. This installation step requires root privileges, which is why most base images default to root.

Unless we are specifically thinking about the runtime user, it is very easy for a service to inadvertently run as root. The security implications of this are as serious as a root user-owned service running on a full OS.

If the container is isolated, why does it matter?

Remote command execution is a common method used by attackers. When an attacker can use this against a service running as root, it means any execution of code will also be root.

Many times when we think of a host being “rooted,” we think about other services on that host being exposed. This is something that container isolation has certainly made more secure. Since our service is isolated within a container, it becomes more difficult to attack other services on the same host.

I say “more difficult” because it is still possible for an attacker to exploit other services on the same host. Isolation prevents the attacker from using root privileges to exploit those services directly, but it does not prevent the attacker from exploiting those services via the network.

An exploited service can be an entry point for an attacker, whether that service is running as root or not. However, a root owned service makes it easier for an attacker to explore and identify other services.

If an attacker were to install a command such as nmap (not typically installed by default), they could use this tool to find other vulnerable services.

Nmap is a common security tool that is used to “explore” networks. A powerful scan option used with the nmap command is -sS. This option tells nmap to use SYN packets to scan network targets.

To generate these SYN packets, nmap must be running as root. Without root privileges, the nmap command is limited to less informative scanning techniques. Let’s see this in action by running the nmap command with the container we created above.

$ docker run --rm example nmap -sS -p 6379 localhost          
Starting Nmap 7.01 ( https://nmap.org ) at 2018-01-01 19:01 UTC  
Nmap scan report for localhost (127.0.0.1)  
Host is up (0.000089s latency).  
Other addresses for localhost (not scanned): ::1  
PORT     STATE  SERVICE  
6379/tcp closed unknown  

Nmap done: 1 IP address (1 host up) scanned in 0.39 seconds

Now what happens when that same command is executed but we tell Docker to run it as an unprivileged user using the -u flag?

$ docker run --rm -u 9000 example nmap -sS -p 6379 localhost  
You requested a scan type which requires root privileges.  
QUITTING!

This is a very simple but very real example of why running as root can create vulnerabilities. If an attacker were able to use root access to install a tool such as nmap and execute a SYN scan, this information could be used to attack other services.

While using the vulnerable service as an entry point for attack is possible with or without root, running a service as root makes it easier for attackers to make those attacks more effective.

How to reduce privileges in a container

Docker provides a few methods of downgrading user privileges within a running container. We used one method above—simply using the -u flag to specify the user at runtime.

$ docker run --rm -u 9000 example

In the command above, we specified the user as 9000, which isn’t a username, but rather a user ID. With Docker’s -u flag, you can specify either a username or a user ID. Note that when specifying a user ID, a corresponding user does not have to exist.

The user ID used in our example, 9000, is an arbitrary user ID that has no special privileges. In accordance with Linux standards, user IDs between 0 and 499 tend to be reserved. It therefore is advisable to use a user ID with a value greater than 499 to avoid unintentionally running as a default system user.

The -u flag is a nice simple method for downgrading user privileges; however, this only applies when executing the docker run command. What happens if the flag is forgotten? We are right back to running the service as root.

Using the USER instruction

Another method of downgrading user privileges is to use the USER instruction. We can use this instruction within our Dockerfile to change the runtime user during image build. Let’s see this in action.

Earlier in the article we used the following Dockerfile to create a Redis container.

FROM ubuntu:latest  
RUN apt-get update --fix-missing && \  
   apt-get install -y redis-server && \  
   rm -rf /var/lib/apt/lists/*  
EXPOSE 6379  
CMD redis-server

We need to be root in order to execute the apt-get commands on line 2. We do this by adding the USER instruction immediately following the RUN instruction.

FROM ubuntu:latest  
RUN apt-get update --fix-missing && \  
   apt-get install -y redis-server && \  
   rm -rf /var/lib/apt/lists/*  
USER 9000  
EXPOSE 6379  
CMD redis-server

This will change the runtime user to user ID 9000, which means any action after the USER instruction, including CMD, will be executed as that user.

Let’s see how this changes our previous whoami example.

$ docker run --rm example whoami  
whoami: cannot find name for user ID 9000

As you can see, the whoami command was executed as user ID 9000, which of course doesn’t really exist. This method of downgrading user privileges is a bit better as it adds consistency. Unless the -u flag is used, the resulting container from this Dockerfile will run as user ID 9000 every time.

Summary

In this article we explored how easy it is to create a Docker container that runs its services as the root user. We also explored how doing so can lead to serious security vulnerabilities.

Luckily, the solution is as simple as adding a single line to our Dockerfile.

USER 9000 

This article focused on the Docker practice of specifying a runtime user. Docker security does not stop there, however. There are many more practices for securing Docker containers that are just as important. A good place to start learning more about Docker security is Docker’s own documentation.