From a Free Account to Every Advertiser’s Address: A Broken Access Control Walk-Through

TLP: CLEAR · May 2026 · Field Note · Confidence: High

Summary

A paginated listing at GET /advertisers/index on a self-serve advertising platform returned the entire platform-wide advertiser directory to any authenticated user. No role check, no object-level scoping — a freshly registered account sitting in “Awaiting Approval” status could walk 72 pages and read the names and street addresses of every advertiser in the system, 1,440+ records at the time of testing.

The platform, host, and all identifiers are redacted; the bug was reported through a coordinated programme. The mechanics below are reconstructed faithfully with anything that would make it copy-paste reproducible against the live estate removed.

1. How It Surfaced

The platform is a legacy PHP application (CakePHP, served by Apache on Ubuntu) under a wide wildcard scope. Anonymous requests to private paths got a 302 → /login — but the interesting question was never “is there a login wall”. It was whether the application’s internal auth model was are you logged in or are you allowed to see this. On a stack where admin sections are just controllers that live one path away, the first model is extremely common.

Registration was free and required only an email verification step. Within minutes I held a real account whose status was “Awaiting Approval” — the lowest privilege level the platform defines.

2. The Hypothesis

Specific and falsifiable: the /advertisers/index action paginates the whole advertisers table with no role check, and any authenticated session is enough to read it. The endpoint name has index in it — CakePHP’s conventional scaffold action — and scaffolded actions are exactly the ones that ship without hand-written authorization.

3. Confirming the Primitive

One direct navigation, no admin UI, no hidden link:

GET /advertisers/index HTTP/1.1
Host: <host>
Cookie: PHPSESSID=<session_cookie>

HTTP 200 with a full HTML table — 20 rows per page, keys ?page=2 through ?page=72 continuing the same output. Each row exposed the advertiser ID, internal user ID, business category, full name, street address, suburb/state/postcode, and account timestamps:

ID=<seq> | UserID=<n> | Type=Accommodation | Name=<advertiser> |
Address=<street>, <suburb> <STATE> <postcode> | Created=<date>

Two things confirmed this was the live production table and not a fixture. First, my own test account — registered minutes earlier — appeared as the last record on page 72, behind every other user in the system. Second, the unauthenticated path returned 302 → /login: the platform itself classifies this data as private, and any logged-in user was treated as entitled to all of it.

4. Impact

The exposure is cross-user PII entered for platform use only — not displayed anywhere on the public side of the product. Two records sharpened the sensitivity beyond “just a directory”:

On scoping: this is a read-only primitive. I sampled across the pagination range to establish shape and scale rather than harvesting the full table, and confirmed no write or modification surface existed through the same path. Reported as a P3 broken-access-control finding: no admin privilege, minimal effort, but data exposure multiplied by population.

5. Root Cause

The index() action called $this->paginate() with no query scoping and no role gate — the CakePHP default returns the entire table. The controller assumed that any request reaching it was legitimate, which was true only while the session layer was the only gate. The framework’s role-based checks were simply never applied to this action.

6. Remediation

7. Closing Note

This is the cheapest class of bug a wide-scope programme will pay for: a role check missing from a scaffolded action, found in the time between registering a free account and finishing the email verification step. The value was in reading the 302 → /login as a statement of intended sensitivity — the platform told me the data was private, and the controller disagreed.