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.
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/plan
s. 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:
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
.
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.
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 socat
container.
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:
name: msfconsole
image: metasploitframework/metasploit-framework
tty: true
volumes:
.:
bind: /data
ports:
- 4444
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… ;)
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…
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.