~/apparatus/the-web-lab.md
The web lab
These pages are props, not lessons. No tier, no order, no exercise of their own. A lesson that names this page owes you the means, and this page pays.
The law travels with the props. Every command here builds a lab that lives inside one machine you own, in docker networks that touch nothing real. The targets are containers you started, the scanner is a container you started, and the only wire between them is a docker bridge that did not exist until you typed it into being. Nothing on this page leaves your box.
Where the strange addresses come from
The web tier's lessons quote addresses like 172.31.13.2 and 172.31.14.3, and before anything else, this page answers where they live: nowhere until you build them. Those addresses are private network ranges docker assigns when you create your own docker network. docker network create, one command below, mints a fresh subnet, and every container you attach gets a numbered address in it, in start order: usually .1 is the bridge itself, .2 the first container, .3 the second. The exact numbers differ on your machine and between runs, and every lesson that quotes one is quoting its own run's receipt, not a value for you to type.
Two laws follow. When a lesson says 172.31.x.y, read: the address your lab printed when you listed it, with the command at the end of this section. And the reason the lessons use these networks at all, instead of the loopback addresses of earlier tiers: scanner containers and target containers must reach each other without crossing your machine's firewall, and a private docker network is a wire that exists for exactly that.
Find your lab's addresses any time:
$ docker network inspect weblab --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}
{{end}}'
The network, once
One network carries every lab on this page. Create it once per machine:
$ docker network create weblab --subnet 172.31.15.0/24
The subnet is chosen to dodge the numbers the lesson receipts quote, so you can always tell your addresses from the receipts'. Take the whole lab down at the end of a session:
$ docker rm -f zap dvwa shop kaliscan; docker network rm weblab
The target box, DVWA
The deliberately broken site from the Live Fire tier, now aimed at by the web tier's instruments. One container, attached to the network:
$ docker run -d --name dvwa --network weblab vulnerables/web-dvwa
$ docker exec -d dvwa xinetd -stayalive
The second line starts the ftp-style services that image boots lazily, and it is why a fresh container sometimes answers nothing for the first minute. The receipt that the target lives:
$ curl -s http://172.31.15.2/login.php | grep -o "<title>.*</title>"
<title>Login :: Damn Vulnerable Web Application (DVWA) v1.10 *Development*</title>
The lessons that use this box: 050 and 051 aim the proxy at it, 052 crawls it, 054 scans it.
The instrument seat, the scanner container
Kali's tools in a container, on the same wire as the target, so the scanner needs no route through your machine:
$ docker run -d --name kaliscan --network weblab kalilinux/kali-rolling sleep infinity
$ docker exec kaliscan apt-get update
$ docker exec kaliscan apt-get install -y nikto whatweb curl
Install takes a few minutes once, and the container then holds every scanner the tier quotes. Every lesson command that begins docker exec kaliscan is this seat speaking.
The engine seat, ZAP
The proxy engine from lessons 050 through 052, daemon mode, on the same network, with its API opened to the lab wire only:
$ docker run -d --name zap --network weblab -p 127.0.0.1:8090:8080 \
ghcr.io/zaproxy/zaproxy:stable \
zap.sh -daemon -host 0.0.0.0 -port 8080 \
-config api.addrs.addr.name=.* -config api.addrs.addr.regex=true \
-config api.disablekey=true -config view.mode=attack
First boot downloads its add-ons and takes a minute. The API is reachable from your machine at the published port, and from the lab wire at the container's own address, the two lanes lessons 050 and 051 receipted.
The shop
Lesson 055's eleven line store, the one whose bug no scanner can see. It is three files of build and worth every line, because building it is the lesson. Create a directory and save this as app.py:
from flask import Flask, request, jsonify
app = Flask(__name__)
CATALOG = {"mug": 500, "shirt": 2000} # cents
@app.get("/api/item/<name>")
def item(name):
p = CATALOG.get(name)
return jsonify(name=name, price=p) if p else (jsonify(error="no such item"), 404)
@app.post("/api/cart/add")
def add():
d = request.get_json(force=True, silent=True) or {}
name, price, qty = d.get("name"), d.get("price"), d.get("qty", 1)
if name not in CATALOG:
return jsonify(error="no such item"), 400
total = price * qty
return jsonify(name=name, qty=qty, total=total, charged=total)
app.run(host="0.0.0.0", port=5001)
Then run it, attached to the network, with flask installed on first boot:
$ docker run -d --name shop --network weblab -v $PWD:/app:ro -w /app \
python:3.12-slim sh -c "pip install -q flask; python app.py"
Wait twenty seconds for the install, then the receipt that the shop lives:
$ docker exec kaliscan curl -s http://172.31.15.4:5001/api/item/shirt
{"name":"shirt","price":2000}
One honest warning, met while building this page: the pip install and the flask boot race on slower machines, and a curl fired too early answers connection refused. That answer means the shop is still waking, not that the build failed. Wait ten seconds and ask again.
The shop's bug is lesson 055's subject and this page does not spoil it beyond what the lesson already prints. Build it, fire the honest purchase, and let the lesson's forged receipt land on you the way it landed on the page.
The liar
Lesson 056's server, a dozen lines of Python whose whole configuration is one overwritten method: the banner it sends claims to be Apache/2.2.15 (Unix), and no Apache exists anywhere in the container. Save as lie.py beside the shop's directory:
from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler):
server_version = ""
sys_version = ""
def version_string(self):
return "Apache/2.2.15 (Unix)"
def do_GET(self):
body = b"<html><body>honest site</body></html>"
self.send_response(200)
self.end_headers()
self.wfile.write(body)
def log_message(self, *a): pass
HTTPServer(("0.0.0.0", 5002), H).serve_forever()
Run it on the network:
$ docker run -d --name liar --network weblab -v $PWD:/app:ro python:3.12-slim python /app/lie.py
The two blank assignments matter: without them Python appends its own true banner next to the lie, and the lesson's receipts want the lie standing alone. The receipt that the liar lives is whatweb believing it: an Apache where a python is, which is the whole point.
The version game trio
Lesson 059's three servers, one shape, three banner postures. Save each beside the shop's directory as plain.py, quiet.py, and lie.py. The plain server is Python's honest default. The quiet server overrides version_string to return empty, the suppression posture. The liar returns the Apache claim, 056's server with a different port:
from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler):
# plain: use the parent class as-is
# quiet: server_version = "", sys_version = "", version_string -> ""
# lie: server_version = "", sys_version = "",
# version_string -> "Apache/2.2.15 (Unix)"
def do_GET(self):
body = b"<html><body>site</body></html>"
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(body)
def log_message(self, *a): pass
HTTPServer(("0.0.0.0", 5003), H).serve_forever()
For quiet and lie, add the three overrides the comments name, and run one per container on ports 5003, 5004, 5005:
$ docker run -d --name plain59 --network weblab -v $PWD:/app:ro python:3.12-slim python /app/plain.py
$ docker run -d --name quiet59 --network weblab -v $PWD:/app:ro python:3.12-slim python /app/quiet.py
$ docker run -d --name lie59 --network weblab -v $PWD:/app:ro python:3.12-slim python /app/lie.py
The receipt that the trio lives is the lesson's own: whatweb reads three different answers off the same engine, and one curl -X BREW makes all three confess the same error page. The 056 liar section above carries the lie's exact form if you want only that one.
The tarpit
Lesson 061's server, the one that breaks the tools' shared assumption. It answers 200 with one friendly body to every path, the shape of catch-all routes, WAF fronts, and misconfigured error pages. Save as tarpit.py:
from http.server import BaseHTTPRequestHandler, HTTPServer
import time
class H(BaseHTTPRequestHandler):
def _answer(self):
body = b"<html><body>Welcome! Everything is fine.</body></html>"
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
time.sleep(0.02)
self._answer()
def do_POST(self):
time.sleep(0.05)
self._answer()
def log_message(self, *a): pass
HTTPServer(("0.0.0.0", 5007), H).serve_forever()
Run it on the network:
$ docker run -d --name tarpit --network weblab -v $PWD:/app:ro python:3.12-slim python /app/tarpit.py
The receipt that the tarpit lives is three curls: a real path, an admin path, and pure nonsense, all answering 200 and 54 bytes. The lesson then shows what ffuf and nikto report on it, and the friendly page's size, 54, is the calibration number that unwinds the fuzzer's fiction in one -fs flag.