Our Blog

dwn – a docker pwn tool manager experiment

Reading time ~10 min

Years ago I learnt docker basics because I just couldn’t get that $ruby_tool to install. The bits of progress I’d make usually left my host’s ruby install in shambles. With docker though, I had quick reproducible build & run environments I could clean up easily without leaving a mess behind. The more I used docker, the more I’ve come to love it, and today it’s become a natural part of my daily workflow. It’s not without its flaws though, so in this post I want to show you an experiment of mine where I tried to write a docker pwn tool manager. A “docker-compose for hackers” if you will, called dwn (/don/). You can find it here: https://github.com/sensepost/dwn.

I am going to assume some basic docker proficiency here, but if you need help check out this and this.

the pain

Having a Dockerfile for a project is relatively common these days, and often projects will have some Continuous Integration configured to build images on Docker Hub or similar. This is great, as a pre-built image means you can usually just run docker pull <image> or docker run <image> and you are ready to go.

Depending on the tool/software you are running, you may often need to map resources to the container. Port and folder mappings have their own command line flags, and knowing exactly which ports & folders to use can be anything from a simple documentation scan, to a frustrating adventure into finding the Dockerfile to learn which ones to use. Not all containers are equal either. Unless there is an official image on Docker Hub, authors of images for the same software may have different mapping requirements. If my docker run invocation is no longer in my shell history and I can’t remember the mappings, well, off I go hunting again.

Speaking of port mappings, once you started a container with a certain map, it’s done. The only official way to change that is to stop & start the container again. Usually this is fine, especially given the ephemeral nature of containers, but if you have something like metasploit running with already connected shells, the last thing you want to do is stop that container! There are ways to achieve a port remapping though. For example, if you are Linux, some iptables rules can help you map a new port to a container. On macOS this is a different story though given the complexity of the Docker for Mac stack. Alternatively using something like Docker Swarm one could publish ports for services too, but thats not really something I want to configure for my host.

In summary, remembering where everything should be mounted & mapped is hard, and dealing with port mappings for running containers is not great either.

the relief

There are existing solutions to all of the “problems” I have just mentioned. Swarm is one of them, but many of my “pains” are (mostly) resolved using docker-compose and a single .yml file with service definitions of what is mounted where, and which ports to expose. If you are lucky, some projects will include an example service which is great. It’s also the way I do services at home as I wrote in a previous post. For static services a docker-compose.yml file is really all you need, but, it’s when things move around that it becomes cumbersome to manage.

the experiment

Configurations using docker-compose just weren’t dynamic enough when used with pentesting tools for my taste. So I tried to write something based on the idea of docker-compose, but more suited to the few edge cases I wanted. And so dwn was born.

Written in Python and leveraging the Docker Python SDK, dwn tries to be a lightweight wrapper around docker, allowing you to use dockerized tools from any folder, with dynamic port maps and volume mounts.

dwn default help

The docker-compose.yml file equivalent in dwn is called a “plan”. There are built-in plans, but you can roll your own and place them in ~/.dwn/plans. In principle, plans are similar to compose files. Both are YAML formatted, but dwn plans are simpler. All you really need is to specify the image name, version and interaction mode. Volume mounts and port maps are optional, and port mappings can be added after a container is booted.

dwn plans show command output

Let’s take a look at a simple example using nginx. A plan for nginx (which is baked in at the time of writing) could look something like this:

name: nginx
image: nginx
detach: true
volumes:
.:
bind: /usr/share/nginx/html
ports:
- 80: 8888

Here we have the image name, the container name, a flag saying we can detach after starting the container and some volumes and ports maps. If you have used docker-compose before this should look really familiar.

A closer look at the volumes map should reveal a key of .. This dot implies that the current working directory should be bind mounted to /usr/share/nginx/html inside the container. If it were something like data instead of ., it would have meant the data/ directory relative to the current working directory. In contrast to docker-compose, something like a ./ would have meant the directory relative to where the compose file lived; this is not the case for dwn. In dwn context matters, and I have almost always found that I want to have separate contexts based on the current directory I am in.

The port mapping is not that different to anything you have seen before. We are just saying that we want to expose port 8888 on the host, and map that to port 80 in the container. If you wanted to you could leave this out entirely. We will take a look at the dynamic port mapping a little later in this post.

With all of that, when running the nginx plan your current working directory will be served on port 8888. For the following example I have a text file called poo.txt, and started the container with dwn run nginx.

dwn run nginx

Notice how dwn tells you which volume mounts and port bindings it knows about at startup. To test the nginx container is working as expected, I can cURL that text file.

nginx container working and serving the current directory

From the plan we know that port 8888 was mapped into the container. Officially, if we wanted to add another port map you have to stop and start the container again. With dwn though, you can just add the new network map with the dwn network add command.

dwn dynamic network mapping added

Without restarting the container we just added an extra port map to the container! ?

Of course, you can remove it again with the remove command instead of add. The way dwn does the port mapping is by creating a dwn specific docker network and attaching the target plan’s container to it. Next, a new lightweight container running socat is started and attached to the same docker network, specifying the inside container and outside network as the two ends for socat. This effectively exposes a new port in a container to the outside world. Cleanup simply tears down the socat container.

At any time you can check which plans you have running, and where things are mapped.

dwn show listing currently running plans

Another neat thing with the context aware mounts is that for containers such as sqlmap and crackmapexec that use a “dot directory” to store artefacts, these are “written back” to your current working directory outside of the container. So for each client, you have persistent, isolated working environments with logs and more.

For tools that rely on interaction with a shell, we can set the tty: True option in a plan. For the metasploit plan, that could look something like this:

name: msfconsole
image: metasploitframework/metasploit-framework
tty: true
volumes:
.:
bind: /data
ports:
- 4444

Running it would look like this:

dwn starting metasploit

Notice how we had to docker attach after the dwn command. Right now I am not sure how to cleanly attach from the Python SDK, so if you know of a better way… ;)

To demonstrate something a little simpler, let’s run the semgrep-sec plan, which is Semgrep with the security audit ruleset. The plan itself would look something like this:

name: semgrep-sec
image: returntocorp/semgrep
command: --config=p/r2c-security-audit
volumes:
  .:
    bind: /src

With this plan you can basically use dwn run semgrep-sec anywhere to run a static code analysis on your current working directory. An example of running it on some code with a no problems whatsoever (haha) would look something like this…

dwn running the semgrep-sec plan

conclusion

This is an experiment; there are bugs and other odd things, but I am quite excited about the prospect of a “docker-compose for hackers” with a bunch of sane plans included. You can find the source code on Github here: https://github.com/sensepost/dwn.