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
- Download: Download the lab (zip)
- Prerequisites: Docker Engine 20+, Docker Compose v2.
- Start it:
cd owasp-a10-exception-handling-lab docker compose up -d --build - Check it worked:
curl -s http://127.0.0.1:5000/statusshould 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
/statusis your instrument panel for mission 1–2 — check it after every upload, good or bad.- The stack-trace leak and the slot leak are independent bugs; you can demonstrate either one without touching the other.
- The fixed app on
:5001already has all three fixes — use it to confirm expected behavior, then apply the same ideas to your own patch.
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
- Why does
try/finallyfix the slot leak even though the code insidetrystill crashes? - Why is returning a generic error message to the client not the same as "hiding the bug" from the team?
- Why does a failed multi-step operation need an explicit rollback instead of just "not committing"?
Show answers
Answers
finallyruns no matter how thetryblock exits — normal return, exception, or anything else — so the slot-release code executes even when the processing code raises, instead of being skipped entirely.- 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.
- 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.