CVE-2026-5231

WP Statistics <= 14.16.4 - Unauthenticated Stored Cross-Site Scripting via 'utm_source' Parameter

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

Description

The WP Statistics plugin for WordPress is vulnerable to Stored Cross-Site Scripting via the 'utm_source' parameter in all versions up to, and including, 14.16.4. This is due to insufficient input sanitization and output escaping. The plugin's referral parser copies the raw utm_source value into the source_name field when a wildcard channel domain matches, and the chart renderer later inserts this value into legend markup via innerHTML without escaping. This makes it possible for unauthenticated attackers to inject arbitrary web scripts in admin pages that will execute whenever an administrator accesses the Referrals Overview or Social Media analytics pages.

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.4
PublishedApril 16, 2026
Last updatedApril 17, 2026
Affected pluginwp-statistics

What Changed in the Fix

Changes introduced in v14.16.5

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan - CVE-2026-5231 (WP Statistics) ## 1. Vulnerability Summary The **WP Statistics** plugin for WordPress is vulnerable to **Unauthenticated Stored Cross-Site Scripting (XSS)** via the `utm_source` parameter. The vulnerability exists because the plugin's referral parser ex…

Show full research plan

Exploitation Research Plan - CVE-2026-5231 (WP Statistics)

1. Vulnerability Summary

The WP Statistics plugin for WordPress is vulnerable to Unauthenticated Stored Cross-Site Scripting (XSS) via the utm_source parameter. The vulnerability exists because the plugin's referral parser extracts values from the utm_source query parameter and stores them unsanitized in the database (specifically the source_name field) when certain wildcard conditions are met. Later, when an administrator views statistics charts (like Referrals or Social Media analytics), the plugin's JavaScript charting component renders these values using the innerHTML property without prior escaping, allowing for arbitrary script execution in the context of the administrator's session.

2. Attack Vector Analysis

  • Endpoint: The tracking endpoint used by the plugin to record visitor hits. This is typically found at wp-json/wp-statistics/v2/hit or wp-admin/admin-ajax.php (if ad-blocker bypass is enabled).
  • Vulnerable Parameter: utm_source (passed via the URL of the page being tracked).
  • Authentication: Unauthenticated (any visitor can trigger a tracking hit).
  • Preconditions:
    • The plugin must be active and tracking visitors.
    • The utm_source must be processed by the "referral parser." This often requires the visitor to have a Referer header or for the URL to match a specific "channel" logic.
  • Vulnerable Sink (Client-side):
    • assets/dev/javascript/chart.js inside externalTooltipHandler.
    • assets/dev/javascript/components/traffic-hour-chart.js inside hourTooltipHandler.
    • Both functions concatenate dataset.label into an HTML string and set it via tooltipEl.innerHTML.

3. Code Flow

  1. Ingestion (Unauthenticated):
    • A visitor accesses a page: https://victim.com/?utm_source=<img src=x onerror=alert(1)>.
    • assets/js/tracker.js executes WpStatisticsUserTracker.init().
    • sendHitRequest() is called, which gathers parameters including page_uri (Base64 encoded current URL).
    • The hit request is sent to the server.
  2. Storage (Server-side):
    • The plugin receives the hit.
    • It decodes page_uri and parses the query string.
    • The referral parser identifies the utm_source.
    • The raw, unsanitized value is saved into the database (field source_name).
  3. Execution (Authenticated Admin):
    • Admin visits wp-admin/admin.php?page=wps_referrals_page (Referrals Overview).
    • The page loads chart data via an internal AJAX/REST call.
    • The chart data includes the malicious source_name as a label for a dataset.
    • assets/dev/javascript/components/traffic-hour-chart.js triggers hourTooltipHandler when the admin hovers over the chart.
    • innerHtml += ... ${dataset.label} ... (line 51) includes the payload.
    • tooltipEl.innerHTML = innerHtml; (line 92) executes the script.

4. Nonce Acquisition Strategy

Tracking hits in WP Statistics often require a nonce found in the WP_Statistics_Tracker_Object.

  1. Shortcode Identification: The plugin typically enqueues the tracker on all public pages if tracking is enabled.
  2. Extraction:
    • Use browser_navigate to the homepage.
    • Use browser_eval to extract the configuration object:
      JSON.stringify(window.WP_Statistics_Tracker_Object)
      
  3. Key Components:
    • Endpoint: WP_Statistics_Tracker_Object.hitParams.endpoint
    • Nonce: WP_Statistics_Tracker_Object.hitParams.wp_nonce (if present) or checked via the REST API headers.
    • Rest URL: WP_Statistics_Tracker_Object.requestUrl

