CVE-2026-93770 One unescaped anchor, and a way to reach it in WP Statistics

Intrudify's autonomous pentesting engine discovered a cross-site scripting vulnerability in WP Statistics, affecting all versions up to and including 14.16.13. Three things combine: one anchor in the Author Analytics header is missing esc_url() while its two neighbours have it, the URL builder behind it is explicitly configured not to encode, and a split between the GET and POST values of the same parameter lets an attacker reach that template while passing a different page's capability check.

Discovered by
Intrudify autonomous engine
Validated & reported by
Tudor Lasuschevici
Disclosure
24 September 2026
Severity
High · CVSS 7.1
Vulnerability class
Cross-site scripting, CWE-79
Affected
WP Statistics ≤ 14.16.13
Fixed in
14.16.14
Active installs
600,000+
Attacker privilege
None, unauthenticated
Victim
Any logged-in WP Statistics user
Execution
On page load, no click or hover

Summary

WP Statistics is a self-hosted analytics plugin. Intrudify's autonomous testing engine found that the Author Analytics header template reflects the author_id request parameter into an href attribute with no output escaping.

That alone would be a minor issue on a page only privileged users can open. The finding is that an unauthenticated attacker can cause a victim's browser to load that template through a page they are entitled to view, so the payload executes inside wp-admin in the victim's session.

Technical detail

One anchor of three

In views/components/headers/author-analytics.php, the parameter is read at the top of the file and echoed into an anchor further down:

$authorId = Request::get('author_id');                          // line 8
...
<a href="<?php echo Menus::admin_url('pages',
    ['tab' => 'top', 'author_id' => $authorId, 'pt' => 'post']); ?>">   // line 34

The two neighbouring anchors, on lines 24 and 33, both wrap the same call in esc_url(). Line 34 does not. This is a single omission in a template that otherwise gets it right.

Why the value arrives unescaped

Three steps each fail to help, and none of them is obviously wrong in isolation.

Request::get() returns sanitize_text_field() of the raw value. That strips tags but leaves double quotes intact, and the payload contains no < at all, so nothing is removed.

Menus::admin_url() passes the array to add_query_arg(), which builds the query string through build_query(). That helper calls _http_build_query() with its encoding argument set to false, so the raw double quote is written into the URL verbatim rather than percent-encoded.

The template then echoes the result with no esc_url(). A double quote therefore closes the href attribute and everything after it becomes attributes on the anchor element.

Working around the added slash

WordPress backslash-escapes quotes in request parameters, which normally blunts this kind of breakout. Here it does not, because the payload needs only one quote to escape the attribute and then uses unquoted HTML attribute syntax, which requires no further quotes:

// author_id value, abbreviated. no quotes after the first.
1" style=animation-name:spin;animation-duration:1s onanimationstart=...//

HTML ignores the stray backslash, the quote still terminates the attribute, and the unquoted attributes that follow are parsed as real attributes. The plugin's own admin.min.css already defines @keyframes spin, so the animation begins as soon as the element renders and the handler fires with no user interaction beyond viewing the page.

Reaching the template: one request, two admin pages

This is the part that turns a template bug into an exploitable one. The header is loaded when Menus::in_page('author-analytics') is true, and in_page() reads $_REQUEST['page']. WordPress itself routes the admin screen from $_GET['page'], and the capability check applies to whichever page that resolves to.

POST /wp-admin/admin.php?page=wps_overview_page&type=single-author&author_id=<payload>
body: page=wps_author-analytics_page

The query string routes the request to the Overview screen, which every WP Statistics user may open, so the capability check passes. PHP builds $_REQUEST from GET then POST, so $_REQUEST['page'] resolves to the author-analytics slug, and the layout loads the vulnerable header. One request presents two different page identities to two different consumers.

Control

As a negative control, the same request with a numeric author_id renders an ordinary link with no additional attributes and nothing executing. The behaviour is attributable to the payload rather than to the template in general.

Impact

The attacker needs no account. They need a logged-in WP Statistics user to open a link or a page that auto-submits the request, and by default that user is an administrator.

Execution happens inside /wp-admin/ in that user's session, on page load, with no click and no hover. From there the usual consequences follow: session-scoped administrative actions, account creation, or plugin installation.

Remediation

Update WP Statistics to 14.16.14 or later.

For maintainers, there are two independent defects and fixing either breaks the chain. Wrap the anchor on line 34 in esc_url(), matching the two anchors immediately around it. And resolve the current admin page from $_GET rather than $_REQUEST, so the screen a capability check authorises is the same screen the templates believe they are rendering.

The wider lesson

The escaping omission is the kind of defect that is trivial to describe and hard to find. Two correct anchors sit within ten lines of the vulnerable one, so the file looks careful. A reviewer scanning for unescaped output in a template that visibly uses esc_url() is primed to conclude it is handled. Consistency is exactly what makes a single inconsistency invisible.

The more interesting half is the routing. WordPress resolves the admin page from $_GET, the plugin resolves it from $_REQUEST, and those are the same value almost always. Because they are not the same value when an attacker chooses otherwise, the capability check and the template dispatch can be pointed at different pages simultaneously. Any authorisation decision and the code it is meant to protect must read their inputs from the same place, or the check guards something other than what runs.

That is the second finding in this set where $_REQUEST lets one request wear two identities. It is worth treating as a pattern rather than an isolated mistake.

Disclosure timeline

  • 2026-09-18 Vulnerability identified and validated by Intrudify, reported via Patchstack
  • 2026-09-24 Public disclosure · CVE-2026-93770 assigned

Questions

What is CVE-2026-93770?

CVE-2026-93770 is a cross-site scripting vulnerability in the WP Statistics WordPress plugin, versions 14.16.13 and earlier. It was discovered autonomously by the Intrudify AI penetration testing engine. An unescaped anchor in the Author Analytics header reflects a request parameter into an href attribute, and a GET versus POST split on the page parameter lets an unauthenticated attacker reach that template while passing a different page's capability check.

Who is affected by CVE-2026-93770?

Any logged-in WP Statistics user who opens the attacker's link, which by default means an administrator. The payload executes inside wp-admin with no click and no hover, because it uses a CSS animation whose keyframes the plugin's own admin stylesheet already defines.

How do I fix CVE-2026-93770?

Update WP Statistics to version 14.16.14 or later. The underlying fix is to wrap the anchor in esc_url, matching the two neighbouring anchors in the same template, and to resolve the admin page from $_GET rather than $_REQUEST.

References

More advisories from Intrudify

Join the Future of
AI-Driven Pentesting