← back to research

One auth flaw to full payout control: chaining five bugs in a payments platform

A field report from a cross-border payments engagement. Five findings, each a routine medium on its own, chain from an anonymous Internet request to direct money movement. The work was reading them together.


This is an anonymized field report from a continuous review Cipher ran for a fast-growing fintech: a multi-tenant, cross-border payments platform that moves real money on behalf of business customers, who in turn serve their own end users. Every identifier here, the company, the hostnames, the tenant names, the balances, has been removed. What remains is the part worth keeping: how five ordinary findings combined into a path from an anonymous request on the public Internet to money leaving an account.

None of these five is a five-alarm fire on its own. A missing authorization annotation on one route. An auth filter that matches a path prefix too loosely. A webhook endpoint that hands its caller the signing secret. An outbound sender that forwards wherever its caller points it. A transaction call that books against any tenant’s ledger. Each is the kind of medium a busy team triages, fixes next sprint, and reasonably forgets. A scanner that found any one of them would have scored it a 5 and moved on.

The severity was never in the individual bugs. It was in how they connected across the platform’s gateway topology, its deployment, its API trust model, and the business meaning of each surface. Seeing that required holding all four in view at once. That is the work this report is really about.

The chain

open /admin rule Anonymous Internet request no auth · no VPN Mint tenant credentials unauth POST /admin/clients impersonate any tenant Hijack signed payment webhooks + steal the HMAC signing secret Outbound delivery as confused deputy SSRF into the internal network · CVSS 10.0 Seize the global payout-vendor pool halt or reroute every tenant's payouts Cross-tenant transaction creation direct money movement

attack-chain-trace
step | action                                              | reached from
-----+-----------------------------------------------------+--------------
  1  | request any path containing "/admin"                | public Internet
     | -> auth filter classifies it as an open endpoint    | (no VPN)
     |                                                      |
  2  | POST /admin/clients  { client: "<any-tenant>", ... }| public Internet
     | -> route has no authorizer; mints Basic credentials |
     |    bound to a tenant the caller names               |
     |                                                      |
  3  | authenticate as that tenant on every client API     | minted creds
     | -> full horizontal auth bypass: be any tenant       |
     |                                                      |
  4a | POST /webhooks/subscribe  (repoint + read secret)   | tenant context
     | -> intercept signed payment events; forge new ones  |
  4b | POST /webhooks/test  (attacker URL + headers)        | tenant context
     | -> server-side request forgery into the internal net |
  4c | PUT  /vendors/{name}/deactivate                      | tenant context
     | -> control the global payout-vendor pool            |
  4d | POST /transactions  (book against another tenant)   | tenant context
     | -> direct money movement                            |
-----+-----------------------------------------------------+--------------
root | one open-endpoint rule + one missing authorizer

Step 1: an auth filter that matches too loosely

A shared request filter decided which routes were public by testing whether the request path contained a known open prefix. The intent was to exempt a small set of unauthenticated routes (health checks, documentation). The implementation used a substring match, and the open list included a value that appeared inside the platform’s admin namespace.

The result: any path containing that fragment skipped the authentication filter entirely. A large family of administrative routes was reachable from the public Internet with no credentials at all.

# illustrative, not the customer's code
isOpenEndpoint(path):
    for prefix in OPEN_PREFIXES:        # e.g. ["/health", "/admin", ...]
        if prefix in path:              # substring, not exact match
            return true                 # -> skip auth filter
    return false

On its own, this is a configuration smell. Whether it is exploitable depends entirely on what lives behind those admin routes, and whether anything else re-checks authorization. Both questions are answered outside this file.

Step 2: the route that mints money-bearing identities

The platform leaned on a second, independent control: a per-method authorizer annotation that each admin handler was supposed to carry. Defense in depth, in principle. In practice it failed open. Any handler missing the annotation had no second check, and the filter from Step 1 had already waved the request through.

The first such route created API clients:

# illustrative
POST /admin/clients
{ "client": "<existing-tenant>", "client_id": "x", "client_secret": "y" }
-> 200 Success

This route mints HTTP Basic credentials bound to any tenant the caller names. Unauthenticated. From the public Internet. One request.

That is the whole pivot. Everything below is one HTTP call away from here, because once you can be any tenant, the rest of the API treats you as that tenant.

NOTE

A scanner can flag “this endpoint has no auth.” It cannot tell you that the endpoint mints a tenant identity, that tenant identity is the unit of trust for money movement, and a different filter already made the route public. The finding is not the open route. The finding is what the open route means in this system.

