TLDR; Now that we have a container image registry, we can leverage the power of Docker Compose to deploy “containers as a configuration” to hosts. Check out the Artifacts section to see some examples.

Disclaimer: If you have not read DevAttackOps Part 1 or Part 2, you should start there. We will be building off the concepts of those posts in this post.

Welcome to part 3 of the DevAttackOps series where I talk about all things regarding Red Team infrastructure automation! If you have stuck around this long, thank you! Let’s start with some basics and the problem we are trying to address.

Multi-Container Problems

We now have an understanding of the value-add we get from containers for Red Team infrastructure. However, let’s revisit the docker run command we use to start a container. To start Cobalt Strike, we would use something like the following:

docker run -it --mount type=bind,source="$(pwd)",target=/opt/cobaltstrike/mount -p "50050:50050/tcp" -p "443:443/tcp" -p "80:80/tcp" -p "53:53/udp" cobaltstrike 192.168.1.1 password /opt/cobaltstrike/mount/c2.profile

When we investigate this docker run command, we can see a lot of complex syntax. Let’s remember, I am an idiot and remembering that command syntax is unrealistic for my tiny brain. I often have to go back to DevAttackOps Part 1 to remember what command I need to use to start Cobalt Strike with all the proper port mappings and bind mounts for Cobalt Strike to operate as I need.

Oh and what if I also wanted to use Sliver on that exact same server? Well now I need to go look up the command I use for Sliver too. Luckily, I am super organized with all my code and it takes me no time at all to find that command (please read with heavy sarcasm). To start Sliver, we would use something like the following:

docker run -it --name sliver -p "3333:3333/tcp" -p "3443:443/tcp" -p "380:80/tcp" -p "353:53/udp" sliver -p 3333

Also, take note of the port mappings I use for Sliver as I am using 2 different command and control frameworks on the same host. Because of this, I now need to change some of the port mappings so that I don’t have port collisions on the host operating system (since both Cobalt Strike and Sliver both have some of the same protocols implemented for C2).

This got me thinking… Wouldn’t it be nice if I could instead of having to remember a complex command and syntax like the two above, and use a single configuration file to define all those options? Yes it would be! And lucky for me, others before me thought of the exact same thing and came up with a solution called “Docker Compose.”

What is Docker Compose?

On the official Docker website, they define Docker Compose as the following:

a tool for defining and running multi-container Docker applications. With Compose, you use a YAML file to configure your application’s services. Then, with a single command, you create and start all the services from your configuration. (https://docs.docker.com/compose/)

This means now I can take the commands above, convert them into a docker-compose.yml file (which is the standard name for a Docker Compose file), use some automation to deploy that docker-compose.yml to a host, and then use the following command to start all the containers (no matter their configuration):

docker-compose up -d

This is so much easier to remember than the two docker run commands I have listed above. But now, we need to figure out how to convert the two commands above into Docker Compose syntax.

Note: You will need to install Docker Compose as it will often not come pre-installed with Docker. Follow the directions to install it by following the official Docker instructions here. Also, some versions of Docker compose use the docker compose syntax while others use the docker-compose syntax. They both operate in the same way, the only difference is the hyphen :)

Compose Syntax

If we look at the Docker Compose getting started page, there are a few examples that we can glean some information from.

version: "3.9"
services:
  web:
    build: .
    ports:
      - "8000:5000"
  redis:
    image: "redis:alpine"

In this example, we are telling Docker to bring up two different containers, one called web and another called redis. In the web container, we are building the container image on the fly using a Dockerfile that is located in the current directory and then mapping the web container TCP port 5000 to the host operating system’s TCP port 8000. With the redis container, we are just pulling an image back from the official Docker registry (this is assumed as there is no full path to a container registry / image).

This seems simple enough. It’s just a YAML file that defines two containers where one gets built on the fly and the other gets pulled from an external registry. Let’s take this example and adapt it to work for just Sliver first.

Converting Docker Run to Docker Compose

In DevAttackOps Part 2 we built out a CI/CD pipeline to automatically build out our container images and publish them to a private registry. Today, we are going to stick with pulling those images from our private registry instead of building the images on the fly like the web container does in the example above. So first, let’s start with Sliver.

Sliver Conversion

To start converting Sliver, we first need to create a docker-compose.yml file.

touch docker-compose.yml

Then, we need to create a key called sliver under the services key inside of our newly created docker-compose.yml file and tell sliver to pull the container image from our private registry we built in Part 2. And just for added ease of use, we will add a container_name to sliver called sliver just to make it easier to label / reference containers once they are running.

version: "3.9"
services:
  sliver:
    container_name: sliver
    image: registry.gitlab.com/ezragit/container-ci_cd/sliver:latest

All we are doing is telling Docker Compose to pull back the image from our private container registry and once the container is started, name it sliver. If we now save our new docker-compose.yml on a host with Docker and Docker Compose already installed and then run docker-compose up -d in the same directory as the docker-compose.yml file, Docker will pull back that image and run it in detached mode (hence the -d).

Remember: You may need to authenticate to your private container registry before running docker-compose up -d (more information exists in Part 2)

Running docker-compose up -d with the sliver container as defined above works just fine, but Sliver requires that you provide runtime arguments to the container to tell it to run in daemon mode and what port to expose for multiplayer mode. Since Docker Compose exposes the runtime arguments as a configuration option, we can easily add our custom runtime arguments using the command key inside of the docker-compose.yml file.

version: "3.9"
services:
  sliver:
    container_name: sliver
    image: registry.gitlab.com/ezragit/container-ci_cd/sliver:latest 
    command: "daemon -p 3333"

Sweet, now the only thing we still need to do is map the ports. Using the same syntax as the web container above, we can create a ports list and convert the docker run port mappings to a YAML list.