5. Exploitation Strategy

  1. Visit Site with Payload: Navigate to the site with the payload in the URL. This ensures the tracker picks it up naturally.
    • URL: http://localhost:8080/?utm_source=<img src=x onerror=alert(window.origin)>
  2. Manual Hit Trigger (Backup): If the automatic hit doesn't trigger, manually send the POST request to the tracking endpoint.
    • Method: POST
    • URL: From WP_Statistics_Tracker_Object.requestUrl + / + WP_Statistics_Tracker_Object.hitParams.endpoint
    • Headers: Content-Type: application/x-www-form-urlencoded
    • Body (URL Encoded):
      • referred: http://www.google.com (to trigger referral parser)
      • page_uri: Base64 of /?utm_source=<img src=x onerror=alert(window.origin)>
      • Include all other keys from WP_Statistics_Tracker_Object.hitParams.
  3. Trigger Admin Execution:
    • Log in as an administrator.
    • Navigate to the Referrals page: /wp-admin/admin.php?page=wps_referrals_page.
    • Hover over the chart elements to trigger the tooltip containing the innerHTML sink.

6. Test Data Setup

  1. Plugin Configuration: Ensure WP Statistics is installed and activated.
  2. Public Content: A standard post or page must exist (the default "Hello World" is sufficient).
  3. Referral Source: To ensure the "wildcard channel" matches, it is recommended to set a Referer header like https://www.google.com or https://twitter.com during the hit request.

7. Expected Results

  • The tracking hit will be successfully recorded.
  • The payload <img src=x onerror=alert(window.origin)> will appear unescaped in the database.
  • When an administrator views the Referrals chart and hovers over the malicious data point, a JavaScript alert showing the site's origin will appear.

8. Verification Steps

  1. Database Check: Run a WP-CLI command to verify the payload is stored:
    wp db query "SELECT * FROM wp_statistics_visitor WHERE last_counter LIKE '%utm_source%';"
    # Or check the referrals table
    wp db query "SELECT source_name FROM wp_statistics_search WHERE source_name LIKE '%<img%';"
    
  2. DOM Check: Using browser_eval while on the admin referrals page:
    • Check if the chart data contains the payload:
      // Check Chart.js instances for the payload in labels
      Chart.instances[0].data.datasets.map(d => d.label)
      

9. Alternative Approaches

  • Social Media Channel: Try the payload via utm_source=facebook and put the payload in the utm_medium or utm_campaign if utm_source is filtered (though the CVE specifically points to utm_source).
  • Direct REST API Injection: If the tracker logic is complex, send the POST request directly to wp-json/wp-statistics/v2/hit with the page_uri parameter containing the URL-encoded payload.
  • Bypass Ad-Blocker Endpoint: If the REST API is restricted, use admin-ajax.php?action=wp_statistics_hit.
Research Findings
Static analysis — not yet PoC-verified

Summary

The WP Statistics plugin for WordPress is vulnerable to unauthenticated stored Cross-Site Scripting via the 'utm_source' parameter. This occurs because the plugin's referral parser fails to sanitize input stored in the 'source_name' field, which is subsequently rendered in administrative charts using innerHTML without escaping.

Vulnerable Code

// assets/dev/javascript/chart.js around line 125
                innerHtml += `
                <div class="current-data">
                    <div>
                        <span class="current-data__color" style="background-color: ${dataset.hoverPointBackgroundColor};"></span>
                        ${dataset.label}
                    </div>
                    <span class="current-data__value">${value.toLocaleString()}</span>
                </div>`;

---

// assets/dev/javascript/components/traffic-hour-chart.js around line 44
                        innerHtml += `
                        <div class="current-data">
                            <div>
                                <span class="current-data__color" style="background-color: ${dataset.backgroundColor};"></span>
                                ${dataset.label}
                            </div>
                            <span class="current-data__value">${value.toLocaleString()}</span>
                        </div>`;

---

