Skip to content
back to the map
technique
stage: operate
2 min read

A10 Mishandling of Exceptional Conditions

Security often depends on what happens when something goes wrong. If an error means "allow", a stack trace means "here is my code", or a half-finished operation means "corrupted state", the failure path is the vulnerability.

Chaos engineering (fault injection)Fuzzers (AFL++, Atheris, go-fuzz)Semgrep (empty catch, swallowed errors)Hypothesis / property-based testingToxiproxy

What it is

Mishandling of exceptional conditions is improper preparation for, detection of, and response to unusual situations: failed dependencies, timeouts, malformed input, resource exhaustion, partial writes, and unexpected states. The typical mistakes are failing open, leaking details in error messages, swallowing exceptions, and leaving transactions half done.

It is a new category in :2025 (A10), and it ties together several older weaknesses around error handling.

How the attack works

  • Fail open: an authorization service times out and the code treats the exception as "no restriction".
  • Information leak: a database error is returned to the client with the table name, query and driver version.
  • Swallowed errors: an empty catch hides a failed signature check and processing continues.
  • Partial state: a payment is charged but the order write fails; retrying duplicates the charge.
  • Resource exhaustion: an unbounded input or an unhandled exception in a loop lets one request take the service down.

Vulnerable code

The check fails open, the error leaks internals, and the multi-step update is not atomic.

access.pypython
def can_access(user, doc):
    try:
        return policy_service.allowed(user.id, doc.id)
    except Exception:
        return True                    # service is down, so let them in

@app.errorhandler(Exception)
def on_error(e):
    return str(e), 500                 # leaks internals to the client
transfer.jsjavascript
await accounts.debit(from, amount);
await accounts.credit(to, amount);   // if this throws, the money has vanished

The fix

: on any doubt about a security decision, deny. Return a generic message with a to the client and keep the details in the logs. Make multi-step changes atomic with a transaction, and bound everything: time, size, retries, memory.

access.pypython
def can_access(user, doc):
    try:
        return policy_service.allowed(user.id, doc.id, timeout=0.5)
    except Exception:
        log.exception("policy check failed", extra={"user": user.id, "doc": doc.id})
        return False                   # fail closed

@app.errorhandler(Exception)
def on_error(e):
    rid = uuid4().hex
    log.exception("unhandled error", extra={"request_id": rid})
    return {"error": "internal error", "request_id": rid}, 500
transfer.jsjavascript
await db.transaction(async (trx) => {
  await accounts.debit(from, amount, trx);
  await accounts.credit(to, amount, trx);   // both happen or neither does
});
client.gogo
ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
defer cancel()

resp, err := policy.Check(ctx, user, doc)
if err != nil {
	log.Error("policy check failed", "err", err)
	http.Error(w, "forbidden", http.StatusForbidden) // fail closed
	return
}

Prove the fix

Inject the failure on purpose. Make the dependency time out and assert that access is denied; make the second step throw and assert that nothing changed; trigger an error and assert that the response carries no internals. Fuzzing adds odd inputs you did not think of.

test_failures.py (pytest)python
def test_denies_when_policy_service_is_down(monkeypatch, user, doc):
    monkeypatch.setattr(policy_service, "allowed", lambda *a, **k: (_ for _ in ()).throw(TimeoutError()))
    assert can_access(user, doc) is False

def test_error_response_has_no_internals(client, monkeypatch):
    monkeypatch.setattr(db, "query", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("relation users_secret does not exist")))
    body = client.get("/api/things").get_json()
    assert "users_secret" not in str(body) and "request_id" in body

@given(st.text())
def test_parser_never_crashes_on_any_text(s):      # Hypothesis
    parse_input(s)       # may reject (ValueError) but must not raise anything else
transfer.test.jsjavascript
test("a failed credit rolls back the debit", async () => {
  jest.spyOn(accounts, "credit").mockRejectedValue(new Error("boom"));
  const before = await accounts.balance(from);
  await expect(transfer(from, to, 50)).rejects.toThrow();
  expect(await accounts.balance(from)).toBe(before);
});

Checklist

  • Decide the failure behavior of every security check up front: it should deny.
  • Catch narrowly; never leave an empty catch; log with context and re-raise or return a safe error.
  • Return generic errors with a correlation id; keep stack traces and queries in logs only.
  • Use transactions and idempotency keys for multi-step operations, so retries are safe.
  • Set timeouts, size limits, rate limits and retry budgets on every external call and input.
  • Practice failure: fault injection, fuzzing and game days reveal fail-open paths before attackers do.
OWASP Top 10The best-known reference list of web application security risks (A01 broken access control, A02 cryptographic failures, A03 injection...), revised every few years by the OWASP community. It is an educational map of risk categories, not a tool checklist or a certification.see in glossary →Fail closedfail secureWhen a security check cannot decide (timeout, error), the answer is deny. The opposite, failing open, turns every outage into an access-control bypass.see in glossary →Correlation idrequest idA unique id attached to a request and to every log line it creates, shown to the user in error messages so support can find the details without leaking them.see in glossary →

connections on the map

updated on 14/10/2026 · sources to go deeper · report an error

in other domains

Study before

test what you learned

Loading questions…