Running Vulnerable Apps in Docker
Publish a broken-on-purpose app so it can be reached, wall it off from everything it could hurt, and reset it to a clean state in seconds.
Builds on
What you will learn
- -Explain how publishing a port reaches a container, and why the host IP in -p decides who else can reach it
- -Wall a target off with an internal network so a popped app cannot pivot to the LAN or phone home
- -Reset a target to pristine state using the image's read-only layers, and clean up what Docker leaves behind
- -Name the failure modes that bite beginners: a 0.0.0.0 bind, Docker bypassing the host firewall, and volumes that outlive the container
docker-compose closed with docker compose up standing a whole on its own in a single command. That same machinery, a published , a private network, and a one-line teardown, is what turns an ordinary laptop into a safe place to attack software that was broken on purpose.
A practice target is an application shipped with real, exploitable flaws left in on purpose, so people can learn to find and them without breaking into anything they don't own. OWASP Juice Shop is the first one: a working web store seeded with dozens of genuine vulnerabilities. It ships as an image, bkimminich/juice-shop, so a single docker run pulls it from the and starts it.
Running software that is broken on purpose creates two separate , and they point in opposite directions. Anything that can reach the target can attack it, so the target must not be reachable from the wider network. And the target can reach out: a gets network access by default, so a target that gets popped, or an image that was malicious to begin with, can turn around and hit everything the can hit. The of this lesson is the three levers that bound both.
Publishing a port so the target can be reached
A container runs in its own network , one of the kernel features from lesson one. Inside that namespace it has its own interface and its own address, typically something like 172.17.0.2 on Docker's private docker0 bridge. That address means nothing to a browser running on the host. It sits on an isolated bridge that host processes have no route to. For a service inside the container to answer anything outside it, the port has to be published.
The ports key in a compose file did this in the last lesson. docker run does it with -p, and underneath the flag is a rewrite rule. When the publishes container port 3000 on host port 3000, it writes a destination- rule into the host's iptables: traffic arriving at host port 3000 gets its destination rewritten to 172.17.0.2:3000 and forwarded into the container's namespace. A small helper process, docker-proxy, backs up the same mapping for local connections. The container never learns any of this happened.
The full form of the flag is [hostIP:]hostPort:containerPort, and the host IP at the front is the part that decides who can reach the mapping. It is also the part people leave off.
Leave it off and the daemon binds 0.0.0.0, meaning every interface at once: , the Wi-Fi address, the ethernet address, all of them. On a shared network that publishes the deliberately vulnerable app to the whole room. Write 127.0.0.1 in front and the rule matches only traffic that arrived on loopback, so a browser on the same host reaches http://127.0.0.1:3000 and a machine one seat over on the same gets nothing.
$docker run --rm -d --name juice-shop -p 127.0.0.1:3000:3000 bkimminich/juice-shopThe mapping is worth confirming rather than trusting, because the cost of getting it wrong is a live vulnerable app on the LAN. docker port prints exactly what the daemon bound.
$docker port juice-shopThe 127.0.0.1 on the right is the proof. If that column read 0.0.0.0, the app would be answering on every interface the host owns.
Here is the failure mode nobody mentions until it burns them. On a Linux host running the ufw , a rule like ufw deny 3000 looks like it closes the port. It does not close a published Docker port. The daemon inserts its forwarding rules into iptables chains that ufw does not manage, and those rules are consulted before ufw's, so a 0.0.0.0 publish stays reachable from the LAN with the deny rule sitting right there doing nothing. Binding to 127.0.0.1 sidesteps the whole problem, because the NAT rule never matches traffic that arrived from another machine in the first place. This behavior is long-standing and documented, and it has exposed more lab targets than any exploit has.
A target is started with -p 3000:3000 (no host IP) on a laptop joined to a cafe's Wi-Fi, and the laptop runs ufw with 'ufw deny 3000' set. Who can reach the vulnerable app?
Keeping the target from reaching back
Publishing who reaches in. It does nothing about the target reaching out, and by default a container reaches out freely. On the default bridge network the daemon masquerades the container's outbound traffic, rewriting its source so replies find their way back, which means the target can talk to the LAN, the , and the internet exactly as the host can. For ordinary software that is a convenience. For software built to be exploited it is a liability: code that lands execution inside the target can scan the internal network from it, reach the router's admin page, or ship data out. A malicious image does not even need to be attacked first. It can call home the instant it starts.
Three levers cut the outbound path, tightest first.
--network none gives the container no interface at all except loopback. Nothing in, nothing out. This is the right setting for examining something actively hostile, a suspicious binary pulled off a target, where the plan is to docker exec a into the container rather than reach it over a network. The cost is that with no interface there is nothing for a published port to bind to, so a web app run this way cannot be browsed.
An internal network sits in the middle and is the workhorse for a lab. docker network create --internal labnet builds a bridge with no gateway to the outside world. Containers attached to it talk to each other by name, and none of them has a route to the LAN or the internet. This is the compose pattern from the last lesson applied for safety: put the target and a container full of tools on the same internal network, and drive the target from the tools container. The target answers the tools and can call nowhere else.
$docker network create --internal labnet$docker run --rm -d --name juice-shop --network labnet bkimminich/juice-shopLoopback publishing on the default bridge, the very first command in this lesson, is the loosest of the three and the most common. -p 127.0.0.1:3000:3000 keeps the target off the LAN and lets a local browser open it, but it leaves the target's outbound path wide open. That is a fair trade for a mainstream target like Juice Shop from a known publisher. It is the wrong trade for an image pulled from a stranger, where the sealed internal network earns its keep.
Reaching a container from outside is opt-in: nothing answers on the host until a port is published. Reaching out of a container is opt-out: a fresh container has a route to the internet the moment it starts. Beginners guard the first direction and forget the second, which is exactly backwards from where the risk sits with a target.
A target is run with '-p 127.0.0.1:8080:80' on the default bridge network. An attacker gains code execution inside it. What can that code reach?
Resetting and tearing down
The reason a container beats a hand-built virtual machine for drilling the same bug ten times is the reset, and the reset comes straight from how images are built. Lesson two established that an image is a stack of read-only layers. When a container starts, the daemon adds one thin writable layer on top, and every file the running app changes lands in that layer and nowhere else. Remove the container and the writable layer goes with it. The next docker run starts from the untouched image again. No uninstall, no snapshot to roll back to.
--rm automates the removal. The daemon deletes the container the moment it stops, along with any anonymous volumes it created for itself. Leaving --rm off keeps the stopped container around; docker ps -a lists it, and a stop-then-remove clears it by hand.
$docker ps$docker stop juice-shop && docker rm juice-shopThere is one place the reset quietly fails, and it catches people who trust --rm too far. Not every file a container writes lives in the writable layer. If the image declares a volume, or the run mounted a named volume with -v data:/var/lib/mysql, that data sits in a volume, and removing the container does not touch it. Recreate the container and yesterday's database is still there, which reads exactly like the reset did not work. docker volume ls shows the volumes that survived, and docker volume rm clears them. A DVWA target backed by MySQL is the usual place this bites, because its database is the one thing built to persist.
At the end of a session, the images and stray networks pile up. docker system prune removes stopped containers, unused networks, and dangling build leftovers. Adding -a also removes every image not currently tied to a container, which frees real disk space at the price of re-pulling next time. Adding --volumes takes the volumes too. Each flag deletes more, so the command rewards knowing which one is in play.
$docker system prune -aA DVWA target's login state is stored in a named MySQL volume. Someone runs 'docker rm dvwa', starts a fresh container from the same image, and finds the old accounts still there. Why?
A deliberately vulnerable app is legal and smart to stand up. The techniques it teaches are not legal to point at systems owned by someone else. Keep every target bound to loopback or an internal network, so an attack can't leak onto a network that isn't the lab, and keep the practice on local instances or authorized ranges like Hack The Box and TryHackMe.
On a machine with Docker installed, run docker run --rm -d --name juice-shop -p 127.0.0.1:3000:3000 bkimminich/juice-shop, then run docker port juice-shop and confirm the mapping reads 127.0.0.1:3000, not 0.0.0.0.
From a second device on the same network, try to open http://<the-first-machine-ip>:3000. It should fail to connect. That failure is the loopback bind doing its job. Now open http://127.0.0.1:3000 on the machine itself: the app loads.
Tear it down with docker stop juice-shop. Because it ran with --rm, docker ps -a should not list it afterward. Run docker volume ls and note whether anything was left behind. Nothing should be, and knowing how to check is the point.
Loopback binds, internal networks, and a clean teardown keep the target's blast radius on the network small. None of it says anything about whether the container itself holds. The isolation here assumes the wall between container and host stays up, and that assumption is exactly what the next lesson, container-security, takes apart: why a container is not a security boundary, what privileged mode and the mounted Docker hand away, and how inside a container relates to root outside it.