Docker container hijack?
User2:“I’m the captain now.”
It’s wild when you accidentally step upon something intended, yet benign? Something like that happened a few months back when I was volunteering at DEF CON Delhi Group 9111, managing the CTF infrastructure.
The story
As any sane & quick CTF organizer, the approach was simple. Registrations and challenge descriptions hosted on CTFd. Challenges themselves were dockerized and deployed as their own containers inside a Ubuntu Server VM. A nginx reverse-proxy to handle traffic on the basis of FQDN (VHOST). Nothing out of the ordinary. I was working with the team, reviewing challenges, creating Dockerfile
and docker-compose.yml
files for each challenge with one of my teammates and deploying them to test before the day of event.
While at it, I was also approached by a partnering community for helping to host their infrastructure for a similar CTF. I decided to just create a separate user in the Ubuntu Server VM itself, and host the docker challenges in a similar way. Sounds good enough. Until it wasn’t.
The way I named the working folder for challenges according to the category followed by double digits to represent order. web00
, web01
, misc00
and so on. For sake of simplicity, I had set up working space for user1
in /home/user1/CTF_Challs/
and similar for user2
(/home/user2/CTF_Challs/
). The challenge folders were placed inside these folders. And the content of challenge folders were Dockerfile
, docker-compose.yml
files and the challenge related files. If one has to remove the challenge related files, the folders look identical. Both the users were added to docker
group so that they don’t require root privileges to execute containers. Dockerfile
were crafted with care to run entry-point commands as non-privilege users and some more basic security practices which are to be followed while deploying challenges to 1000s of cybersecurity enthusiasts online.
Everything was in place (thankfully dates of the 2 CTFs didn’t overlap) and ready to be tested. I started deploying challenges using docker compose up --build -d
command one by one. Challenges for DEF CON Delhi CTF were up and running. Everything looked great. Went into /home/user1/CTF_Challs/web00
, ran docker compose up --build
, it builds the image, and container starts with name web00-web-1
. When no container name is specified in the docker-compose.yml
file, it’ll default to pick a name based on current working directory and service name in the following format: <current_working_directory>-<service_name>-1
. One by one, I had challenges up and running.
I moved to user2
workspace. Went into web00
, it had same structure, just different ports in the docker-compose.yml
file to host this challenge on a different port. Hit docker compose up --build -d
and done. Container web00-web-1
up and running. Oh wait. Something’s odd here? The final line after building the image in the docker compose status is
Recreating web00-web-1
What do you mean Recreating
? This is the first time I’m trying to start this challenge. Unless it means… Oh. OH.
Went back to check listening ports, and yup. There it was. The web00
challenge from user1
workspace overridden by web00
challenge in user2
. I didn’t expect this to happen. I assumed that different users can spin up separate dockers which won’t interact with each other at least on the host. But seeing the opposite happening got me curious. For the time being, occupied with volunteering duties I didn’t pay much attention to it and for quick solution, I just hosted challenges only the day before the CTF dates. Since the dates were far apart, everything went well (except for few deployment oopsies in DC9111 CTF :p)
The Demo
After the events were concluded, I demonstrated this small exercise to one of my teammates, that how a user can “takeover” or “hijack” other user’s docker container with just simple enumeration and modifications. This however requires very specific conditions to be met:
- Must have access to a privileged user. (User being in the
docker
group becomes somewhat privileged as in default deployment, the docker-daemon runs withroot
privileges, granting some of the root privileges to the users indocker
group as well, described later in this post) - The users must be accessing the same docker daemon.
Steps to Reproduce:
Setup Environment:
- OS:
Ubuntu 24.04.1 LTS
- Docker Engine version:
26.1.3
- Docker Compose version:
2.27.1+ds1-0ubuntu1~24.04.1
- containerd version:
1.7.24
- runc version:
1.1.12-0ubuntu3.1
- docker-init version:
0.19.0
Enumeration:
-
The host system contains two user:
user1
,user2
. Both users are member ofdocker
group, to be able to access the docker daemon and start/stop containers. -
For this PoC, we’ll assume
user1
is the legitimate user running servicewebapp
cloned fromhttps://github.com/mostwanted002/flask-app
(Thanks to wh1t3r0se for lending me the dummy app. forked it XD) -
The
user2
can identify the running service with multiple methods. One of them is breaking down the default container name set by docker compose. Assuming that theuser1
cloned and set upwebapp
with following commands:git clone https://github.com/mostwanted002/flask-app.git cd flask-app docker compose up --build -d # simple web app, serves on http://localhost:3020/
The default container name assigned by docker compose is
<working_directory_>-<service_name>-1
. Here, it’ll result in docker container with nameflask-app-webapp-1
. -
user2
can identify the running container and services by following way:-
docker ps
command, and thendocker inspect
(sinceuser2
is in the docker group. This is intended behavior). This will reveal the working directory as well and service name.... "PortBindings": { "3020/tcp": [ { "HostIp": "0.0.0.0", "HostPort": "3020" } ] } ... "Labels": { "com.docker.compose.config-hash": "aea20f29e2b633942e42d45097089edae6a133163aa3dd28d83d326d193c5a38", "com.docker.compose.container-number": "1", "com.docker.compose.depends_on": "", "com.docker.compose.image": "sha256:5d45d0901c3c541946a88c71141cb214b7db41487bb2930eaf83708d9e81b768", "com.docker.compose.oneoff": "False", "com.docker.compose.project": "flask-app", "com.docker.compose.project.config_files": "/home/user1/flask-app/docker-compose.yml", "com.docker.compose.project.working_dir": "/home/user1/flask-app", "com.docker.compose.replace": "cafdc66cb782a16a68cb20128f24e566787c1aeea51c928c8e203e3766f3c041", "com.docker.compose.service": "webapp", "com.docker.compose.version": "2.27.1" }
-
-
Now
user2
doesn’t even have to get the exact application. They can create a dummy application in a directoryflask-app
, create adocker-compose.yml
, with a servicewebapp
in it. -
user2
malicious app:#!/usr/bin/env python3 # # main.py # A simple webapp to display ("Overtaken by user2") on visiting homepage. from flask import Flask app = Flask(__name__) @app.route("/") def index(): return "Overtaken by user2\n" if __name__ == "__main__": app.run()
# Dockerfile FROM python:3.12 WORKDIR /webapp COPY main.py ./ RUN python3 -m pip install flask gunicorn EXPOSE 3020 CMD gunicorn main:app -b 0.0.0.0:3020
# docker-compose.yml services: webapp: # Important to match it exactly to the existing running service. build: . ports: - "3020:3020"
-
Place these files anywhere in the system in a folder named
flask-app
and run the following commands:docker compose up --build -d
-
Docker compose will re-create the container with new files and app from
user2
, taking over the service running onhttp://localhost:3020
-
Here’s a video PoC:
What else?
Well one can argue that why not just attach to running container with docker exec -it
command? And to that yes, why not. Plenty of ways to go about it. I initially thought this is a bug in the docker compose itself and I reached to the Docker Security team at security@docker.com. (I honestly had doubts about it being a “bug”. It felt more of a “lack of hardening” than a valid bug)
Kudos to the team for reviewing the report quickly and clarifying me on the things around this behavior. Their response:
To follow-up on your report:
The attack you describe needs the following pre-requisites:
- Local user access
- Membership in the docker group
- Shared Docker daemon
By default we manage services via project name (directory name), not user ownership, and our docs describe this:
“If you make a configuration change to a service and run docker compose up to update it, the old container is removed and the new one joins the network under a different IP address but the same name.“source: https://docs.docker.com/compose/how-tos/networking/#update-containers-on-the-network
We hence qualify this as outside of our thread model, given that a user compromise is a pre-requisite to this attack scenario.
We also warn in our docs about the level of privilege the docker group grants.
“The docker group grants root-level privileges to the user. For details on how this impacts security in your system, see Docker Daemon Attack Surface."
How to get out of such scenarios?
To mitigate this behavior, current workarounds are:
- Two instances of docker daemon, isolating one user to one instance. (
user1
todaemon1
, anduser2
todaemon2
) - Running docker-daemon in rootless mode. ( Documentation)
Feel free to reach out to me if you have other ways of mitigate around this.