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

Three failed boundaries led to account takeover
  1. 01 / Badge pageStored JavaScript ran on a trusted origin
  2. 02 / Account APICredentialed CORS exposed account actions
  3. 03 / Email changeNo current password was required

ResultEmail changed, then password reset gave permanent control

  1. An unauthenticated account stored an XSS payload in its own badge name.
  2. A logged-in user opened the public badge link.
  3. The payload ran on the badge application's trusted origin.
  4. JavaScript called the separate account API with browser credentials attached.
  5. The permissive CORS policy allowed the script to read and write authenticated account data.
  6. The script requested an email change to an attacker-controlled address.
  7. The confirmation code arrived at that new address, with no current-password check.
  8. 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:

Redacted payload shape
<iframe srcdoc="&lt;script src=//REDACTED&gt;&lt;/script&gt;">

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.

Redacted response headers
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.

Account flow
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:

  1. 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.
  2. Credentialed CORS: cross-origin credentials are now limited to the two public read routes that need them. Account routes reject credentialed cross-origin requests.
  3. 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.