OWASP Top 10:2025 Course — Module 10 of 10 (final module). Course home · Previous: Module 9 — Security Logging Failures

Learn first: what is Mishandling of Exceptional Conditions

A10:2025 is about what happens after something goes wrong — an error, a malformed request, an unexpected input. This module packs three separate failure modes into one small bank app, all stemming from the same root cause: exceptions that don’t get handled with cleanup, containment, and a safe response.

1) Resource leak:            upload bad data ──▶ crash before releasing
                              the processing slot ──▶ slot leaked FOREVER
                              x3 ──▶ pool exhausted ──▶ legitimate users get 503

2) Information leak:         malformed request ──▶ unhandled exception
                              ──▶ debug=True ──▶ full stack trace + file
                              paths sent straight to the client

3) Data integrity:           transfer crashes mid-way ──▶ alice already
                              debited, bob never credited ──▶ no rollback
                              ──▶ money has vanished from the ledger

The fix for all three is the same idea applied three times: wrap risky operations in try/finally (or a transaction) so cleanup always runs, and never let an unhandled exception’s details reach the client.

Scenario briefing

You’re doing a resilience review of MiniBank, a small internal transfer app, ahead of a planned traffic spike. Nobody has load-tested what happens when a request fails partway through.

Lab setup

  1. Download: Download the lab (zip)
  2. Prerequisites: Docker Engine 20+, Docker Compose v2.
  3. Start it:
    cd owasp-a10-exception-handling-lab
    docker compose up -d --build
  4. Check it worked: curl -s http://127.0.0.1:5000/status should return {"max_slots": 3, "slots_in_use": 0}.

Missions

Mission 1 — leak the processing-slot pool. Send three malformed uploads and watch /status.

Show solution

Solution — Mission 1

for i in 1 2 3; do
  echo "not-a-number" > /tmp/bad-$i.txt
  curl -s -F "file=@/tmp/bad-$i.txt" http://127.0.0.1:5000/upload
  curl -s http://127.0.0.1:5000/status
done

Each malformed upload returns HTTP 500 and leaks one slot (acquire() runs, but the crash happens before release()). After three, slots_in_use sits at 3 and never comes back down.

Mission 2 — exhaust the pool. Send one more, perfectly valid, upload.

Show solution

Solution — Mission 2

echo "5" > /tmp/good.txt
curl -s -i -F "file=@/tmp/good.txt" http://127.0.0.1:5000/upload | head -1
HTTP/1.1 503 SERVICE UNAVAILABLE
{"error": "server busy, all processing slots in use", "flag": "FLAG{a10-slot-pool-exhausted-dos}"}

A real denial of service: three malformed requests from one attacker took down the service for every legitimate user, because the pool is a shared, finite resource that nothing ever restores.

Mission 3 — leak a stack trace. Call /transfer with an account that doesn’t exist.

Show solution

Solution — Mission 3

curl -s -X POST http://127.0.0.1:5000/transfer -H "Content-Type: application/json" \
  -d '{"from":"alice","to":"does-not-exist","amount":100}'

The response is the full Werkzeug interactive debugger — TypeError: 'NoneType' object is not subscriptable, complete with the exact source line and absolute file path on the server. debug=True in production turns every unhandled exception into free reconnaissance.

Mission 4 — check the financial damage. Compare balances before and after the failed transfer.

Show solution

Solution — Mission 4

curl -s http://127.0.0.1:5000/balances   # {"alice": 500, "bob": 500}
curl -s -X POST http://127.0.0.1:5000/transfer -H "Content-Type: application/json" \
  -d '{"from":"alice","to":"does-not-exist","amount":100}'
curl -s http://127.0.0.1:5000/balances   # {"alice": 400, "bob": 500}

Alice was debited $100 before the crash; bob was never credited because the exception hit between the two writes with no transaction wrapping them. $100 simply disappeared from the ledger.

Mission 5 — fix all three and re-test. Add try/finally cleanup, a global error handler, and a transactional rollback.

Show solution

Solution — Mission 5

slot = acquire_slot()
try:
    process_upload(file)
finally:
    release_slot(slot)        # ALWAYS runs, even on exception
@app.errorhandler(Exception)
def handle_error(e):
    app.logger.error("unhandled_exception", exc_info=e)   # full trace, server-side only
    return jsonify({"error": "internal error, please try again later"}), 500
try:
    debit(from_acct, amount)
    credit(to_acct, amount)
    db.commit()
except Exception:
    db.rollback()             # all-or-nothing
    raise

Restart the service with your patch and run python3 lab.py verify — the pool recovers after bad input, errors return a generic message, and balances stay consistent after a failed transfer.

Hints

Fix it + re-test

Three independent, well-known patterns: try/finally guarantees cleanup code runs regardless of how the try block exits; a global exception handler ensures every unhandled error gets logged in full detail server-side but returns only a generic message to the client; and wrapping multi-step state changes in a database transaction with rollback-on-exception keeps partial failures from corrupting data. Re-run all four missions against the fixed app (:5001) — the pool recovers, no trace leaks, balances stay consistent.

Detect it

The real traceback never disappears — it’s still recorded, just server-side: docker compose logs fixed | grep unhandled_exception. Alert on a rising rate of that log line (it means something upstream is sending malformed input, intentionally or not), and separately alert on slots_in_use staying non-zero for longer than a legitimate request should ever take.

Quick self-check

  1. Why does try/finally fix the slot leak even though the code inside try still crashes?
  2. Why is returning a generic error message to the client not the same as "hiding the bug" from the team?
  3. Why does a failed multi-step operation need an explicit rollback instead of just "not committing"?
Show answers

Answers

  1. finally runs no matter how the try block exits — normal return, exception, or anything else — so the slot-release code executes even when the processing code raises, instead of being skipped entirely.
  2. The generic message only changes what the client sees; the full exception, with its traceback, still gets logged server-side via the error handler — the team loses nothing for debugging, while an outside attacker loses the free reconnaissance.
  3. Without an explicit rollback, a partially-executed sequence of writes (debit succeeded, credit never ran) can leave the database in whatever state it was in when the exception hit — "not committing" only helps if every write in the sequence is part of one atomic transaction that’s rolled back together, not applied statement by statement.

Course complete

You’ve now exploited and fixed one real example of every category in the OWASP Top 10:2025 — broken access control, security misconfiguration, supply chain failures, cryptographic failures, injection, insecure design, authentication failures, data integrity failures, logging failures, and exception mishandling. Go back through any module where a mission felt shaky, and consider applying the same review lens (learn the concept, find the missing check, fix it server-side, verify, detect) to a real codebase you work on.