version: "3.9"
services:
  sliver:
    container_name: sliver
    image: registry.gitlab.com/ezragit/container-ci_cd/sliver:latest 
    command: "daemon -p 3333"
    ports:
      - "3333:3333/tcp"
      - "3443:443/tcp"
      - "380:80/tcp"
      - "353:53/udp"

Since some of the Sliver ports are TCP and some are UDP, I find it is generally a better practice to be explicit in telling Docker what protocol each port will use, but keep in mind that it will default to TCP. But now that we have our Docker Compose definition for Sliver, we can try it out!

Starting Sliver

Now, in the same directory as our new docker-compose.yml file, we can use the docker-compose up -d command to start Sliver in detached mode.

We can confirm that the container is up by running docker container ls.

And then if we want to stop the container, we can run docker-compose down.

Sweet, it works! Wow, I never thought that would actually work.

Cobalt Strike Conversion

We have Sliver converted, but what if we wanted to convert Cobalt Strike too. For sanity’s sake, let’s start with a fresh docker-compose.yml file to make sure we are isolating any errors to be Cobalt Strike specific.

echo "" > docker-compose.yml

Now that we have a fresh compose file, we will want to follow all the same steps as above, but with the Cobalt Strike docker run command. Doing so will give us the following docker-compose.yml.

version: "3.9"
services:
  cobaltstrike:
    container_name: cobaltstrike
    image: registry.gitlab.com/ezragit/container-ci_cd/cobaltstrike:latest
    command: "192.168.1.1 password /opt/cobaltstrike/mount/c2.profile"
    ports:
      - "1111:50050/tcp"
      - "1443:443/tcp"
      - "180:80/tcp"
      - "153:53/udp"

If we save the docker-compose.yml with that content and then use docker-compose up -d, we will see the Cobalt Strike container start for about 5 seconds and then exit. This is because we haven’t defined the bind mount that will give the container access to the /opt/cobaltstrike/mount/c2.profile file. We can fix this by adding a volumes key inside of cobaltstrike and defining a list item that defines the path to mount inside the container.

version: "3.9"
services:
  cobaltstrike:
    container_name: cobaltstrike
    image: registry.gitlab.com/ezragit/container-ci_cd/cobaltstrike:latest
    command: "192.168.1.1 password /opt/cobaltstrike/mount/c2.profile"
    ports:
      - "1111:50050/tcp"
      - "1443:443/tcp"
      - "180:80/tcp"
      - "153:53/udp"
    volumes:
      - type: bind
        source: /opt/container/cobaltstrike/mount
        target: /opt/cobaltstrike/mount

Before we start the container again, we need to make sure the /opt/container/cobaltstrike/mount/c2.profile file and path exist.

mkdir -p /opt/container/cobaltstrike/mount && vi /opt/container/cobaltstrike/mount/c2.profile

And then just for example’s sake, you can paste the following into c2.profile.

set sample_name "Docker Compose";

http-get {
  set uri "/itstheredteam";
  client {
    metadata {
      netbiosu;
      parameter "tmp";
    }
  }
  server {
    header "Content-Type" "application/octet-stream";
    output {
      print;
    }
  }
}

http-post {
  set uri "/isittheredteam";
  client {
    header "Content-Type" "application/octet-stream";
    id {
      uri-append;
    }
    output {
      print;
    }
  }
  server {
    header "Content-Type" "text/html";
    output {
      print;
    }
  }
}

Now, we can run the docker-compose up -d command and Cobalt Strike should run with the exact same configuration as our docker run command.

Combining the Container Compose Definitions

We now have two working Docker Compose definitions for our Sliver and Cobalt Strike containers. Now let’s combine them! Going back to the example provided from the Docker Compose documentation, we can just combine both the sliver and cobaltstrike container definitions under the services key and that’s it!

version: "3.9"
services:
  sliver:
    container_name: sliver
    image: registry.gitlab.com/ezragit/container-ci_cd/sliver:latest
    command: "daemon -p 3333"
    ports:
      - "3333:3333/tcp"
      - "3443:443/tcp"
      - "380:80/tcp"
      - "353:53/udp"

  cobaltstrike:
    container_name: cobaltstrike
    image: registry.gitlab.com/ezragit/container-ci_cd/cobaltstrike:latest
    command: "192.168.1.1 password /opt/cobaltstrike/mount/c2.profile"
    ports:
      - "1111:50050/tcp"
      - "1443:443/tcp"
      - "180:80/tcp"
      - "153:53/udp"
    volumes:
      - type: bind
        source: /opt/container/cobaltstrike/mount
        target: /opt/cobaltstrike/mount

Now, in the same directory as our new docker-compose.yml file, we can use the docker-compose up -d command to start both Sliver and Cobalt Strike in detached mode.

We can confirm that the containers are up by running docker container ls.

And then if we want to stop both containers, we can run docker-compose down.

And boom, that’s it. Now we can easily take this docker-compose.yml file to any server and use the exact same syntax to start all of our containers!

Artifacts

services:
  cobaltstrike:
    container_name: cobaltstrike
    image: <image_path>
    command: "192.168.1.1 password /opt/cobaltstrike/mount/c2.profile"
    ports:
      - "1111:50050/tcp"
      - "1443:443/tcp"
      - "180:80/tcp"
      - "153:53/udp"
    volumes:
      - type: bind
        source: /opt/container/cobaltstrike/mount
        target: /opt/cobaltstrike/mount

  sliver:
    container_name: sliver
    image: <image_path>
    command: "daemon -p 3333"
    ports:
      - "3333:3333/tcp"
      - "3443:443/tcp"
      - "380:80/tcp"
      - "353:53/udp"
    volumes:
      - type: bind
        source: /opt/container/sliver/mount
        target: /opt/sliver/mount