Lab 20: Capstone — Web Application Penetration Test
Objective
Conduct a complete, structured penetration test against a multi-vulnerability web application from Kali Linux. You will chain together techniques from all 19 previous labs to go from zero knowledge to full account takeover, data exfiltration, and documented report.
Step 1: Environment Setup — Launch the Capstone Target
📸 Verified Output:
Step 2: Phase 1 — Reconnaissance
📸 Verified Output:
Step 3: Phase 2 — SQL Injection Login Bypass
📸 Verified Output:
💡 SQLi → admin token → full admin API access — this is a complete authentication bypass chain. In 2 HTTP requests we went from no credentials to reading every user's password in plaintext.
Step 4: Phase 3 — Privilege Escalation via IDOR
📸 Verified Output:
Step 5: Phase 4 — Data Exfiltration via SQLi
📸 Verified Output:
Step 6: Phase 5 — Business Logic and CSRF Abuse
📸 Verified Output:
Step 7: Phase 6 — Write the Penetration Test Report
Step 8: Cleanup
Capstone Summary
Finding
Severity
CVSS
Lab Reference
SQLi — authentication bypass
CRITICAL
9.8
Lab 03 (A03)
SQLi — search/data exfiltration
CRITICAL
9.1
Lab 03 (A03)
IDOR — read any user profile
HIGH
7.5
Lab 01 (A01)
Business logic — negative qty
HIGH
8.1
Lab 16
Secrets in admin API response
HIGH
7.2
Lab 02 (A02)
Missing CSRF protection
MEDIUM
6.8
Lab 18
Excessive data exposure
MEDIUM
5.3
Lab 04 (A04)
Missing security headers
MEDIUM
5.4
Lab 19
What You've Learned
Across all 20 practitioner labs you have:
Executed real attacks against live Docker containers — no simulated output
Applied every OWASP Top 10 (2021) category against purpose-built vulnerable apps
docker network create lab-a20
cat > /tmp/victim_a20.py << 'PYEOF'
from flask import Flask, request, jsonify, make_response
import sqlite3, hashlib, time, os, hmac
app = Flask(__name__)
DB = '/tmp/capstone.db'
SECRET = b'capstone-weak-secret'
with sqlite3.connect(DB) as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY, username TEXT,
password TEXT, role TEXT, email TEXT, balance REAL);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY, name TEXT, price REAL,
stock INTEGER, internal_cost REAL, supplier TEXT);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY, user_id INTEGER,
product_id INTEGER, qty INTEGER, total REAL, status TEXT, ts REAL);
CREATE TABLE IF NOT EXISTS coupons (
code TEXT PRIMARY KEY, discount REAL, single_use INTEGER, used INTEGER DEFAULT 0);
INSERT OR IGNORE INTO users VALUES
(1,'admin','admin','admin','[email protected]',9999.0),
(2,'alice','alice123','user','[email protected]',500.0),
(3,'bob','bob456','user','[email protected]',200.0);
INSERT OR IGNORE INTO products VALUES
(1,'Surface Pro 12',864.0,10,410.0,'TaiwanCo'),
(2,'Surface Pen',49.0,100,12.0,'StylusCorp'),
(3,'Surface Arc Mouse',99.0,5,38.0,'MouseMakers');
INSERT OR IGNORE INTO coupons VALUES
('SAVE20',20.0,0,0),('VIP100',100.0,1,0);
""")
def db(): c=sqlite3.connect(DB); c.row_factory=sqlite3.Row; return c
# VULN 1: SQLi login
@app.route('/api/login', methods=['POST'])
def login():
d = request.get_json() or {}
u, p = d.get('username',''), d.get('password','')
try:
row = db().execute(
f"SELECT * FROM users WHERE username='{u}' AND password='{p}'"
).fetchone()
if row:
return jsonify({'token':f'tok_{row["username"]}','role':row['role'],'user':dict(row)})
return jsonify({'error':'Invalid credentials'}),401
except Exception as e:
return jsonify({'error':str(e)}),500
# VULN 2: IDOR — user sees any other user's data
@app.route('/api/user/<int:uid>')
def user(uid):
# BUG: no auth check — any token can read any user
token = request.headers.get('X-Token','')
if not token: return jsonify({'error':'No token'}),401
row = db().execute('SELECT id,username,email,balance,role FROM users WHERE id=?',(uid,)).fetchone()
return jsonify(dict(row)) if row else (jsonify({'error':'Not found'}),404)
# VULN 3: excessive data exposure — internal fields in product list
@app.route('/api/products')
def products():
rows = db().execute('SELECT * FROM products').fetchall()
# BUG: returns internal_cost and supplier — should be user-facing fields only
return jsonify([dict(r) for r in rows])
# VULN 4: SQLi in product search
@app.route('/api/search')
def search():
q = request.args.get('q','')
try:
rows = db().execute(
f"SELECT * FROM products WHERE name LIKE '%{q}%'"
).fetchall()
return jsonify([dict(r) for r in rows])
except Exception as e:
return jsonify({'error':str(e)}),500
# VULN 5: negative quantity (business logic)
@app.route('/api/order', methods=['POST'])
def order():
d = request.get_json() or {}
uid,pid,qty = d.get('user_id',1),d.get('product_id',1),d.get('qty',1)
c = db()
usr = c.execute('SELECT * FROM users WHERE id=?',(uid,)).fetchone()
prd = c.execute('SELECT * FROM products WHERE id=?',(pid,)).fetchone()
if not usr or not prd: return jsonify({'error':'Not found'}),404
total = prd['price'] * qty # BUG: no qty>0 check
new_bal = usr['balance'] - total
c.execute('UPDATE users SET balance=? WHERE id=?',(new_bal,uid))
c.execute('INSERT INTO orders VALUES (NULL,?,?,?,?,?,?)',(uid,pid,qty,total,'completed',time.time()))
c.commit()
return jsonify({'order_total':total,'new_balance':new_bal})
# VULN 6: CSRF — transfer with no token
@app.route('/api/transfer', methods=['POST'])
def transfer():
d = request.get_json() or {}
sess = {'tok_alice':{'user':'alice','id':2,'balance':500.0},
'tok_admin':{'user':'admin','id':1,'balance':9999.0}}.get(d.get('session',''))
if not sess: return jsonify({'error':'Not logged in'}),401
amount = float(d.get('amount',0))
sess['balance'] -= amount
return jsonify({'transferred':amount,'to':d.get('to',''),'remaining':sess['balance'],
'note':'No CSRF token checked'})
# VULN 7: missing security headers (root has none)
@app.route('/')
def index():
return jsonify({'app':'InnoZverse Capstone','version':'1.0.0',
'server':'Flask/Werkzeug','python':'3.10',
'endpoints':['/api/login','/api/user/<id>','/api/products',
'/api/search','/api/order','/api/transfer','/api/admin']})
# VULN 8: admin panel accessible with any admin token (no rate limiting)
@app.route('/api/admin')
def admin():
token = request.headers.get('X-Token','')
if not token.startswith('tok_admin'):
return jsonify({'error':'Forbidden'}),403
users = [dict(r) for r in db().execute('SELECT * FROM users').fetchall()]
return jsonify({'message':'Admin panel','users':users,'db_path':DB,
'secret_key':SECRET.decode(),'note':'All user data exposed'})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)
PYEOF
docker run -d \
--name victim-a20 \
--network lab-a20 \
-v /tmp/victim_a20.py:/app/victim.py:ro \
zchencow/innozverse-cybersec:latest \
python3 /app/victim.py
sleep 4
VICTIM_IP=$(docker inspect -f '{{.NetworkSettings.Networks.lab-a20.IPAddress}}' victim-a20)
echo "Target: http://$VICTIM_IP:5000"
curl -s http://$VICTIM_IP:5000/ | python3 -m json.tool