CVE-2026-94487 A nonce check that fails open in PublishPress Capabilities
Intrudify's autonomous pentesting engine discovered a cross-site request forgery vulnerability in PublishPress Capabilities, affecting all versions up to and including 2.50.1. The nonce check on the Roles screen delegates termination to a shared notification helper that only exits when it redirects. Suppress the redirect and a failed nonce check simply returns, so the write handler continues and creates an administrator-clone role.
Summary
PublishPress Capabilities manages WordPress roles and permissions. Intrudify's autonomous testing engine found that the Roles screen dispatches its write actions behind a nonce check that does not actually stop anything. Each handler begins by verifying the nonce and, on failure, calls a shared helper on the assumption that the helper terminates the request.
It only terminates when it redirects. The helper contains a branch that suppresses the redirect for one particular page value, and in that case it returns normally, so execution falls straight back into the handler and the write proceeds.
Technical detail
The guard that returns instead of dying
In class-pp-roles-actions.php, the non-AJAX branch of notify():
if (!empty($_REQUEST['page']) && ('pp-capabilities' == $_REQUEST['page'])) {
$redirect = false; // suppress the redirect
}
if ($redirect) { wp_safe_redirect(...); exit; } // only dies IF it redirectsWhen $_REQUEST['page'] is pp-capabilities, $redirect becomes false, the exit is never reached, and notify() returns. The calling handler for pp-roles-add-role, pp-roles-edit-role or pp-roles-delete-role carries on past the failed nonce check and performs the write.
One request, two pages at once
This is the part that makes it reachable, and it is worth understanding on its own. WordPress routes admin screens from $_GET['page']. The plugin's dispatch reads $_REQUEST['action'], and the guard reads $_REQUEST['page']. Since PHP merges POST over GET into $_REQUEST, a single request can present one page value to the router and a different one to the guard:
POST /wp-admin/admin.php?page=pp-capabilities-roles // routes to the Roles screen
body: page=pp-capabilities&action=pp-roles-add-role // the guard sees the other pageBoth values are required and they must differ. The URL value gets the request to the screen whose load hook fires the handler; the body value trips the branch that suppresses the redirect.
The tell
The response makes the failure visible if you read it. A single HTTP 200 carries both notices at once: Your link has expired, from the nonce check that failed, and a confirmation that the role was created successfully. The security control reports its own failure and the write happens anyway in the same response.
Verification and controls
We verified this live against 2.50.1. After a forged, nonce-less, cross-origin request, get_role("backdoor") returns a role carrying 76 capabilities including manage_options, edit_users and promote_users. The result is read out of the role object rather than inferred from a response status.
Two negative controls. An identical POST whose body carries the same page value as the URL, with no override, returns 302 and creates no role, so the nonce check holds when it is not bypassed. And a Subscriber or Contributor receives 403 on every variant, so the capability boundary is intact. Only the CSRF boundary fails, and we state it that way rather than claiming a privilege escalation the plugin does not have.
One reproduction note that otherwise looks like a fix. The installer grants the manage_capabilities_* capabilities to administrator on admin_init. A headless activation with no subsequent admin page load leaves them ungranted, and every request then returns 403. That is a false negative rather than a mitigation.
Impact
There is no authorization gate on these actions beyond the nonce, and the nonce is the thing that fails. An unauthenticated attacker who lures a logged-in administrator to a page that auto-submits the forged POST can, in a single request:
Create an administrator-clone role with 76 capabilities, which is a persistent backdoor surviving password changes. Or disable login for the administrator role entirely, locking every administrator out of the site while lower-privileged logins continue to work. Or rename any role, including administrator. Or delete a non-core role, migrating its users down.
The lockout variant is worth separating out because it is destructive rather than exploitative: a single forged request removes administrative access from the site with no way back in through the normal login flow.
Remediation
Update PublishPress Capabilities to 2.51.0 or later.
For maintainers, the fix belongs at the call site rather than in the helper. A nonce check should terminate the request itself, with wp_nonce_ays() or an explicit exit, instead of calling a notification function and assuming it will not return. Separately, read the routing value from $_GET rather than $_REQUEST so a request cannot present different page values to the router and to the guard.
The wider lesson
The defect is not a missing nonce check. The check is present, it runs, it correctly detects the forged request, and it reports the failure in the response. What is missing is the guarantee that detection stops execution.
That is the generalisable pattern: any security check whose enforcement is delegated to a helper that terminates only under some conditions is a check that fails open. The helper here was written to display notices, and termination was a side effect of one of its code paths. Once another path was added that skipped the redirect, every caller relying on it as a guard silently stopped being guarded.
It is also a reminder that $_REQUEST is a poor source for anything a security decision depends on. When the router and the guard read the same logical value from different superglobals, an attacker gets to choose which one each of them sees.
Disclosure timeline
- 2026-09-18 Vulnerability identified and validated by Intrudify, reported via Patchstack
- 2026-09-23 Public disclosure · CVE-2026-94487 assigned
Questions
What is CVE-2026-94487?
CVE-2026-94487 is a cross-site request forgery vulnerability in the PublishPress Capabilities WordPress plugin, versions 2.50.1 and earlier. It was discovered autonomously by the Intrudify AI penetration testing engine. The nonce guard on the Roles screen calls a shared helper that only terminates the request when it redirects, so suppressing the redirect lets a failed nonce check fall through and the write handler runs anyway.
What can an attacker do with CVE-2026-94487?
An unauthenticated attacker who lures a logged-in administrator to a page that auto-submits a forged POST can create an administrator-clone role with 76 capabilities including manage_options, edit_users and promote_users. Other variants disable login for the administrator role entirely, rename any role, or delete a non-core role.
How do I fix CVE-2026-94487?
Update PublishPress Capabilities to 2.51.0 or later. The underlying fix is for the nonce check to terminate the request itself rather than delegating termination to a notification helper that only exits when it redirects.