Step 3: a webhook that returns its own signing secret

As an impersonated tenant, the next surface is the outbound webhook configuration. Tenants register a URL to receive transaction events (processing, completed, failed) for their own end users. Those events carry payment-relevant data and are HMAC-signed so the receiver can trust them.

The subscribe endpoint did two things that are each defensible alone and catastrophic together: it let the authenticated tenant set the destination URL, and it returned the tenant’s HMAC signing secret in the response body.

So the attacker, already impersonating the tenant from Step 2, repoints the tenant’s webhook stream to a server they control and walks away with the signing key. Now they receive every one of that tenant’s customers’ payment events, already signed by the platform as authentic, and they hold the key to forge new signed events that any downstream consumer will trust.

This is a live PII and payment-event interception primitive, built entirely from “the tenant can manage their own webhook,” which is a feature.

Step 4: the platform’s own egress, turned into a weapon

The same outbound-delivery machinery accepts an attacker-chosen URL and an attacker-chosen set of headers, then sends the request from the platform’s own trusted egress. That is a textbook confused deputy: internal services, and the cloud metadata endpoint, treat the call as coming from the platform itself.

Chained with the tenant impersonation from Step 2, an external attacker reaches the internal network from outside it, choosing both the destination and the headers that destination sees. Scored in isolation against the internal trust boundary it crosses, this reaches the top of the scale (CVSS 10.0). But it only becomes externally reachable because of Step 2. Alone, it is “our webhook sender forwards custom headers,” another medium.

Step 5: booking a transaction in someone else’s ledger

The payoff. As an impersonated tenant, the attacker calls the transaction API and the platform books the transaction in that tenant’s books, drawing on that tenant’s balance. With a valid recipient, that is settlement: money moving to an account the attacker chose, funded by a victim tenant.

A related route let any authenticated tenant administer the global payout-vendor pool, not just its own scope, so the same primitive could deactivate every legitimate payout partner at once, halting the platform or forcing all traffic through a route the attacker influences.

Each of these routes, read alone, is “an authenticated endpoint with a permission gap.” Read after Step 2, where authentication is something an anonymous attacker grants themselves, the gap is the whole exploit.

Why this isn’t “a payment processor left wide open”

It is tempting to summarize this as a careless platform. It was not. It was a normal platform with a normal density of medium-severity findings, the kind every system this size carries. The difference between that and a press-release breach was the connective tissue, and that is invisible to any tool or review that looks at findings one at a time.

Pulling the chain together meant reasoning across four layers at once:

  • Infrastructure and topology. Which routes are exposed by the public gateway versus only an internal load balancer; what identity the platform’s egress carries; what the metadata endpoint will answer. Step 4 is only catastrophic once you know where the egress sits.
  • Deployment reality. The environment under test ran the same code as production. “Staging” was not a safety boundary, so a staging-reachable route was a production-reachable route.
  • API trust model. Two independent auth mechanisms, each assuming the other held. The substring filter assumed handlers re-checked; the annotations assumed the filter blocked. Neither is wrong in isolation; the seam between them was the door.
  • Business context. That POST /admin/clients mints a money-bearing tenant identity, that the webhook stream carries real customers’ payment events, that the vendor pool is shared across tenants. Without the business meaning, these are CRUD endpoints. With it, they are the crown jewels.

The severe vulnerabilities rarely live in a line of code. They live in the assumptions between systems, the ones each component is sure some other component is enforcing.

Remediation

The most useful number in the whole engagement: one root-cause fix closed roughly 80% of the externally reachable critical surface. Replace the substring open-endpoint rule with an explicit allowlist of fully-qualified public routes, and require the authorizer on the client-minting route. That single change collapses the entry point that every Tier-1 chain above depended on. The rest is defense in depth:

  • Open endpoints by exact match, not substring. Default every route to authenticated; opt specific, fully-qualified paths out.
  • Fail closed on authorization. A handler with no authorizer should be denied, not allowed. Make the missing annotation a build failure.
  • Never return signing material. The webhook secret should be settable and rotatable, never readable. Require elevated authorization to change a delivery URL.
  • Treat outbound delivery as SSRF-sensitive. Resolve and reject internal, loopback, link-local, and metadata destinations; allowlist forwarded headers.
  • Scope admin operations. Tenant credentials must not authorize global vendor or cross-tenant operations; separate the roles.

The lesson for the team was not “you had bugs.” It was that their real risk was never visible in any single finding, and would not have been visible to any process that reviewed findings one at a time. It took reading the system as a system.