Hacking labs
How to Write a Good Pentest Report
Structure a pentest report and write one finding someone else can reproduce, with evidence, impact, a CVSS v4.0 vector and a fix.
Build a Docker network with no internet access, run OWASP Juice Shop on it, and publish a browser copy only on 127.0.0.1. Verify each step.
A lab is only as safe as the paths in and out of it. If your vulnerable app can reach the internet, it can make calls to places you never meant to touch. If it listens on every interface of your machine, anyone on the same Wi-Fi can reach it. Docker gives you control over both paths with a handful of commands. This guide uses OWASP Juice Shop as the target and walks through each one.
You will create a network with no way out, start Juice Shop on it, call the app from a second container, and prove the network cannot reach the internet. Then you will run a separate copy for your browser, published only on the loopback interface, so nothing on your local network can see it. Isolation is the first security control of any lab, and every later exercise, from scanning to exploitation practice, depends on it.
If you have not run Juice Shop yet, start with OWASP Juice Shop with Docker. This article assumes the container basics from that guide.
A security lab runs software built to be broken. Juice Shop has deliberate flaws, and that is the point. The risk is not the app itself. The risk is what happens when a flaw, a misconfiguration or a typo gives the app, or a tool you run against it, a path somewhere else.
Three cases show how this goes wrong:
-p 3000:3000. Docker then accepts connections on every host interface, so other devices on your network can reach the vulnerable app.Isolation covers each case. An internal network has no route out. A port bound to loopback has no path through the LAN. A dedicated network keeps lab containers apart from your other projects. None of these steps is hard. They are easy to forget when you are in a hurry, which is why this guide has you verify each one.
You need three ideas before the commands make sense.
Docker attaches containers to virtual networks. The default driver is bridge. Each container on a bridge network gets its own IP address on a private subnet. On Linux the bridge lives on the host itself. On Windows and macOS, Docker Desktop runs the engine inside a Linux virtual machine, so the bridge lives inside that VM.
Containers on the default bridge can reach each other by IP address but not by name. A bridge network you create yourself with docker network create resolves container names through DNS, using a resolver built into Docker. That is why the tool container in this guide can call http://juice:3000 instead of relying on an IP that changes after a restart.
When you create a network with --internal, Docker restricts external access to it. The Docker documentation describes it this way: containers on an internal network can talk to each other, but not to anything outside. That includes the internet. You will see this in step 4, when a lookup for an outside host fails.
By default, a container's port is not published to other machines. The -p or --publish option changes that. It maps a host address and port to a container port, and the address you choose decides which interfaces accept connections.
-p 3000:3000 listens on all host interfaces, including your LAN address. -p 127.0.0.1:3000:3000 listens on loopback only, so only programs on the same machine can connect. The Docker documentation on port publishing shows this exact form. The difference is one prefix, and it matters.
The lab has five parts:
juicenet, an internal network with no route out.juice, Juice Shop attached only to juicenet, with no published port.curlimages/curl container on juicenet that calls juice by name.example.com, which should fail.juice-local, a second Juice Shop instance published on 127.0.0.1:3000 for your browser.The juice container is the one you isolate. juice-local is a convenience copy for browsing. It runs on the default network, so it can reach the internet. Keep that in mind and do not put real credentials or personal data into the app.
You need:
docker info should print server details without errors. On the machine used for this guide, Docker Engine 29.8.2 was running on Windows 11 through Docker Desktop.-w format strings need different quoting.Check names before you pick them. Other projects on your machine may already use names like juice or juicenet. Run this first:
docker ps -a --format '{{.Names}}'
docker network ls --format '{{.Name}}'If either list shows juice, juice-local or juicenet, choose other names or remove the old ones first. Do not delete anything you did not create.
Also check the image you are about to run. Juice Shop publishes version labels on its image:
docker image inspect bkimminich/juice-shop --format '{{index .Config.Labels "org.opencontainers.image.version"}}'This command fails until the image is local, because the first docker run is what downloads it. On the machine used for this guide, the image reported version 20.3.0. Yours may report another one, so write it down.
docker network create --internal juicenetDocker prints the ID of the new network. Yours will differ. Here is what it printed on the test machine:
44a865311b8b0df7808f81dccd11f4b50ca38800d7a38a6d17bc80fc104dadf9Confirm the flag took effect:
docker network inspect juicenet --format '{{.Internal}}'trueIf the output is false, you created the network without --internal. Remove it with docker network rm juicenet and create it again. Do not continue until you see true, because every later test assumes the flag is on.
docker run -d --name juice --network juicenet bkimminich/juice-shopEach option has a job. -d runs the container in the background. --name juice gives it a DNS name other containers on juicenet can resolve. --network juicenet attaches it only to that internal network. There is no -p, so the container publishes no port to the host.
Docker prints the container ID:
d9f182bbbec2dc8db7dcde02f3e94b054274b6cf8addb1a5f994e9ee23ddebf8The app inside listens on port 3000. Juice Shop needs a while to start after the container is up, so do not test right away. Use a loop that checks whether a tool container gets an HTTP 200:
for i in $(seq 1 30); do
s=$(docker run --rm --network juicenet curlimages/curl -s -o /dev/null -w '%{http_code}\n' http://juice:3000)
echo "try $i: $s"
[ "$s" = "200" ] && break
sleep 3
doneWhile the app starts, the loop prints 000, which means curl could not connect. On the test machine it printed 000 four times and then 200 on the fifth try. Yours may take more or fewer tries. The loop gives up after 30 tries. If you never see a 200, check docker logs juice for errors.
docker run --rm --network juicenet curlimages/curl -s -o /dev/null -w '%{http_code}\n' http://juice:3000200Read each part. --rm deletes the tool container when it exits. --network juicenet puts the tool on the same internal network as the app. curlimages/curl is a small image that contains curl. http://juice:3000 uses the container name, which Docker resolves inside juicenet. The -s flag hides the progress bar, -o /dev/null discards the response body and -w prints only the status code.
This is the pattern you will reuse. A scanner, a proxy or a script joins the lab network and targets the container by name. Nothing on your host has to route to the app.
The image tag here is not pinned, so Docker pulls whatever latest is today. For a lab you plan to repeat, pin the tag once you have tested it.
A passing test only means something when a failing test sits next to it. Step 3 showed the tool container reaches the app, so the network works. Now check the other direction:
docker run --rm --network juicenet curlimages/curl -sS -m 5 https://example.com; echo "exit=$?"curl: (28) Resolving timed out after 5000 milliseconds
exit=28The -m 5 flag caps the request at five seconds. -sS hides the progress bar but keeps errors visible. Exit code 28 is curl's timeout code. The failure happens at name resolution: the lookup for example.com never gets an answer, so curl never opens a connection. That is what you expect from a network with no route out.
This is the result you want. The lab reaches its own containers and does not reach the outside. Save the output of steps 3 and 4 in your lab notes with the date and the Docker version. That record helps when you come back and something has changed.
Your browser cannot resolve the name juice, and the juice container publishes no port, so you cannot reach it from the host. To browse, start a second copy and publish it on loopback:
docker run -d --name juice-local -p 127.0.0.1:3000:3000 bkimminich/juice-shopThe option -p 127.0.0.1:3000:3000 maps port 3000 on the loopback interface to port 3000 inside the container. This copy uses the default bridge, not juicenet, so it is not isolated from the internet. That is a deliberate trade for a browsing copy. If you need something tighter, you have to design and test a separate setup.
The copy takes a moment to start. Use the same kind of loop as before, pointed at the host:
for i in $(seq 1 30); do
s=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/)
echo "try $i: $s"
[ "$s" = "200" ] && break
sleep 3
doneOn the test machine the copy answered with 200 on the tenth try. Now confirm where the port is bound:
docker port juice-local3000/tcp -> 127.0.0.1:3000Next, check the LAN side. Find your machine's IPv4 address on the local network with ipconfig on Windows or ip addr on Linux. Then request the app at that address:
curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://YOUR-LAN-IP:3000/Replace YOUR-LAN-IP with your address. On the test machine this printed 000 and curl exited with code 7, which means it could not connect. The same request to 127.0.0.1 returned 200. That gap is the whole point of binding to loopback.
Now open http://127.0.0.1:3000 in your browser. You should see the Juice Shop home page. Use the app only from that same machine.
Run docker ps to see both containers side by side:
docker ps --filter name=juice --format 'table {{.Names}}\t{{.Ports}}'NAMES PORTS
juice-local 127.0.0.1:3000->3000/tcp
juice 3000/tcpThe juice row shows 3000/tcp with no host address. The port exists inside the network but is not mapped to the host. The juice-local row shows the host binding.
Stop and remove the lab. Containers must go before the network, because Docker refuses to remove a network that still has containers attached:
docker rm -f juice juice-local && docker network rm juicenetjuice
juice-local
juicenetDocker prints the names it removed. Then confirm nothing is left:
docker ps -a --filter name=juice --format '{{.Names}}'
docker network ls --filter name=juicenet --format '{{.Name}}'Both commands should print nothing. Filters match part of a name, so read the output before assuming it is complete, and remove only what you started. Also note that --rm only cleans up the throwaway tool containers. Containers started with -d stay until you remove them, which is why this step exists.
Publishing without an address. -p 3000:3000 listens on every host interface. Use the 127.0.0.1: prefix for any lab app you open from the same machine, and check the result with docker port.
Forgetting --internal. The network works without the flag and nothing warns you. Run docker network inspect juicenet --format '{{.Internal}}' and look for true.
Attaching the app to the default network. A container started without --network joins the default bridge, which reaches the outside. If the app must stay isolated, name the network every time.
Trusting a failed test. A failure in step 4 could come from DNS, a proxy, a VPN or a typo. Step 3 is the control that shows the test is measuring the right thing.
Using the name from the host. The name juice only resolves for containers on juicenet. Your browser cannot use it. That is why the browser copy exists.
Leaving containers running. A background container keeps using resources and keeps its port bound. End each session with the cleanup step and run docker ps afterward.
The internal network option is a control, and it has limits.
It does not stop a tool on juicenet from reaching every other container on that network. Containers on the same network can talk to each other, and that is what makes step 3 work. Keep unrelated services off the lab network.
Loopback publishing protects you from other machines on your LAN. It does not protect you from programs already running on your own computer, because those can connect to 127.0.0.1 too.
The test checks one name and one external host. It does not prove every egress path is closed. Docker behavior can differ between Docker Desktop, Linux engines and rootless setups, so rerun the checks on the machine you actually use.
Finally, this lab only shows that isolation works. It does not show that Juice Shop is safe to expose. It is not, and that is the reason to keep it isolated.
Once the lab works, a few small changes make it more useful:
juicenet, such as a database, and confirm the app reaches it by name. This is how multi-container labs are built.juicenet network is a good first target because you control every container on it.Hacking labs
Structure a pentest report and write one finding someone else can reproduce, with evidence, impact, a CVSS v4.0 vector and a fix.
Hacking labs
Build a home lab you are allowed to attack: a scope file, Docker ports bound to loopback, an isolated network, a notebook and a clean teardown.
Hacking labs
Run OWASP Juice Shop in a Docker container bound to your own machine, confirm it responds, and stop it cleanly.