A lab in one file
Everything so far has been one container at a time. What if you want something a bit more realistic? Real targets are not one machine, they are several machines on networks that do not all talk to each other, and the interesting part is usually the path between them.
Docker compose lets you describe a network in a single file. One command brings the whole thing up, one tears it down.
The file
Make a directory, dfh-lab, and put this in compose.yaml:
services:
juice-shop:
image: bkimminich/juice-shop
networks: [public, private]
web-dvwa:
image: vulnerables/web-dvwa
networks: [private]
kali:
build: ./kali
command: tail -f /dev/null
cap_add: [NET_ADMIN, NET_RAW]
networks: [public]
networks:
public:
ipam:
config:
- subnet: 100.126.0.0/27
private:
ipam:
config:
- subnet: 10.10.10.0/27
Alongside it, the kali/Dockerfile from lesson 5. That is the entire lab.
Read the topology out of it, because the topology is the exercise:
- juice-shop is on both networks. It is the internet facing thing, and it is the only route from one subnet to the other.
- web-dvwa is on
privateonly, with no published port. From your host, and from the attacker container, it does not exist. - kali is on
publiconly, with a command that keeps it alive so we can exec into it.
If you have seen older compose files, they start with version: "3.9". That key
is obsolete in Compose v2 and later, which will tell you so on every run. Delete
it.
Up
$ docker compose up -d
Network dfh-lab_public Created
Network dfh-lab_private Created
Container dfh-lab-web-dvwa-1 Started
Container dfh-lab-juice-shop-1 Started
Container dfh-lab-kali-1 Started
Names are <project>-<service>-<number>, and the project defaults to the
directory name. Networks get the same treatment, which is why they are
dfh-lab_public and dfh-lab_private rather than what you called them. You
will need those exact names in a minute.
$ docker compose ps
NAME IMAGE COMMAND SERVICE STATUS PORTS
dfh-lab-juice-shop-1 bkimminich/juice-shop "/nodejs/bin/node /j…" juice-shop Up 3 seconds 127.0.0.1:3000->3000/tcp
dfh-lab-kali-1 dfh-lab-kali "tail -f /dev/null" kali Up 3 seconds
dfh-lab-web-dvwa-1 vulnerables/web-dvwa "/main.sh" web-dvwa Up 3 seconds 80/tcp
Juice Shop is on http://localhost:3000 in your browser. DVWA shows 80/tcp
with no mapping, meaning the port is open inside the lab and unreachable from
your host. That is the point of the exercise.
Drop -d and you get all three containers’ logs interleaved in your terminal,
which is the right way to run it the first time and when something will not
start.
Getting a shell on Kali
$ docker compose exec kali bash
┌──(root㉿a69e3c7cda0a)-[/]
└─# ip -br a
lo UNKNOWN 127.0.0.1/8 ::1/128
eth0@if82 UP 100.126.0.2/27
One interface, on the public subnet, which is a /27. Thirty usable addresses,
so a sweep of the whole thing is quick.
The capability that Kali needs
Try nmap here without that cap_add line and you get a failure that looks
nothing like a permissions problem:
┌──(root㉿a69e3c7cda0a)-[/]
└─# nmap 100.126.0.3
/usr/bin/nmap: 6: exec: /usr/lib/nmap/nmap: Operation not permitted
You are root. The file is executable. It still will not run. The reason is file capabilities:
└─# getcap /usr/lib/nmap/nmap
/usr/lib/nmap/nmap cap_net_bind_service,cap_net_admin,cap_net_raw=eip
Kali’s nmap asks the kernel for cap_net_admin at exec time, Docker’s default
capability set does not include it, and a binary whose permitted set is not a
subset of the bounding set is refused outright. Hence cap_add: [NET_ADMIN, NET_RAW], or --cap-add=NET_ADMIN on a plain docker run. Raw sockets for
SYN scans need NET_RAW, which Docker does grant by default, but add it
explicitly so the file documents what the container needs.
Sweep the subnet you can see
└─# nmap -Pn 100.126.0.0/27
Nmap scan report for 100.126.0.1
Host is up (0.0000030s latency).
PORT STATE SERVICE
22/tcp open ssh
3000/tcp open ppp
MAC Address: AA:C8:BA:51:C1:5E (Unknown)
Nmap scan report for dfh-lab-juice-shop-1.dfh-lab_public (100.126.0.3)
Host is up (0.0000050s latency).
PORT STATE SERVICE
3000/tcp open ppp
Nmap done: 32 IP addresses (3 hosts up) scanned in 202.72 seconds
Two hosts besides ourselves, and the first one deserves a second look.
100.126.0.1 is the gateway, which is your host, with your actual SSH daemon on
it. Every container you run can see it. Nothing about a lab network is a
boundary around your laptop.
The second is Juice Shop, resolvable by name because compose put us on a user-defined network.
DVWA is not in the results, and asking for it directly gets you nowhere:
└─# curl -m 5 http://web-dvwa/
curl: (6) Could not resolve host: web-dvwa
Not a firewall, not a timeout. Docker’s embedded DNS only answers for the networks this container is attached to, and DVWA is not on ours. The name does not exist from here.
The pivot
The route to DVWA is Juice Shop, because it is on both networks. On a real engagement you would earn that by exploiting it. In the lab we can take the shortcut from lesson 6 and simply wear its network stack:
$ docker run --rm --network container:dfh-lab-juice-shop-1 attackbox ip -br a
lo UNKNOWN 127.0.0.1/8 ::1/128
eth0@if84 UP 10.10.10.3/27
eth1@if85 UP 100.126.0.3/27
Two interfaces, one on each subnet. That is what a dual homed host looks like from the inside, and it is exactly what you are hunting for when you land on something.
From there the private side is reachable. Attaching a fresh container directly to the private network shows the same thing without the borrowed stack:
$ docker run --rm --network dfh-lab_private attackbox sh -c 'ip -br a; curl -s -o /dev/null -w "dvwa %{http_code}\n" http://web-dvwa/'
lo UNKNOWN 127.0.0.1/8 ::1/128
eth0@if88 UP 10.10.10.4/27
dvwa 302
302 is DVWA redirecting to its login page. From the attacker container on public,
that host does not resolve. From private, it answers. Same lab, same second,
different network.
Note what made that possible: --network dfh-lab_private on a plain
docker run. Any container can be attached to any network you can name, which
is a fast way to check segmentation in a lab and a very good reason to care who
can run containers on a production host.
Things worth adding to the file
A few compose keys turn this from a demo into a lab you keep:
services:
target:
image: some/target
platform: linux/amd64 # force an architecture on an ARM host
volumes:
- ./loot:/loot # a shared directory you can read from the host
environment:
- DEBUG=1
restart: unless-stopped # survives a laptop reboot
platform is the one you will reach for most on Apple silicon, because a lot of
vulnerable images were only ever built for amd64 and will refuse to start
without it.
Useful commands while a lab is up:
$ docker compose logs -f web-dvwa # follow one service
$ docker compose restart juice-shop # bounce a target you broke
$ docker compose exec kali bash # get back into the container
$ docker compose up -d --build # rebuild changed images and reconcile
You now have a repeatable, segmented, disposable network on a laptop, described by a file you can commit and hand to someone else. Next: taking it apart, and getting your image onto a machine that has no internet.