Praktyczne ćwiczenie: Testowanie i mitigacja – Mishandling of Exceptional Conditions (A10:2025)
Cel ćwiczenia
Znaleźć trzy błędy obsługi sytuacji wyjątkowych w małej aplikacji bankowej, wykorzystać je, a potem poprawić kod i potwierdzić poprawkę tymi samymi testami.
Ćwiczenie pokrywa scenariusze nr 2 (wyciek informacji w błędzie) i nr 3 (przerwana transakcja) z opisu OWASP oraz CWE-636 (fail open) z listy CWE kategorii.
Przygotowanie środowiska
Aplikacja jest celowo podatna — uruchamiaj ją tylko lokalnie. Tryb debug Flaska (Werkzeug) udostępnia w przeglądarce interaktywną konsolę, która pozwala wykonać dowolny kod na serwerze, chronioną tylko PIN-em.
Zapisz kod jako app.py i uruchom (wymagany Python 3 i Flask):
python3 -m venv venv && . venv/bin/activate
pip install flask
python app.py
import sqlite3
from flask import Flask, request, jsonify
app = Flask(__name__)
DB = "bank.db"
def db():
conn = sqlite3.connect(DB)
conn.row_factory = sqlite3.Row
return conn
def init():
with db() as c:
c.executescript("""
DROP TABLE IF EXISTS accounts; DROP TABLE IF EXISTS users;
CREATE TABLE accounts (id INTEGER PRIMARY KEY, owner TEXT, balance INTEGER);
CREATE TABLE users (token TEXT PRIMARY KEY, name TEXT, role TEXT);
INSERT INTO accounts VALUES (1, 'alice', 100), (2, 'bob', 0);
INSERT INTO users VALUES ('t-alice', 'alice', 'user'), ('t-admin', 'root', 'admin');
""")
def is_admin(token):
# BŁĄD 1: fail open - wyjątek w kontroli kończy się przepuszczeniem
try:
row = db().execute("SELECT role FROM users WHERE token = ?", (token,)).fetchone()
return row["role"] == "admin"
except Exception:
return True
@app.get("/admin/accounts")
def admin_accounts():
if not is_admin(request.headers.get("X-Token", "")):
return jsonify(error="forbidden"), 403
rows = db().execute("SELECT * FROM accounts").fetchall()
return jsonify([dict(r) for r in rows])
@app.post("/transfer")
def transfer():
data = request.get_json()
amount = int(data["amount"])
conn = db()
# BŁĄD 2: dwa osobne commity - błąd w połowie zostawia stan częściowy
conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (amount, data["from"]))
conn.commit()
if conn.execute("SELECT 1 FROM accounts WHERE id = ?", (data["to"],)).fetchone() is None:
raise ValueError("unknown destination account " + str(data["to"]))
conn.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, data["to"]))
conn.commit()
return jsonify(status="ok")
@app.get("/balance/<int:acc>")
def balance(acc):
row = db().execute("SELECT balance FROM accounts WHERE id = ?", (acc,)).fetchone()
return jsonify(balance=row["balance"])
if __name__ == "__main__":
init()
# BŁĄD 3: tryb debug - pełny traceback w odpowiedzi
app.run(port=5000, debug=True)
Krok 1: Fail open w autoryzacji
Endpoint /admin/accounts ma być dostępny tylko dla administratora.
curl -i -H "X-Token: t-alice" http://localhost:5000/admin/accounts # zwykły użytkownik
curl -i -H "X-Token: t-admin" http://localhost:5000/admin/accounts # administrator
curl -i -H "X-Token: nieznany" http://localhost:5000/admin/accounts # token, którego nie ma w bazie
curl -i http://localhost:5000/admin/accounts # brak nagłówka
Co się dzieje: dla nieistniejącego tokenu fetchone() zwraca None, a odwołanie row["role"] rzuca wyjątek. Ogólny except Exception zwraca True — błąd kontroli kończy się przepuszczeniem. W naszym teście dwa ostatnie żądania dostały 200 i pełną listę kont, choć zwykły użytkownik dostaje 403.
To jest właśnie CWE-636 (Not Failing Securely): napastnik nie musi znać żadnego tokenu — wystarczy, że wywoła błąd.
Krok 2: Przerwany przelew
curl -s http://localhost:5000/balance/1
curl -i -X POST -H "Content-Type: application/json" \
-d '{"from":1,"to":99,"amount":30}' http://localhost:5000/transfer
curl -s http://localhost:5000/balance/1
Co się dzieje: obciążenie konta źródłowego jest zatwierdzone (commit) przed sprawdzeniem konta docelowego. Przelew na nieistniejące konto kończy się błędem 500, ale saldo Alice spada ze 100 do 70 — pieniądze zniknęły. To scenariusz nr 3 z opisu OWASP: brak wycofania całej transakcji.
Krok 3: Wyciek informacji w błędzie
curl -s -X POST -H "Content-Type: application/json" \
-d '{"from":1,"to":2}' http://localhost:5000/transfer | grep -o "KeyError[^<]*"
Co się dzieje: brak pola amount kończy się nieobsłużonym KeyError. W trybie debug odpowiedź zawiera pełny traceback: nazwy wyjątków, fragmenty kodu i bezwzględną ścieżkę do pliku aplikacji na serwerze. To rekonesans dla napastnika (CWE-209) — i naruszenie ASVS 16.5.1.
✅ Mitigacja
Poprawki w formie diffa:
@@ -1,5 +1,6 @@
import sqlite3
from flask import Flask, request, jsonify
+from werkzeug.exceptions import HTTPException
app = Flask(__name__)
DB = "bank.db"
@@ -23,12 +24,13 @@
def is_admin(token):
- # BŁĄD 1: fail open - wyjątek w kontroli kończy się przepuszczeniem
+ # POPRAWKA 1: brak użytkownika i każdy błąd kończą się odmową (fail closed)
try:
row = db().execute("SELECT role FROM users WHERE token = ?", (token,)).fetchone()
- return row["role"] == "admin"
- except Exception:
- return True
+ except sqlite3.Error:
+ app.logger.exception("authorization check failed")
+ return False
+ return row is not None and row["role"] == "admin"
@app.get("/admin/accounts")
@@ -41,19 +43,32 @@
@app.post("/transfer")
def transfer():
- data = request.get_json()
- amount = int(data["amount"])
+ # POPRAWKA 2: walidacja na wejściu i jedna transakcja - błąd wycofuje całość
+ data = request.get_json(silent=True) or {}
+ try:
+ amount, src, dst = int(data["amount"]), int(data["from"]), int(data["to"])
+ except (KeyError, TypeError, ValueError):
+ return jsonify(error="invalid request"), 400
+ if amount <= 0:
+ return jsonify(error="invalid request"), 400
conn = db()
- # BŁĄD 2: dwa osobne commity - błąd w połowie zostawia stan częściowy
- conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (amount, data["from"]))
- conn.commit()
- if conn.execute("SELECT 1 FROM accounts WHERE id = ?", (data["to"],)).fetchone() is None:
- raise ValueError("unknown destination account " + str(data["to"]))
- conn.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, data["to"]))
- conn.commit()
+ with conn: # commit przy sukcesie, rollback przy wyjątku
+ conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (amount, src))
+ if conn.execute("SELECT 1 FROM accounts WHERE id = ?", (dst,)).fetchone() is None:
+ raise ValueError("unknown destination account")
+ conn.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, dst))
return jsonify(status="ok")
+# POPRAWKA 3: handler "ostatniej szansy" - szczegóły do logu, użytkownik dostaje ogólny komunikat
[email protected](Exception)
+def last_resort(e):
+ if isinstance(e, HTTPException): # 404, 405 itd. zostają bez zmian
+ return e
+ app.logger.exception("unhandled error")
+ return jsonify(error="internal error"), 500
+
+
@app.get("/balance/<int:acc>")
def balance(acc):
row = db().execute("SELECT balance FROM accounts WHERE id = ?", (acc,)).fetchone()
@@ -62,5 +77,4 @@
if __name__ == "__main__":
init()
- # BŁĄD 3: tryb debug - pełny traceback w odpowiedzi
- app.run(port=5000, debug=True)
+ app.run(port=5000, debug=False)
| Poprawka | Zasada | ASVS 5.0 |
|---|---|---|
is_admin zwraca False przy braku użytkownika i przy błędzie bazy | fail closed | 16.5.3 |
walidacja wejścia + jedna transakcja with conn: | błąd wycofuje całość | 16.5.3 |
@app.errorhandler(Exception) + debug=False | ogólny komunikat dla użytkownika, szczegóły w logu | 16.5.1, 16.5.4 |
Handler „ostatniej szansy" przepuszcza wyjątki HTTP (404, 405) bez zmian — bez tego każdy nieistniejący adres zwracałby 500.
✅ Oczekiwane zachowanie po naprawie
Ten sam zestaw żądań po wdrożeniu poprawek (sprawdzone na Flasku uruchomionym lokalnie):
| Test | Przed | Po |
|---|---|---|
| nieznany token / brak nagłówka | 200 + lista kont | 403 |
| przelew na konto 99 | 500, saldo 100 → 70 | 500 z ogólnym komunikatem, saldo bez zmian |
przelew bez amount | 500 z tracebackiem | 400 invalid request |
| poprawny przelew 30 z konta 1 na 2 | — | 200, salda 70 i 30 |
| nieistniejący adres | 404 | 404 |
Poprawki dotyczą wyłącznie obsługi sytuacji wyjątkowych. /transfer celowo nadal nie sprawdza, czy wywołujący jest właścicielem konta (A01:2025) ani czy saldo wystarcza — to nie jest kod do skopiowania na produkcję.
✅ Zadania do wykonania
- Przeprowadź kroki 1–3 i zapisz wyniki w formacie raportu z części 4 kursu.
- Wdróż poprawki i powtórz te same żądania.
- Zadanie dodatkowe: po poprawkach
GET /balance/999nadal zwraca500. Popraw endpoint tak, by zwracał404, i zastanów się, czy różnica404/403przy cudzym koncie nie zdradza, które konta istnieją. - Zadanie dodatkowe: przelew na nieistniejące konto zwraca teraz
500. Zmień to na błąd walidacji (400lub422) — sytuacja przewidywalna nie powinna być obsługiwana jako wyjątek nieoczekiwany. - Zadanie dodatkowe: dodaj do
/transferautoryzację (tylko właściciel konta źródłowego) i kontrolę salda. Sprawdź, czy brak tokenu i błąd bazy kończą się odmową. - Uruchom OWASP Juice Shop i rozwiąż wyzwanie Error Handling — WSTG wskazuje je jako miejsce do ćwiczenia testu WSTG-ERRH-01.
Wnioski
- Najgroźniejszy błąd w tej aplikacji nie jest widoczny w kodzie jako „podatność" — to zwykły
try/except, który przy błędzie wybiera złą stronę. - Każdą kontrolę bezpieczeństwa trzeba przetestować nie tylko z danymi poprawnymi i niepoprawnymi, ale też z danymi, które ją wysypią.
- Transakcja w bazie to kontrola bezpieczeństwa, nie tylko mechanizm spójności danych.
To ostatnia kategoria OWASP Top 10:2025. W następnej części: Raport bezpieczeństwa — jak udokumentować wyniki testów.