The Exploit
An unauthenticated attacker can inject arbitrary JavaScript into WP Statistics analytics pages by poisoning the User-Agent HTTP header with a malicious payload. The stored XSS fires whenever a logged-in user visits the analytics dashboard, allowing cookie theft, session hijacking, or admin account takeover.
Step 1: Inject payload via User-Agent header
GET / HTTP/1.1
Host: vulnerable-wordpress.local
User-Agent: <img src=x
Connection: close
Step 2: Trigger the stored payload by visiting analytics dashboard
GET /wp-admin/admin.php?page=wp_statistics_dashboard HTTP/1.1
Host: vulnerable-wordpress.local
Cookie: wordpress_logged_in_xxx=admin_session_token
Connection: close
The dashboard renders the user-agent string from previous visitors in device tables without escaping. The payload executes in the admin's browser context. An attacker observes the admin's session cookie exfiltrated to their server, or sees reflected admin actions (dashboard modifications, plugin installation) performed by JavaScript running under the admin's privileges.
The vulnerability persists because WP Statistics stores the unsanitized User-Agent header in its analytics database on every page visit, then displays it without output encoding in admin templates.
What the Patch Did
Before:
return apply_filters('wp_statistics_user_http_agent', (isset($_SERVER['HTTP_USER_AGENT']) ? wp_unslash($_SERVER['HTTP_USER_AGENT']) : ''));
After:
return apply_filters('wp_statistics_user_http_agent', isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '');
The patch added sanitize_text_field() to strip HTML tags and dangerous characters from the HTTP User-Agent header at ingestion time. wp_unslash() alone only removes WordPress magic quotes and does not provide sanitization. sanitize_text_field() is WordPress's standard utility for normalizing user-supplied text fields—it removes all HTML markup and encodes entities, rendering <img src=x> harmless as literal text.
The plugin also applied output escaping to the devices model template:
Before:
<span title="<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->model); ?>" class="wps-model-name">
<?php echo self::isUnknown($item->model) ? esc_html__('Unknown', 'wp-statistics') : $item->model; ?>
After:
<span title="<?php echo esc_attr(\WP_STATISTICS\Admin_Template::unknownToNotSet($item->model)); ?>" class="wps-model-name">
<?php echo self::isUnknown($item->model) ? esc_html__('Unknown', 'wp-statistics') : esc_html($item->model); ?>
Here, esc_attr() escapes the title attribute context and esc_html() escapes HTML content. These are defence-in-depth measures: even if a malicious string bypassed sanitization at ingestion, output escaping would neutralize it before rendering.
Root Cause
CWE-79: Improper Neutralization of Input During Web Page Generation (Cross-site Scripting).
The HTTP User-Agent header is attacker-controlled—the browser sends it with every request, and an attacker fully controls its value via HTTP client libraries or proxy tools. The plugin's User-Agent ingestion function (src/Service/Analytics/DeviceDetection/UserAgent.php, line 14) reads $_SERVER['HTTP_USER_AGENT'] and passes it through wp_unslash() without sanitization, then returns the raw string via apply_filters().
This unsanitized value is stored in the analytics database during normal request processing. Later, when an admin visits the dashboard, the plugin queries stored User-Agent records and outputs them in HTML templates (e.g., includes/admin/templates/pages/devices/models.php) without escaping. The attacker-controlled data crosses a trust boundary: from the HTTP layer (untrusted user input) directly into the WordPress database (persisted as authoritative data) and then into HTML output (rendered as executable code).
Why It Works
The load-bearing line is sanitize_text_field() in the ingestion path. Removing only that function would leave the vulnerability exploitable: an attacker's <img> payload would still traverse into the database and could still execute if output escaping were ever forgotten or misconfigured elsewhere.
However, the engineer correctly applied defence-in-depth: they sanitized at ingestion (closest to the source) and also added output escaping at every render point. This two-layer approach is critical in WordPress plugins because:
- Sanitization at ingestion prevents the malicious data from entering the database and makes future uses safer by default.
- Output escaping at render time is a failsafe—if sanitization is incomplete or a future code path forgets to sanitize, escaping still stops XSS.
sanitize_text_field() is the critical fix; esc_attr() and esc_html() are the safety net. Together, they follow WordPress security fundamentals: "sanitize on input, escape on output."
Hardening Checklist
- Apply
sanitize_text_field()to all HTTP headers used for storage or display, not just User-Agent. Headers like Referer, Accept-Language, and custom headers often carry untrusted data. - Use context-appropriate output escaping functions:
esc_html()for HTML content,esc_attr()for attributes,esc_url()for URLs, andesc_js()for JavaScript strings. Review allechostatements in admin templates against this list. - Run PHPCS with WordPress-Coding-Standards ruleset to catch missing sanitization (
sanitize_*functions) and escaping (esc_*functions) at code review time, not production. - Do not trust
apply_filters()to sanitize. Filters are meant for extensibility, not security. Sanitize before the filter, not after. - Audit CLI command handlers and batch import routines for direct assignment to
$_SERVERsuperglobals without validation—these are often overlooked in threat models but allow attackers to spoof IP addresses, User-Agents, and referrers on a per-request basis.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-9816