Investor Account IDOR Leaks Any User's Identity Record on a Wealth Platform
The Risk
Any logged-in investor on the platform could read the stored identity record of any other customer, with a single normal request. The leaked record included full legal name, address, phone number, and a direct pointer into the identity-verification provider that holds the customer's Social Security number, date of birth, and document images. Customer IDs were short numbers, so an attacker could walk the entire customer base in a few hours. The same request also overwrote the victim's name to a broken value that then appeared on the victim's own dashboard until staff manually restored it.
The Vulnerability
The platform exposed a customer-facing investor account API at /a/api/investor-entity/{id}. Every neighbouring sub-path on that resource correctly enforced an ownership check and returned 403 when called cross-account. One sub-path, PATCH /a/api/investor-entity/{id}/provider/etc, did not. The handler accepted a body, fetched the target entity from the database by ID, performed a third-party identity-provider call, persisted the response, and returned the stored entity, all with no request.user.id == entity.userId check.
Two structural facts made the break full-database in scope. First, entity IDs were short sequential integers exposed in normal URLs. Second, the response payload contained every stored field on the target entity record, not just the fields the request body touched. A one-field request body returned a multi-field response with the victim's data.
The Attack
Step 1: baseline read
From a victim session controlled by the researcher, read the victim entity normally and note the stored display name.
curl -s -b "Auth-Token=$VIC" \
"https://<platform>/a/api/investor-entity/$VICTIM_ENTITY" | jq .investorEntity.name Step 2: cross-account PATCH from the attacker session
The minimum body that passed schema validation was a single field. From the attacker session (a separate researcher-owned account), call the vulnerable sub-path against the victim entity ID:
curl -s -X PATCH -b "Auth-Token=$ATK" -H 'Content-Type: application/json' \
"https://<platform>/a/api/investor-entity/$VICTIM_ENTITY/provider/etc" \
-d '{"owner":{"ssn":"000000000"}}' Response: 200 with the victim's full stored entity record. Fields present in the response that were never in the request body included the victim's user ID, stored first and last name, account creation timestamp, linked third-party identity provider and banking provider names, stored legal address, phone number in international format, the identity-verification provider's user record ID, and the IDs of every linked investor account.
Step 3: confirm the integrity tamper
Re-reading the victim's entity from the victim session showed the stored display name now set to a literal broken value, persisted across sessions on both the entity and every linked investor account. Refreshing the victim's own dashboard, the greeting still used the intact first-name field but the account widget rendered the corrupted name string. Recovery required staff to restore the field from the identity provider source.
Step 4: authorisation comparison
Three neighbouring routes on the same entity path returned 403 from the attacker session, while only the vulnerable sub-path returned 200:
403 POST bank-account/default
403 POST investor-account
403 PATCH user/investor-profile
200 PATCH provider/etc The auth check existed throughout the rest of the resource. It was missing only on this one sub-path.
Real-user evidence
A single real production entity was probed during initial discovery and re-confirmed on a separate day. The response leaked the user's full legal address, phone number, identity-provider user record ID, and confirmed the third-party identity-verification provider and banking provider names. The corrupted name field persisted across both reads, demonstrating that the integrity tamper survived in production until manually remediated.
The Impact
For each cross-account hit, the response leaked: full legal first and last name, full legal address (street, city, state, ZIP), phone number, the identity-verification provider's user record ID (which is a direct pointer into the vault holding the customer's Social Security number, date of birth, and ID-document images), the identity-verification and banking provider names, account creation date, account type, and linked investor-account IDs.
Because entity IDs were short sequential integers, an attacker could enumerate the entire customer base in a few hours of automated requests. Each hit also persisted a broken display name on the victim's record, corrupting a regulated identity field that rendered immediately on the victim's own dashboard, statements, and account widgets. The bug shape and missing check made every user on the platform reachable through identical requests; this is a full-database identity-record disclosure with an integrity-tamper side effect, not a single-user finding.
The finding mapped directly to the program's stated focus areas: cross-account portfolio metadata access and reading data out of the underlying user database.
Remediation
- Add a server-side ownership check on
PATCH /a/api/investor-entity/{id}/provider/etcbefore any entity lookup, body validation, or response serialisation. Reject with the same403 investorEntity.forbiddenresponse already used by every neighbouring route whenrequest.user.id != entity.userId. - Audit every other handler on the same resource for the same gap. The existing pattern of per-route ownership checks is fragile; consider centralising the check in middleware that runs unconditionally for any path prefixed with
/a/api/investor-entity/{id}. - Restore the corrupted name fields on every affected entity by re-reading from the identity-verification provider source. Run a one-time scan for any other entities currently stored with the broken display value and restore those as existing victims of this bug.
- Audit access logs for historical cross-account hits on the vulnerable sub-path where the requester is not the entity owner. Given the missing check has been live, this surface may have been touched before.
- Replace short sequential integer entity IDs with unguessable identifiers (UUIDs or opaque slugs) so a future missing check does not become enumerable-by-default.