The idea: the error path is code too

Mishandling of Exceptional Conditions is item A10 of the OWASP Top 10:2025, a new category this edition. It covers programs that fail to prevent, notice or respond well to unusual situations: a dependency that times out, input the code did not expect, a step that fails half way, a resource that runs out. Most code is written and tested for the path where everything works. Attackers go looking for the other path, because they can trigger errors on purpose.

One request through the app, with four things that can go wrong: the authz service times out, the input breaks the SQL, the second step of a transfer fails, and an exception skips the release of a pooled connection. For each, the unsafe handling (access allowed, stack trace sent, half the change kept, connection leaked) and the safe one (deny, generic message with a reference id, roll back, release in finally)
Each error has an unsafe and a safe way to handle it; the attacker chooses which error happens.

Every attack on this page runs against the vulnerable app and then against the fixed app. The happy-path code is the same in both; only the catch, finally or transaction around it changes. The rules for the error path panel shows which rule is missing (✗) or enforced (✓).

OWASP maps 24 CWEs to A10. The ones the page shows:

CWENameOn this page
CWE-636Not Failing Securely ("Failing Open")the authz timeout (demo 1)
CWE-209, CWE-550Error message containing sensitive information; server-generated error message containing sensitive informationthe stack trace (demo 2)
CWE-460Improper cleanup on thrown exceptionthe half-done transfer and the leaked connection (demos 3 and 4)
CWE-754, CWE-755Improper check for, and improper handling of, exceptional conditionsthe pattern behind all four
CWE-772 (not in the A10 list)Missing release of resource after effective lifetimethe pool running dry (demo 4)

Reading the canvas: alice (a customer) and mallory (the attacker, with a normal account) are in the client panel, with the full request, the response and mallory's notes. The server side has the app with its handler, the database with one table and a pool of 4 connections, the authz service that answers permission checks, the rules, and the server log. Boxes fly for every hop: blue is an HTTP request, orange an SQL statement or an authz call, grey rows coming back, green a 2xx reply and red an error or a 4xx/5xx reply.

Fail open vs fail closed

The admin export asks an authz service whether the user may export. Today that service is overloaded and the call throws TimeoutException after 2 s. The vulnerable handler was written so that an outage would not block users: catch (e) { allowed = true }. mallory, a plain user, asks for /admin/export and gets all 3 customers' emails and cards with 200 OK (Demo 1: auth service down, fail-open). That is failing open: an error in the security check is treated as a yes.

The same TimeoutException from the authz service reaches two catch blocks. Left, fail open: allowed = true, the customers are read and 200 OK returns 3 rows. Right, fail closed: allowed = false, log and alert, 503 Service Unavailable and 0 rows
The same timeout: fail open turns it into a data leak, fail closed into a short outage.

The fixed handler fails closed: no answer means no access. It logs the timeout, raises an alert and answers 503; the customers table is never read. The price is availability: while authz is down, real admins can't export either. For a security decision that is the right trade. Graceful degradation, such as a cached answer or a default, belongs to features where a wrong yes does no harm, like showing recommendations. A circuit breaker makes the outage cheaper (it stops waiting 2 s per call), but its fallback must still deny.

Error messages that leak

mallory adds a quote to an order id, /orders?id=1', to break the SQL on purpose. In the vulnerable app nothing catches the exception and the framework's debug error page answers with 500 and the stack trace (Demo 2). From one request she learns that the SQL is built by pasting the id (so it is injectable), the table orders and column card_no, the driver and framework versions (to look up known bugs), a file path and the database's internal address 10.0.3.7. That is the reconnaissance for the next attack.

The fixed app has one global exception handler. It makes a reference id, err-7f3a, writes the full trace to the server log under it and answers {"error":"internal error","ref":"err-7f3a"}. The user can quote the ref to support; the attacker learns nothing. Turning off debug pages in production is the same rule seen as configuration (see A02 Security Misconfiguration). The pasted SQL is still a bug, and it belongs to A05 Injection; this page only fixes what the error path says.

