CVE-2026-48839

WP Statistics – Simple, privacy-friendly Google Analytics alternative <= 14.16.6 - Unauthenticated Stored Cross-Site Scripting

highImproper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
7.2
CVSS Score
7.2
CVSS Score
high
Severity
14.16.7
Patched in
1d
Time to patch

Description

The WP Statistics – Simple, privacy-friendly Google Analytics alternative plugin for WordPress is vulnerable to Stored Cross-Site Scripting in versions up to, and including, 14.16.6 due to insufficient input sanitization and output escaping. This makes it possible for unauthenticated attackers to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Changed
Low
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=14.16.6
PublishedJune 1, 2026
Last updatedJune 1, 2026
Affected pluginwp-statistics

What Changed in the Fix

Changes introduced in v14.16.7

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

This research plan outlines the steps to investigate and exploit a Stored Cross-Site Scripting (XSS) vulnerability in the **WP Statistics** plugin (versions <= 14.16.6). --- ### 1. Vulnerability Summary * **Vulnerability:** Unauthenticated Stored Cross-Site Scripting (XSS). * **Location:** Vis…

Show full research plan

This research plan outlines the steps to investigate and exploit a Stored Cross-Site Scripting (XSS) vulnerability in the WP Statistics plugin (versions <= 14.16.6).


1. Vulnerability Summary

  • Vulnerability: Unauthenticated Stored Cross-Site Scripting (XSS).
  • Location: Visitor statistics recording logic and subsequent admin dashboard rendering.
  • Cause: The plugin records visitor details (Browser, Platform, Device) based on the User-Agent header. These values are stored in the database and later displayed in the WordPress admin dashboard templates (e.g., browsers.php, categories.php, platforms.php) without sufficient output escaping.
  • Affected Files:
    • includes/admin/templates/pages/devices/browsers.php
    • includes/admin/templates/pages/devices/categories.php
    • includes/admin/templates/pages/devices/platforms.php
    • includes/api/v2/class-wp-statistics-api-hit.php (REST API hit endpoint)

2. Attack Vector Analysis

  • Endpoint: Either the main site frontend (triggering automatic tracking) or the REST API endpoint /wp-json/wp-statistics/v2/hit.
  • Payload Location: The User-Agent HTTP request header.
  • Authentication: None (Unauthenticated).
  • Preconditions:
    • The plugin must be active and configured to track visits.
    • For the REST API attack: The "Cache Plugin" setting (use_cache_plugin) must be enabled.

3. Code Flow

  1. Entry Point (REST): WP_STATISTICS\Api\v2\Hit::hit_callback() is triggered via a POST request to wp-json/wp-statistics/v2/hit.
  2. Processing: hit_callback() calls WP_STATISTICS\Hits::record().
  3. Storage: Hits::record() extracts the User-Agent from the environment, uses a parser (likely DeviceHelper) to determine the browser/platform/device, and stores these strings in the database (typically the {prefix}statistics_visitor table).
  4. Admin Rendering: An administrator navigates to WP Statistics > Optimization or specific device reports.
  5. Sink: The template includes/admin/templates/pages/devices/browsers.php (and others) iterates through the stored visitors.
  6. XSS Trigger: The code executes:
    <span title="<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent); ?>" ...>
        ...
        <?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent); ?>
    </span>
    
    The function unknownToNotSet() merely returns the string if it is not "unknown". Since there is no esc_html() or esc_attr() surrounding the echo, the payload executes in the admin's browser.

4. Nonce Acquisition Strategy

If attacking via the REST API (hit endpoint), a signature check is performed:

  1. Action String: The REST route uses checkSignature($request).
  2. Acquisition:
    • Identify a page where WP Statistics tracking is active (usually all public pages).
    • Use browser_navigate to visit the homepage.
    • WP Statistics typically localizes data for its frontend script. Use browser_eval to look for variables like wp_statistics_tracker or WP_Statistics_L10n.
    • Specific to REST: Check window.WP_Statistics_REST_Config?.nonce or similar.
  3. Fallback: The vulnerability is "unauthenticated" because simple visits to the homepage with a spoofed User-Agent are recorded by the plugin's standard hook into wp or template_redirect if the REST hit API is not being used.

5. Exploitation Strategy

Method A: Direct Visit (Recommended)

  1. Payload: Mozilla/5.0 (X11; Linux x86_64) <script>alert(document.domain)</script>
  2. Request: Use http_request to send a GET request to the WordPress homepage.
  3. Headers: Set the User-Agent header to the payload above.
  4. Wait: The plugin may record hits asynchronously or on the next tick; usually, it is immediate.

Method B: REST API (If Method A fails)

  1. Endpoint: POST /wp-json/wp-statistics/v2/hit
  2. Headers:
    • User-Agent: "><img src=x onerror=alert(1)>
    • Content-Type: application/x-www-form-urlencoded
  3. Body Parameters:
    • page_uri: /
    • _wpnonce: (Acquired from JS context if signature check is enforced)

6. Test Data Setup

  1. Install Plugin: wp plugin install wp-statistics --version=14.16.6 --activate
  2. Enable Tracking: Ensure tracking is enabled (default behavior).
  3. Optional: To test the REST hit path, enable the cache setting:
    wp option patch update wp_statistics_options use_cache_plugin 1

7. Expected Results

  • The http_request should return a 200 OK (for Method A) or a JSON success response (for Method B).
  • When an administrator views the "Browsers" or "Devices" report, the injected script will execute.

8. Verification Steps

  1. Check Database: Use WP-CLI to verify the payload is stored:
    wp db query "SELECT agent FROM $(wp db prefix)statistics_visitor ORDER BY ID DESC LIMIT 1;"
  2. Confirm Sink: Navigate to the browser report page as an admin:
    browser_navigate("http://localhost:8080/wp-admin/admin.php?page=wp-statistics-browsers")
  3. Verify Execution: Use browser_eval to check for a global flag set by your payload (if using a more complex payload than alert).

