HighIDOR· 4 min read

The Oracle at the End of the Redirect

A photo-print service hid every image behind an unguessable URL — then handed that URL to anyone who asked, no login required. So I asked for everyone's.

Target anonymized · a big-box retailer's photo-printing service

Photo-printing sites are a strange, tender corner of the internet. People upload the most personal images they own — kids, weddings, the passport shot, the thing they would never want a stranger to see — hand them to a big-box retailer's photo-printing service to be turned into 6x4 prints, and never think about it again. I was poking at exactly one of these services, watching how it shuttled images to the browser, when a single redirect caught my eye and refused to let go.

The spark

When you click a thumbnail, the app doesn't just load an <img> from a static path. It fires a GET at an internal endpoint — something shaped like /photo/{collection_id}/{size}/{photo_id} — and the server replies with a 302 Found. The real image lives somewhere else entirely, on a CDN, behind a URL like:

https://cdn.example.com/get/<64-char-sha256>.jpg

That hash is the whole game. Sixty-four hex characters, no pattern, effectively 256 bits of entropy. You are never guessing it. My first reaction was actually admiration: whoever built this understood that a predictable image path is a liability, so they buried the real files behind an unguessable digest. Good instinct.

So I almost closed the tab.

Digging in

What nagged me was the input. The unguessable output was reached through a very guessable door. The collection_id and photo_id in that first request weren't hashes — they were plain integers. Short ones. I uploaded a second photo, compared, and the new photo_id was only slightly larger than the last. Sequential. Predictable.

That reframed everything. The CDN hash wasn't a secret you had to break — it was a lookup result. And the /photo/ endpoint was the lookup. Feed it a small integer and it happily returns the matching 256-bit URL. The entropy of the final filename means nothing if a machine will compute it for you on demand.

My dead end was the CDN itself. I burned real time hunting for directory listings, common filename patterns, anything on the storage host — because I'd fixated on "the secret is the hash." Wrong layer. The hash was never the target. The mapping was.

The last piece turned a maybe into a critical: authorization. I opened a fresh session, dropped every cookie and auth header, and replayed the request cold — no login, nothing tying me to the account that owned the photo.

It returned the photo anyway.

The exploit

The endpoint performed no authorization check whatsoever. It never asked who was requesting this photo_id, or whether they were logged in at all. It just resolved the ID to a URL. Here's the entire attack, fully unauthenticated (IDs illustrative):

GET /photo/4210094817/x250/8800031544 HTTP/2
Host: cdn.example.com
HTTP/2 302 Found
Location: https://cdn.example.com/get/<sha256>.jpg

No Cookie. No Authorization. A 302 straight to a stranger's private photo. From there it's a for-loop:

import requests

col = 4210094817
for pid in range(8800031544, 8800040000):
    r = requests.get(
        f"https://cdn.example.com/photo/{col}/x250/{pid}",
        allow_redirects=False,
    )
    if r.status_code == 302:
        print(pid, r.headers["Location"])  # unguessable URL, served on demand

Walk the photo_id space, collect the Location headers, download at leisure. The unguessable filenames are no defense, because the server is the one guessing them for you.

The hash wasn't a secret — it was a lookup result. Unguessable output is worthless when an unauthenticated endpoint will compute it for any guessable input.

Impact

An unauthenticated attacker — no account, no cookies, nothing to trace back — can enumerate sequential IDs and harvest the real, downloadable URLs of private photos belonging to arbitrary users, at scale. On a photo-printing service that means family pictures, event albums, and whatever documents people scan to get printed. It's a mass privacy breach that needs nothing more than a laptop and a loop, and it leaves almost no fingerprint because each request looks like an ordinary image load. Information disclosure of the most personal category of user data there is.

The fix

The root cause is a missing authorization check on the resolver endpoint, so that's where the fix belongs:

  • Enforce object-level authorization on /photo/. Before returning a Location, the server must confirm the current session owns — or has been explicitly granted — the requested collection_id/photo_id. No session, no photo.
  • Reject unauthenticated requests outright with a 401 before any lookup happens.
  • Defense in depth: replace sequential integer IDs with unguessable identifiers (UUIDv4) so enumeration is hard even if an authz bug ever creeps back in, and add rate limiting plus anomaly detection to catch a client marching through the ID range.

Access control belongs on every endpoint that resolves an object — not just the login page, and not just the visible front door. Here, the most personal data on the platform sat one predictable integer away from anyone who thought to ask. The entire bug is that nobody taught the resolver how to say no.

IDORaccess-controlunauthenticatedinformation-disclosureenumeration

All identifiers, targets and payloads in this post are anonymized or defanged. Findings were reported and resolved through responsible disclosure.