Yaamlabs
Case study 04

Cross-tenant statement access

Banking

A digital bank asked us to test the customer application and the APIs behind it. One endpoint served account statements. It checked that the caller was logged in. It never checked that the statement belonged to them.

Web & API penetration test, Digital bank, severity CRITICAL, reported 2024

At a glance

  • Challenge. A bank statement endpoint confirmed the caller was logged in but never that the statement belonged to them, and the identifier was enumerable.
  • Approach. We mapped the API, isolated routes that accept an object identifier, then replayed one account's request with a second account's session.
  • Result. An ownership check was added at the shared data-access layer, then the sibling, export and search routes were retested closed.
  • Impact. A customer could pull any other customer's statement, with names, balances and counterparties, and at scale because the identifier is enumerable.
  • Classification. CWE-639 Authorization Bypass Through User-Controlled Key, OWASP API Security Top 10 (2023): API1 Broken Object Level Authorization. Component: Account statement API endpoint.

The challenge

Broken object level authorization is the most ordinary critical there is, and the most common. Nothing about the request looked wrong. The session was valid, the token was valid, the request was well formed, and the object identifier in the path was trusted.

The distinction that matters is between authentication and authorization. Authentication asks who is calling. Authorization asks whether this caller may act on this object. A platform can get the first one exactly right, with short-lived tokens, rotation and multi-factor on login, and still answer the second one by assumption, because the identifier arrived inside a request the user could not have made unless they were logged in. That reasoning is circular, and it survives review because every individual control in it is correct.

It also survives testing that is organised by endpoint rather than by object. A scanner sees a well-formed authenticated request returning 200. A test plan that walks the feature list sees a statement download that works. Neither of them asks the only question that matters: what happens when the caller is authenticated as somebody else.

Statements are not a minor object. They carry names, balances, transaction history and counterparties, which is the full record a regulator would treat as the most sensitive thing a bank holds about a customer. Anything reachable this way is reachable at scale, because the identifier is enumerable, the endpoint is fast, and the rate limiting that exists is applied to the session rather than to the object.

Our approach

We mapped the API surface before touching it: every route the application called, plus the ones it no longer called but had not removed. Then we split that surface in two, the endpoints that accept an object identifier and the endpoints that do not. Only the first group can carry this bug, and it is a much shorter list than the feature list.

From there the test is mechanical. Two accounts under our control, both real and fully provisioned. A statement request issued as the first account, then replayed with the second account’s session and the first account’s identifier. The response came back complete. Not an error, not a redacted view, the document itself.

Authorization bugs travel in families. They come from a shared assumption, and a shared assumption gets implemented more than once, so the sweep that followed asked:

  • Every sibling endpoint operating on the same object type
  • Every other endpoint that accepted an identifier in a path, a query string or a body
  • Whether the missing check sat in one handler or in a shared layer that other handlers also bypassed
  • Whether the same object was reachable through export, search and notification paths
  • Whether the identifier was sequential, and therefore whether the exposure was one record or all of them

We stopped at proof. One statement belonging to an account we controlled was enough to demonstrate the class. Enumerating further would have pulled real customer records into the engagement, and there is no finding on the other side of that line worth having.

It was in the client channel the hour it was confirmed, with the two requests side by side and the reasoning attached, so an engineer could reproduce it in a minute rather than decode a severity vector.

From finding to fix

  1. Mapped the account API surface
  2. Isolated routes keyed on an object id
  3. Requested another tenant's statement
  4. Enforced ownership checks, retested

The outcome

The fix was an ownership check at the object level: resolve the identifier, compare the owner against the authenticated principal, fail closed. It went in at the shared data-access layer rather than the single handler, so the sibling endpoints we flagged were covered by the same change.

The structural part mattered more than the patch. Object ownership stopped being something each handler had to remember and became something a handler would have to opt out of.

We retested after the fix shipped, against the original path, the siblings, and the export and search routes that reached the same object, and confirmed all of them were closed. Reported as CRITICAL in 2024. Retest included in the engagement.

Check your own system

  • For every endpoint that accepts an object identifier, resolve the owner and compare it to the authenticated caller before responding.
  • Put the ownership check in a shared data-access layer, so a handler has to opt out rather than remember.
  • Test authorisation by object rather than by endpoint, replaying one account's request as another.
  • Rate limit on the object, not only the session, since enumerable identifiers turn one leak into all of them.

Read more case studies, see Web Application Penetration Testing, or tell us about your system on the contact page.