9. Alternative Approaches

  • Platform Payload: If the browser parser filters certain characters, try targeting the Platform field by crafting a User-Agent that triggers a specific OS detection followed by the payload.
  • Device Category: Target categories.php by crafting a User-Agent that mimics a mobile device with an injected string.
  • Search Engine XSS: Check if the "Search Engines" or "Referrals" reports are also vulnerable by spoofing the Referer header with an XSS payload in the query parameters of a search engine URL.
Research Findings
Static analysis — not yet PoC-verified

Summary

The WP Statistics plugin for WordPress is vulnerable to unauthenticated Stored Cross-Site Scripting (XSS) due to a failure to sanitize and escape visitor information derived from the User-Agent header. An attacker can inject malicious scripts by visiting the site with a crafted User-Agent, which is then executed in the context of an administrator's browser when they view visitor reports.

Vulnerable Code

// includes/admin/templates/pages/devices/browsers.php lines 39-42
<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>

---

// includes/admin/templates/pages/devices/categories.php lines 35-37
<span title="<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->device); ?>" class="wps-model-name">
    <?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->device); ?>
</span>

---

// includes/admin/templates/pages/devices/platforms.php lines 36-39
<span title="<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->platform); ?>" class="wps-platform-name">
    <img alt="<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->platform); ?>" src="<?php echo esc_url(DeviceHelper::getPlatformLogo($item->platform)); ?>" class="log-tools wps-flag"/>
    <?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->platform); ?>
</span>

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.6/includes/admin/templates/pages/devices/browsers.php /home/deploy/wp-statistics/14.16.7/includes/admin/templates/pages/devices/browsers.php
--- /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.6/includes/admin/templates/pages/devices/browsers.php
+++ /home/deploy/wp-statistics/14.16.7/includes/admin/templates/pages/devices/browsers.php
@@ -36,9 +36,9 @@
                                 <?php foreach ($data['visitors'] as $item) : ?>
                                     <tr>
                                         <td class="wps-pd-l">
-                                                <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 title="<?php echo esc_attr(\WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent)); ?>" class="wps-browser-name">
+                                                    <img alt="<?php echo esc_attr(\WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent)); ?>" src="<?php echo esc_url(DeviceHelper::getBrowserLogo($item->agent)); ?>" class="log-tools wps-flag"/>
+                                                    <?php echo esc_html(\WP_STATISTICS\Admin_Template::unknownToNotSet($item->agent)); ?>
                                                 </span>
                                         </td>
                                         <td class="wps-pd-l">
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.6/includes/admin/templates/pages/devices/categories.php /home/deploy/wp-statistics/14.16.7/includes/admin/templates/pages/devices/categories.php
--- /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.6/includes/admin/templates/pages/devices/categories.php
+++ /home/deploy/wp-statistics/14.16.7/includes/admin/templates/pages/devices/categories.php
@@ -32,8 +32,8 @@
                                 <?php foreach ($data['visitors'] as $item) : ?>
                                     <tr>
                                         <td class="wps-pd-l">
-                                                <span title="<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->device); ?>" class="wps-model-name">
-                                                    <?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->device); ?>
+                                                <span title="<?php echo esc_attr(\WP_STATISTICS\Admin_Template::unknownToNotSet($item->device)); ?>" class="wps-model-name">
+                                                    <?php echo esc_html(\WP_STATISTICS\Admin_Template::unknownToNotSet($item->device)); ?>
                                                 </span>
                                         </td>
                                         <td class="wps-pd-l">
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.6/includes/admin/templates/pages/devices/platforms.php /home/deploy/wp-statistics/14.16.7/includes/admin/templates/pages/devices/platforms.php
--- /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.6/includes/admin/templates/pages/devices/platforms.php
+++ /home/deploy/wp-statistics/14.16.7/includes/admin/templates/pages/devices/platforms.php
@@ -33,9 +33,9 @@
                                 <?php foreach ($data['visitors'] as $item) : ?>
                                     <tr>
                                         <td class="wps-pd-l">
-                                                <span title="<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->platform); ?>" class="wps-platform-name">
-                                                    <img alt="<?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->platform); ?>" src="<?php echo esc_url(DeviceHelper::getPlatformLogo($item->platform)); ?>" class="log-tools wps-flag"/>
-                                                    <?php echo \WP_STATISTICS\Admin_Template::unknownToNotSet($item->platform); ?>
+                                                <span title="<?php echo esc_attr(\WP_STATISTICS\Admin_Template::unknownToNotSet($item->platform)); ?>" class="wps-platform-name">
+                                                    <img alt="<?php echo esc_attr(\WP_STATISTICS\Admin_Template::unknownToNotSet($item->platform)); ?>" src="<?php echo esc_url(DeviceHelper::getPlatformLogo($item->platform)); ?>" class="log-tools wps-flag"/>
+                                                    <?php echo esc_html(\WP_STATISTICS\Admin_Template::unknownToNotSet($item->platform)); ?>
                                                 </span>
                                         </td>
                                         <td class="wps-pd-l">

Exploit Outline

The exploit involves sending an unauthenticated HTTP request to the WordPress site (either to the homepage or the REST API hit endpoint) with a malicious User-Agent header. This payload, which contains JavaScript (e.g., using <script> or event handlers like onerror), is parsed by the plugin's device helper and stored in the database. The XSS triggers when an administrator navigates to the 'Browsers', 'Categories', or 'Platforms' report pages in the WP Statistics menu, as the plugin renders the stored agent/device/platform string directly into HTML attributes and content without escaping.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.