// assets/dev/javascript/helper.js around line 348
            let labelDiv = document.createElement('div');
            labelDiv.innerHTML = labels[i];
            labelDiv.setAttribute('aria-label', labels[i]);

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.4/assets/dev/javascript/chart.js /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.5/assets/dev/javascript/chart.js
--- /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.4/assets/dev/javascript/chart.js	2025-12-01 07:17:10.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.5/assets/dev/javascript/chart.js	2026-04-11 07:13:12.000000000 +0000
@@ -125,7 +125,7 @@
                 <div class="current-data">
                     <div>
                         <span class="current-data__color" style="background-color: ${dataset.hoverPointBackgroundColor};"></span>
-                        ${dataset.label}
+                        ${wps_js.escapeHtml(dataset.label)}
                     </div>
                     <span class="current-data__value">${value.toLocaleString()}</span>
                 </div>`;
@@ -496,7 +496,7 @@
                 </div>` : '';
 
                 legendItem.innerHTML = `
-                <span>${dataset.label}</span>
+                <span>${wps_js.escapeHtml(dataset.label)}</span>
                 <div>
                     <div class="current-data">
                         <span class="wps-postbox-chart--item--color" style="border-color: ${dataset.borderColor}"></span>
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.4/assets/dev/javascript/components/traffic-hour-chart.js /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.5/assets/dev/javascript/components/traffic-hour-chart.js
--- /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.4/assets/dev/javascript/components/traffic-hour-chart.js	2025-12-01 07:17:10.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.5/assets/dev/javascript/components/traffic-hour-chart.js	2026-04-11 07:13:12.000000000 +0000
@@ -44,7 +44,7 @@
                         <div class="current-data">
                             <div>
                                 <span class="current-data__color" style="background-color: ${dataset.backgroundColor};"></span>
-                                ${dataset.label}
+                                ${wps_js.escapeHtml(dataset.label)}
                             </div>
                             <span class="current-data__value">${value.toLocaleString()}</span>
                         </div>`;
@@ -272,7 +272,7 @@
                     // Build the legend item HTML
                     legendItem.innerHTML = `
                             <span class="current-data">
-                                 <span class="wps-postbox-chart--item--color" style="border-color: ${dataset.backgroundColor}"></span> ${dataset.label}
+                                 <span class="wps-postbox-chart--item--color" style="border-color: ${dataset.backgroundColor}"></span> ${wps_js.escapeHtml(dataset.label)}
                             </span>
                            `;
 
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.4/assets/dev/javascript/config.js /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.5/assets/dev/javascript/config.js
--- /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.4/assets/dev/javascript/config.js	2022-06-04 14:37:24.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.5/assets/dev/javascript/config.js	2026-04-11 07:13:12.000000000 +0000
@@ -1,6 +1,12 @@
 /* Start Wp-statistics Admin Js */
 var wps_js = {};
 
+var _htmlEscapeMap = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'};
+wps_js.escapeHtml = function (str) {
+    if (typeof str !== 'string') return str == null ? '' : String(str);
+    return str.replace(/[&<>"']/g, function (c) { return _htmlEscapeMap[c]; });
+};
+
 /* Get WP Statistics global Data From Frontend */
 wps_js.global = (typeof wps_global != 'undefined') ? wps_global : [];
 
Only in /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.4/assets/dev/javascript: Tinymce
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.4/assets/dev/javascript/helper.js /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.5/assets/dev/javascript/helper.js
--- /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.4/assets/dev/javascript/helper.js	2025-12-01 07:17:10.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wp-statistics/14.16.5/assets/dev/javascript/helper.js	2026-04-11 07:13:12.000000000 +0000
@@ -345,7 +345,7 @@
                 labelImageDiv.appendChild(img);
             }
             let labelDiv = document.createElement('div');
-            labelDiv.innerHTML = labels[i];
+            labelDiv.innerHTML = wps_js.escapeHtml(labels[i]);
             labelDiv.setAttribute('aria-label', labels[i]);
             labelDiv.classList.add('wps-horizontal-bar__label');
             labelImageDiv.appendChild(labelDiv);

Exploit Outline

The exploit is unauthenticated and can be triggered by a visitor navigating to the target site with a malicious payload in the 'utm_source' query parameter (e.g., `/?utm_source=<img src=x onerror=alert(1)>`). The plugin's client-side tracker sends this URL to the tracking endpoint (usually `wp-json/wp-statistics/v2/hit`), where the server-side referral parser stores the raw payload in the database. When a site administrator views the analytics dashboard (specifically the Referrals or Social Media pages), the malicious script is rendered via an insecure `innerHTML` call in the charting component, executing the payload in the admin's session context.

Check if your site is affected.

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