Przejdź do głównej zawartości

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
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
+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)
PoprawkaZasadaASVS 5.0
is_admin zwraca False przy braku użytkownika i przy błędzie bazyfail closed16.5.3
walidacja wejścia + jedna transakcja with conn:błąd wycofuje całość16.5.3
@app.errorhandler(Exception) + debug=Falseogólny komunikat dla użytkownika, szczegóły w logu16.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):

TestPrzedPo
nieznany token / brak nagłówka200 + lista kont403
przelew na konto 99500, saldo 100 → 70500 z ogólnym komunikatem, saldo bez zmian
przelew bez amount500 z tracebackiem400 invalid request
poprawny przelew 30 z konta 1 na 2—200, salda 70 i 30
nieistniejący adres404404

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​

  1. Przeprowadź kroki 1–3 i zapisz wyniki w formacie raportu z części 4 kursu.
  2. Wdróż poprawki i powtórz te same żądania.
  3. Zadanie dodatkowe: po poprawkach GET /balance/999 nadal zwraca 500. Popraw endpoint tak, by zwracał 404, i zastanów się, czy różnica 404 / 403 przy cudzym koncie nie zdradza, które konta istnieją.
  4. Zadanie dodatkowe: przelew na nieistniejące konto zwraca teraz 500. Zmień to na błąd walidacji (400 lub 422) — sytuacja przewidywalna nie powinna być obsługiwana jako wyjątek nieoczekiwany.
  5. Zadanie dodatkowe: dodaj do /transfer autoryzację (tylko właściciel konta źródłowego) i kontrolę salda. Sprawdź, czy brak tokenu i błąd bazy kończą się odmową.
  6. 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.