Partial transactions

mallory has two accounts, chk with $30 and sav with $0, and the table has CHECK (balance >= 0). She asks to move $100. The vulnerable handler runs two statements with autocommit: the credit to sav succeeds and is committed at once, then the debit of chk breaks the constraint and throws. The catch block logs it and answers 500, but nothing undoes the credit: $30 became $130, and every retry adds $100 more (Demo 3: half-done transfer). OWASP's own example is the same: an interrupted debit, credit and log sequence that is not rolled back.

Two time-down sequences for the transfer of 100 from chk (30) to sav (0). Left, autocommit: the credit commits sav = 100, the debit fails the CHECK, the catch returns 500, the balances end at chk 30 and sav 100, total 130. Right, one transaction: BEGIN, the credit is not committed, the debit fails, ROLLBACK, the balances end at chk 30 and sav 0, total 30
Without a transaction the first step stays; with BEGIN … ROLLBACK the failure leaves nothing behind.

The fixed handler wraps both statements in BEGIN … COMMIT and calls ROLLBACK in the catch block: both changes or neither, and the answer is 409 insufficient funds. The order of the steps doesn't matter: with debit first, a failing credit would destroy money instead of making it. When the steps span services and no single transaction can cover them, a saga's compensating steps do the same job (see Saga vs Two-Phase Commit).

Resource exhaustion

Every upload takes one of the pool's 4 database connections, parses the CSV, inserts the rows and releases the connection. mallory uploads a file with an unterminated quote. The parser throws, the catch block answers 400, and conn.release(), which came after the parse, never runs. Four broken files leak all 4 connections. alice's ordinary request then waits 30 s for a connection and gets 503, and so does everyone else until a restart (Demo 4: leaked connections). Locks, file handles, temp files and threads leak the same way.

The fix is to release in finally, or with a construct that does it for you: try-with-resources in Java, with in Python, using in C#, defer in Go. The same four files then cost the server nothing and alice gets 200. Pool timeouts, leak detection (most pools can log a connection held too long), upload size limits and rate limits cap whatever is left. See Connection Pool for how a pool hands out and waits for connections.

How to prevent it

  • Expect failure and catch errors where they happen, where the code still knows what was going on, not only in one handler at the top.
  • When a security check can't be made, deny. Never write a catch that sets "allowed".
  • Handle each error meaningfully: a clear message to the user, a log entry with the details, an alert when it matters. Keep a global exception handler as a backstop that never sends a stack trace.
  • If a transaction fails part way, roll back all of it. OWASP calls this failing closed, too.
  • Release resources on every path (finally, try-with-resources), and use rate limits, quotas and timeouts so one client can't use them all.
  • Validate input strictly, so fewer odd values reach the code at all, and handle "impossible" cases (a default in every switch, a check on every return value).
  • Watch for repeated errors from one client: a burst of 500s or parse errors is often an attack in progress.
  • Test the error paths: stress tests, fault injection (kill a dependency, fill the disk) and penetration tests.

OWASP Top 10:2025 series

See also OWASP A06 Insecure Design, whose gift-card race is the concurrent cousin of the half-done transfer, and Circuit Breaker, for failing fast when a dependency is down.

What the page leaves out

  • Real timing: the 2 s authz timeout and the 30 s pool timeout are text, not a clock, and mallory's three extra uploads are one line.
  • The rest of A10's 24 CWEs: unchecked return values, null dereferences, divide by zero, missing default cases, generic catch (Exception) blocks, and missing or extra parameters.
  • Isolation levels and locks inside the transaction, and the outbox and saga patterns for steps across services.
  • Logging and alerting themselves (A09), and the risk of putting secrets or personal data into the log.
  • Crashes and restarts: an uncaught exception that kills a worker, and what a supervisor or orchestrator does next.