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.
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.
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.
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.
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.
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:
- 80: 8888
Here we have the image name, the container name, a flag saying we can detach after starting the container and some
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 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 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.
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.
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
At any time you can check which plans you have running, and where things are mapped.
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:
Running it would look like this:
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… ;)
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…
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.