Disclosure. Authorized bug-bounty engagement against one of the world's five largest fintechs. The target and product are anonymized; every technical detail and response shape below is unmodified from the engagement record. The affected system is referred to as the Key Manager. The finding was reported through the program, remediated by the vendor, and awarded a €5,000 bounty. Key material, device identifiers and internal package names in the excerpts are redacted.
Why this one is worth writing up
Most access-control findings are boring to read about because the interesting part is the payload. This one has no payload. The vulnerability is a category error - a piece of observability infrastructure standing in the position of an authorization control - wrapped inside a second, more common mistake: an SSO-protected front end in front of an API that trusts the front end to have done the protecting.
That combination is why it survived on an internet-facing host, behind a WAF, at a company with a mature security program. Everything about the surface said "locked." The login wall was real. The token was real. The API answered anonymous requests with 403 Forbidden. The enforcement was missing anyway - and it took exactly one inconsistent endpoint to prove it.
Starting from nothing
One root domain in scope. No endpoint list, no credentials, no hints.
Passive subdomain enumeration surfaced a long-tail host whose name referenced key management. Live probing fingerprinted a React single-page application on Google Cloud, behind a CDN and a WAF. Loading it in a browser produced an immediate redirect to an Okta sign-in page. Every route was gated.
At that point, most automated tooling is finished. There is no unauthenticated surface to crawl, no forms to fuzz, and the WAF discourages anything noisy. A human tester on a time budget would very reasonably move to the next host.
Two signals pushed it to the top of the queue instead, and no human made that call:
- A host named for key management has no business being internet-reachable. Naming is a signal. Infrastructure that manages cryptographic material belongs on an internal network behind identity-bound access, and when it isn't, that's worth an hour before anything else on the surface.
- An SSO wall protects the UI, not the API. Okta gates who can load the page. It says nothing about whether the backend checks the token that the page sends.
The login wall protected the app, not its blueprint
The redirect to Okta stops a browser. It does not stop a request for a static asset.
The application's JavaScript chunks were served straight off the CDN with no authentication at all - and alongside them, the source map. That turned a minified bundle back into readable, structured front-end source: the route table, the API client, the auth wiring, the page components for key and certificate management.
The auth guard itself was the first thing worth reading:
export const ProtectedRoute = ({ children }: PropsWithChildren) => {
const { authState, oktaAuth } = useOktaAuth()
useEffect(() => {
if (authState && !authState.isAuthenticated) {
oktaAuth.signInWithRedirect({ originalUri: /* … */ })
}
}, [authState, oktaAuth])
// …
}
A client-side redirect. It decides what a browser renders. It has no bearing on what the server answers.
The API client showed the front end doing its part correctly:
// common/http.ts
const accessToken = await oktaAuth.getAccessToken()
requestHeaders.set('Authorization', `Bearer ${accessToken}`)
So a real Okta token was being attached to every API call. The open question - the one that decided this engagement - was whether anything on the server side ever looked at it.
And the same bundle gave up the routes to ask:
rkl: {
csr: "/api/rkl-ui/api/v1/certificate/create-csr",
addCertificate: "/api/rkl-ui/api/v1/certificate/add-certificate",
// data routes recovered from the same bundle:
// /api/rkl-ui/api/v1/atm
// /api/rkl-ui/api/v1/atm/{id}
// /api/rkl-ui/api/v1/certificate/certificates
}
rkl is Remote Key Loading - how an ATM receives its encryption keys from a host system without a technician carrying key components to the machine. The route names alone described what this API governed. /atm/{id} was flagged immediately as object-scoped, which is where access-control bugs live.
No wordlist, no 404 noise, no fuzzing. The application published its own private map, because that map ships to every visitor whether or not they can log in.
One endpoint talked
With the route table recovered, every data route got probed anonymously. Every one of them refused:
GET /api/rkl-ui/api/v1/atm HTTP/1.1
Host: key-manager.<redacted>
HTTP/1.1 403 Forbidden
That is the correct-looking answer. An Okta-protected console, and its API returns 403 to anyone without a session. Read across the whole route table; those responses say the same thing a scanner would report: authorization is enforced, there is nothing here, move to the next host. This is the point where the engagement should have ended.
It didn't, because one endpoint answered differently. The CSV upload route - an operational function, not one of the data routes - returned this:
GET /api/rkl-ui/api/v1/atm/upload-csv HTTP/1.1
Host: key-manager.<redacted>
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{"title":"Bad Request","status":400,
"detail":"Required header 'request-guid' is not present.",
"instance":"/rkl-ui/api/v1/atm/upload-csv"}
A different status, and a different failure mode. Where its siblings denied the request outright, this one got far enough into request processing to complain about a missing header - and named it.
That inconsistency is the entire finding. One endpoint validated the header before whatever produced the 403 elsewhere, and in doing so it disclosed the name of the thing the platform actually gated on. A single route out of the set, handling an operational upload rather than a data read, wired up in a slightly different order.
Two responses from the same API, and the disagreement between them was the vulnerability.
The question worth asking is narrower: what is this header for?
request-guid is a naming convention from distributed tracing. Correlation IDs get attached to a request at the edge so a platform team can follow it across services in their logs. They're generated by the client or the gateway; they aren't secret, they aren't enrolled anywhere, and nothing validates them - validating them would defeat the purpose.
If that was right, the header was a formatting requirement rather than a credential - and the 403s on the data routes were not an authorization verdict at all. They were the same missing-header rejection, surfaced less helpfully.
That was the hypothesis worth testing: take the header name leaked by the one chatty upload endpoint and replay it against the routes that had just refused. A throwaway UUID, no Okta token, no cookie, no session:
GET /api/rkl-ui/api/v1/atm HTTP/1.1
Host: key-manager.<redacted>
request-guid: 298f2b97-4215-4ec8-b271-fcc2fa6a6666
HTTP/1.1 200 OK
Content-Length: 665224
[{"atmId":"<redacted>","hostCert":"PK_HSM","eppCa":"EPP","bindStatus":"BOUND",
"tmk":"S100…<redacted key block>","tmkKvc":"<redacted>",
"tpkLmk":"S100…<redacted key block>","tpkKvc":"<redacted>",
"certificate":"3082…<redacted X.509>","publicKey":"<redacted>",
"eppSerialNumber":"<redacted>"}, … ] // ×186
The same route that had returned 403 Forbidden sixty seconds earlier now returned 665 KB of production key material. The only thing that changed between the two requests was a header the server itself had named, carrying a value invented on the spot - never issued by the platform, in no log, belonging to no session. The Authorization header the front end works so hard to populate was still absent, and the server still did not notice.
The header was a doorbell, not a lock. It replayed cleanly against the certificate store too, and every other route in the recovered map behaved the same way: 403 without it, full data with it.
What was behind it
Two endpoints answered unauthenticated requests in full:
GET /api/rkl-ui/api/v1/atm - 200 OK, 665 KB, 186 ATM records, 182 in BOUND state. Each record carried:
| Field | What it is |
|---|---|
tmk |
Terminal Master Key - the key-encrypting key for that ATM |
tmkKvc / tpkKvc |
Key check values, used to verify a key without exposing it |
tpkLmk / tpkTmk |
Terminal PIN Key, under the LMK and under the TMK |
certificate |
Device X.509 certificate, chaining to the production ATM RKL Root CA |
publicKey, eppSerialNumber |
Device public key and Encrypting PIN Pad serial |
bindStatus |
Whether the terminal is currently bound to the host |
GET /api/rkl-ui/api/v1/certificate/certificates - 200 OK, 11 HSM host certificates, five with populated privateKey blocks, including the primary HSM identity and the ATM vendor's host interface.
This is the complete inventory and identity material of a production ATM estate - every device's serial, certificate, binding state and key set, plus HSM host private keys - served to anonymous callers on the public internet. It is the map of the key hierarchy and a foothold for forging trusted device identities in the key-loading chain. For a payments platform, exposure of the root of trust for PIN encryption is not a privacy incident. It's a cryptographic one.
Twelve minutes, start to finish
| Phase | What happened | Time |
|---|---|---|
| Recon & surface mapping | subdomain enumeration → live host → React/Okta/WAF fingerprint → ranked to top of queue | 7 min |
| Map & exploit | unauthenticated JS bundle + source map → route table recovered → request-guid hypothesis → first unauthenticated 200 OK key dump |
3 min |
| Evidence assembly | X.509 chain decoded, 186 ATMs and 11 HSM certificates enumerated, reproducible critical written | 2 min |
| 12 min total |
Discovery-to-exploit was the fast part. The judgment that turned a 400 into a fleet-wide critical happened inside the three minutes of phase two - everything after that was the agent proving it beyond argument.
What the scanners saw
Every signal that made this host look protected was real. None of them were authorization.
| At the surface | The reasonable conclusion | What was actually true |
|---|---|---|
| Okta SSO redirect on every route | "Authenticated app - no anonymous surface" | Client-side guard; the API never verified the token |
| WAF and CDN in front of the host | "Protected - deprioritize" | A WAF filters payloads; it does not check identity |
| SPA with no visible endpoints | "Nothing to fuzz" | The unauthenticated JS bundle is the API documentation |
403 Forbidden across every data route |
"Authorization enforced - dead end" | Not an authz verdict; a missing-header rejection wearing a 403 |
One route answering 400 Required header … |
Noise; a different error on an unrelated endpoint | The disagreement between two responses was the vulnerability |
Header satisfied → 200 OK |
(never reached) | No identity check anywhere in the path |
The vulnerability lived in two gaps: between "the UI is authenticated" and "the API is authenticated," and between "a header is required" and "a header authenticates." Neither is visible to status-code matching - a 403 is the most reassuring response an access-control test can get, and here it was covering for a control that did not exist. Closing that gap requires noticing when two endpoints on the same API disagree about why they said no, and asking which one is telling the truth.
For defenders
Six things worth taking from this, whether or not you run ATMs:
- SSO on the front end is not authentication on the back end. An Okta guard decides what a browser renders. If the API does not independently validate the bearer token on every request, the login wall is decoration. Verify the token at the service, not just at the edge.
- Correlation IDs are not credentials. If the only requirement to reach an endpoint is that a header is present, there is no access control on that endpoint.
- Make your endpoints fail the same way. Every data route here returned
403; one operational route returned400and named the header it wanted. That single inconsistency handed over the mechanism. Centralize authentication and error handling so no endpoint can leak, through its failure mode, what the platform is really checking - and so a403means the same thing everywhere. - Your SPA publishes your private API - even behind a login. Static bundles are served before authentication, and a shipped source map turns them back into readable source. Strip source maps from production, and design as though every backend route is public knowledge, because it is.
- A WAF in front of a missing authorization check is still a missing authorization check. Perimeter filtering buys time against known payloads. It does nothing about a well-formed request the application was always going to answer.
- Key-management and HSM consoles should not be internet-reachable. If business need forces it: identity-bound authentication, network-level restriction, and alerting on bulk reads of the device inventory. A single anonymous request returning the whole estate should have paged somebody.
The pattern worth remembering
There was no clever payload here. The exploit was a randomly generated UUID in a header the server itself named, sent to an API that a genuine Okta login sat in front of and never protected. Everything visible said stop: an SSO wall, a WAF, and 403 Forbidden on every route that mattered. The finding came from noticing that one endpoint out of the set refused for a different reason, and asking which refusal was honest - formed in seconds, tested in one request, and worth €5,000 because nothing else on that perimeter had ever thought to ask.
That's the class of judgment we built Strike Atlas to apply at scale: an autonomous penetration-testing platform that reasons about a target's intent instead of matching its status codes, works an engagement end to end with no human in the loop, and files findings with evidence a defender can reproduce. It found this one pointed at a single domain and left alone for twelve minutes.
See what Strike Atlas finds on your attack surface
Fully autonomous, evidence-first AI penetration tests - the same engagement you just read, run against your own scope, on demand. Reproducible criticals, not scanner noise.
Discovered and validated by Strike Atlas during an authorized bug-bounty engagement. Target and product names redacted at the vendor's discretion; technical detail and response shapes are unmodified from the engagement record.