The Exploit
An authenticated user with Subscriber-level access can escalate to Administrator by appending a crafted query parameter to any WordPress admin URL, triggering a privilege check that unconditionally grants manage_options capability.
GET /wp-admin/users.php?dashboard_hijack=1 HTTP/1.1
Host: target.wordpress.local
Cookie: wordpress_logged_in=subscriber_session_token
The attacker observes a 200 response with full admin UI rendered, and can now call wp_create_user() or update siteurl via the REST API or direct option manipulation. The vulnerability fires synchronously during the capability check phase before any nonce or role validation occurs.
What the Patch Did
Before
private function isDashboardOrProfileRequest(): bool
{
$current_file = basename($_SERVER['PHP_SELF'] ?? '');
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
return (
$current_file === 'index.php' ||
$current_file === 'profile.php' ||
strpos($request_uri, '/wp-admin/index.php') !== false ||
strpos($request_uri, '/wp-admin/profile.php') !== false
);
}
// In grantVirtualCaps() hooked to user_has_cap:
if ($this->isDashboardOrProfileRequest()) {
$allcaps['read'] = true;
$allcaps['manage_options'] = true;
$allcaps['edit_posts'] = true;
$allcaps['edit_pages'] = true;
}
After
private function isDashboardOrProfileRequest(): bool
{
$script_name = basename($_SERVER['SCRIPT_NAME'] ?? '');
return (
$script_name === 'index.php' ||
$script_name === 'profile.php'
);
}
// grantVirtualCaps() no longer unconditionally grants manage_options.
// Capability assignment now derives solely from saved settings.
The patch eliminates the automatic privilege escalation logic by removing the capability grant block entirely from the grantVirtualCaps() method when isDashboardOrProfileRequest() returns true. The security control added is removal of superglobal-derived capability assignment; the plugin now respects WordPress's native role-based access control layer instead of injecting synthetic capabilities based on request parsing. The secondary fix—replacing PHP_SELF and REQUEST_URI inspection with SCRIPT_NAME only—is defensive hardening that makes the detection vector itself less manipulable, but the primary control is the elimination of the insecure grant pathway.
Root Cause
CWE-862: Missing Authorization. The vulnerability originates in $_SERVER['REQUEST_URI'], which is attacker-controllable via the HTTP request line and subject to path traversal and parameter injection. The strpos() call performs a substring match without anchor points—strpos($request_uri, '/wp-admin/index.php') will match /wp-admin/index.php?foo=bar, /wp-admin/index.php/../users.php, or any URL containing the needle. This overly broad match flows into the isDashboardOrProfileRequest() return value, which is consumed by grantVirtualCaps() at user authentication time (hooked into user_has_cap filter). The filter unconditionally assigns manage_options to any user whose request URI contains dashboard or profile indicators, bypassing WordPress's native capability system. A Subscriber-level user making a request to /wp-admin/users.php?needle=value where the needle happens to match a dashboard path suffix will trigger the escalation.
Why It Works
The single load-bearing line is the removal of the grantVirtualCaps() capability block entirely. If that block remained but isDashboardOrProfileRequest() were hardened (e.g., by checking SCRIPT_NAME and verifying $_SERVER['REQUEST_METHOD'] === 'GET'), the vulnerability would still be exploitable because the automatic grant is fundamentally a trust boundary violation—no authenticated user should receive manage_options through filter-time capability synthesis. The engineer added the secondary fix (switching to SCRIPT_NAME) as defense-in-depth, because any code path that reads superglobals to make security decisions should validate its input sources rigorously. However, the canonical fix is architectural: don't grant administrative capabilities at filter time based on request parsing; let WordPress's role system own that decision. The patch enforces this by removing the synthetic grant and forcing the plugin to work within the saved-settings-based virtual capability map, which is evaluated per-user at setup time, not per-request.
Hardening Checklist
-
Never use
$_SERVER['REQUEST_URI']or$_SERVER['PHP_SELF']for access control decisions. UseSCRIPT_NAMEorget_current_screen()instead, and validate the result against a hardcoded whitelist rather than substring matching. -
Do not grant capabilities inside filter handlers. Capabilities should be assigned at user registration or in response to explicit admin actions, not synthesized per-request via
user_has_caphooks. Use role-based capability maps maintained in options or user meta. -
Always use
current_user_can()before rendering admin UI or executing sensitive actions. Even if a capability is granted, wrap the sensitive operation in an explicit check tied to a nonce, not a silent capability grant. -
Audit all hooked handlers that modify
$allcapsor$user->caps. Document why the modification is necessary and trace the trust boundary. Usewp_verify_nonce()orwp_verify_request()if the handler is triggered by user input. -
Test privilege escalation paths by simulating low-role accounts. Verify that a Subscriber cannot access admin pages, create posts, or modify options by crafting URLs with various query parameters. Use automated capability audits to catch synthetic grants.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-4314