wp_ajax_nopriv_ means "no privilege required" quite literally, and never calls current_user_can() or validates the real bytes of an upload. This week's feed carries two textbook instances: CVE-2026-9067 in the "Schema & Structured Data for WP & AMP" plugin (unauthenticated arbitrary file upload, CWE-434, CVSS 9.1) and CVE-2025-6254 in Doctreat Core (unauthenticated privilege escalation to administrator via unrestricted role registration, CWE-269, CVSS 9.8). Different plugins, same root cause: a frontend handler that skipped the authorization check. This piece walks the decision tree from one such handler to full site or server control, and the single class test that protects customers across the whole plugin ecosystem rather than one CVE at a time.
There is a comfortable way to track WordPress risk, and it is wrong. The comfortable way is to subscribe to a CVE feed, match the plugin name and version against your install list, and patch the matches. It feels like coverage. It is always a step behind, because the supply of vulnerable plugins is effectively unbounded: tens of thousands of plugins, written by tens of thousands of authors of wildly varying security maturity, each shipping new versions every week. A CVE-by-CVE scanner can only see the failures that have already been found, named, and published. The failures that ship tomorrow are invisible to it until someone else gets breached first.
This is part of our attack-research series on the classes that ship despite a decade of patches. Earlier pieces walked SSRF into cloud metadata, JWT confusion, and cross-tenant isolation as classes rather than individual bugs. This one does the same for the most prolific source of real-world web compromise we see: the WordPress frontend handler that forgets who is allowed to call it. Verifiable security.
The attack pattern in one paragraph
WordPress lets plugins expose server-side actions to the browser through the admin-ajax mechanism. A plugin registers a callback under add_action('wp_ajax_myaction', ...) for logged-in users and, crucially, under add_action('wp_ajax_nopriv_myaction', ...) for anonymous visitors when a feature needs to work on the public frontend: a contact form, an image uploader, a registration step. The trap is that registering the nopriv hook is a deliberate decision to accept unauthenticated callers, and the burden then falls entirely on the callback to enforce its own authorization. Two checks are routinely missing. First, the capability check: the callback never calls current_user_can('upload_files') or its equivalent, so an anonymous request reaches privileged logic. Second, the content-type validation: an upload handler trusts the client-supplied Content-Type header or the file extension instead of inspecting the actual bytes, so a request that claims image/png sails through even when its body is something else entirely. A sibling of the same family is open role registration: a registration handler reads the desired role from request input and passes it to wp_insert_user() without an allow-list, so the attacker simply asks to be an administrator. In all three, the code runs because nothing ever asked whether the caller was allowed to run it.
The unifying observation: these handlers were written for a trusted caller and deployed for an untrusted one. The attacker does not break a control. There is no control to break.
Why this still ships in 2026
If "check the capability before doing the privileged thing" is the most basic rule in the platform's own developer handbook, why does a new instance land in the feed almost every week? Four structural reasons.
- The
noprivhook makes "unauthenticated" the easy default. A developer building a frontend feature finds that their AJAX action does not fire for logged-out visitors, searches, and learns they need thewp_ajax_nopriv_variant. They add it, the feature works, and they move on. The hook does exactly what it says, opens the action to anyone, and the developer rarely circles back to add the authorization the open door now requires. - Client-supplied content type looks like validation but is not. Checking that the uploaded file's
Content-Typeisimage/pngfeels like a real check. It is attacker-controlled metadata. As the WPScan write-up for CVE-2026-9067 states plainly, the plugin "checks only the client-supplied Content-Type against an image MIME allowlist, so spoofing it toimage/pngis enough to pass the plugin's check." The OWASP guidance is blunt: the content type "is provided by the user, and as such cannot be trusted, as it is trivial to spoof." - Role assignment is one parameter away from privilege escalation. WordPress registration handlers that let a site offer custom roles read the requested role from input. Without an explicit allow-list of self-service roles, the handler will faithfully grant whatever role string arrives. CVE-2025-6254 in Doctreat Core is exactly this:
doctreat_process_registration()"not properly restricting the roles that a user can register with," so an unauthenticated attacker registers directly asadministrator. - The ecosystem rewards features, not review. A plugin author's incentive is to ship the feature that gets installs. There is no platform-enforced security review gate for the long tail of plugins, no mandatory capability-check linter on the path to the directory, and the same handler pattern gets copied from plugin to plugin, tutorial to tutorial. The bug is not one author's mistake; it is a template the whole ecosystem reproduces.
The attacker decision tree
Two entry handlers, one missing check each, converging on full control of the site and often the server.
The decisive step is step 2, and it is cheap. The attacker does not need a zero-day or a memory bug. They send the handler's request with no authentication cookie and watch the response. A handler that performs its privileged action anyway, returning the uploaded file's URL or the new user's ID, has told the attacker everything. From there the two branches differ only in payload. The upload branch lands a file; if the platform's executable-type guard happens to be weak or absent, that file becomes a web shell and the server is the attacker's. Even when executable types are blocked, as they were in CVE-2026-9067, the attacker still hosts arbitrary content under the victim's trusted domain, which is malware distribution, phishing, and SEO poisoning at the brand's expense. The registration branch grants an administrator account, and an administrator in WordPress can edit plugin and theme files directly, which means writing PHP, which means the same RCE outcome by a different door.
A composite real-world scenario
The setting is a mid-market retailer's marketing site on WordPress: a managed host, a commercial theme, and roughly forty active plugins accumulated over five years of campaigns, including a structured-data plugin for rich search snippets and a directory plugin left over from a partner-locator project. The security team patches WordPress core promptly and runs a plugin-version scanner that maps installed versions against published CVEs. On the morning the structured-data plugin's flaw is disclosed, their scanner has no signature for it yet, because the CVE is hours old.
An attacker, scanning broadly rather than targeting this retailer specifically, fingerprints the plugin from a stylesheet handle in the page source and tests the known frontend action against admin-ajax with no session:
# Probe the frontend upload handler with NO authentication cookie.
# A spoofed Content-Type is the entire bypass.
$ curl -s https://shop.example/wp-admin/admin-ajax.php \
-F 'action=schema_amp_frontend_upload' \
-F 'file=@payload.svg;type=image/png' # body is not a PNG
{ "success": true, "url": "https://shop.example/wp-content/uploads/2026/06/payload.svg" }
The handler never called current_user_can('upload_files'), and it validated only the type=image/png the attacker themselves supplied. The file lands in the public uploads directory under the retailer's own domain. Executable PHP is blocked by the host's upload guard, so this does not become server RCE here, but the attacker now hosts a credential-phishing page and a tranche of malware on a trusted retail domain, and search engines begin indexing it. The brand-integrity damage is immediate and the host's abuse desk, not the security team, is the first to notice.
The same week, the leftover directory plugin's registration flaw is disclosed. The attacker, already enumerating the retailer's plugins, sends the registration request and asks for the role outright:
# Open role registration: the handler trusts the submitted role.
$ curl -s https://shop.example/wp-admin/admin-ajax.php \
-F 'action=doctreat_process_registration' \
-F 'email=attacker@mail.test' -F 'password=...' \
-F 'role=administrator' # no allow-list on role
{ "success": true, "user_id": 5512, "role": "administrator" }
Now the attacker holds an administrator login. In WordPress, that is not the end of the escalation, it is a fresh start: the built-in theme and plugin editors let an administrator write PHP to disk directly. The attacker drops a one-line handler into an inactive theme file and has remote code execution on the server the host's upload guard was protecting. Two unrelated plugins, two missing authorization checks, and the textbook-patched core never mattered. Total attacker effort: a few HTTP requests against handlers a class test would have flagged before either CVE existed.
What we observe in customer environments
We are honest about the limits of our visibility. CelvexGroup's continuous validation runs against assets the customer scopes in, using read-only requests that carry an X-Celvex-Probe attribution header so the customer's operations team can always identify our traffic, and we benign-probe rather than weaponize on live customer systems. Within those bounds, what we assess on WordPress estates is the authorization posture of every reachable frontend handler, not just the ones with a published CVE. Across web-application engagements over the past nine months, the recurring findings:
- Roughly one in four WordPress sites we reviewed exposed at least one
wp_ajax_nopriv_handler that performed a state-changing action without an observable capability check. - The large majority of upload-capable frontend handlers we tested relied on the client-supplied content type or the filename extension rather than inspecting the actual file bytes.
- A recurring minority of sites running directory, membership, or marketplace plugins accepted a role parameter on the registration path with no server-side allow-list of self-service roles.
- Almost universally, the customer's existing tooling was a version-matching CVE scanner that, by construction, could not see any of the above until the specific plugin and version had already been published as vulnerable.
The honest read: the individual CVE is a lagging indicator, and the class is a leading one. We do not need the plugin to have a CVE to tell a customer that a specific handler answers privileged requests from anonymous callers. That is a finding we can ship today, on this run, refreshed every run as new handlers appear.
What to do about it: the frontend-handler contract
The fix is not one patch, because there will always be another plugin. It is a contract every frontend handler must satisfy, and the platform already documents every piece of it. Most of the controls are a single line of code in the right place.
Frontend-handler authorization contract: controls that close the class
- Call
current_user_can()at the top of every privileged handler. Registering awp_ajax_nopriv_action is a decision to accept anonymous callers; the handler must then explicitly verify the capability the action requires (upload_files,edit_posts, and so on) andwp_die()otherwise. The platform's own plugin handbook says to run privileged code only when the user has the capability. - Pair the capability check with a nonce, but never substitute one for the other. A nonce (
check_ajax_referer()) proves the request originated from your form; it is not authorization. WordPress states it directly: nonces "should never be relied on for authentication, authorization, or access control." Use both. - Validate uploads by their actual bytes, server-side, against an allow-list. Ignore the client
Content-Typeand the extension. Inspect the real file signature, runwp_check_filetype_and_ext(), store outside the web root or with execution disabled, and rename to a generated filename. Accept known-good types only; never block a deny-list. - Allow-list self-service roles on every registration path. Never pass a request-supplied role to
wp_insert_user(). Define the exact set of roles a visitor may self-assign (usually justsubscriberor one custom low-privilege role) and reject anything outside it. Map intent server-side; do not trust the client to name the role. - Treat
admin-ajax.phpand REST endpoints as one attack surface. The same authorization gap appears inregister_rest_route()permission_callbackset to__return_true. Audit both surfaces with the same checklist. - Test the class, not the CVE. Enumerate every frontend handler on a schedule and probe each one unauthenticated for a privileged response. A handler that acts on an anonymous request is the finding, whether or not it has a CVE yet.
The CVE tells you which plugin failed last week. The class test tells you which handler will fail next week, before anyone has published the bug.
The audit, in concrete terms, starts by enumerating the handlers and probing each one without a session:
# Enumerate frontend AJAX actions a plugin exposes to anonymous users
$ grep -rEn "wp_ajax_nopriv_" wp-content/plugins/ \
| sed -E 's/.*wp_ajax_nopriv_([a-z0-9_]+).*/\1/' | sort -u
# For each action, confirm the callback gates before it acts
$ grep -rnE "current_user_can|check_ajax_referer|wp_verify_nonce" \
wp-content/plugins// | head
# Probe live, unauthenticated: a privileged 200 is the finding
$ curl -s "https://site/wp-admin/admin-ajax.php?action=" --no-cookie
Read each handler that registers a nopriv action. Confirm it checks the capability before it does anything privileged, validates uploads by content rather than by the client's claim, and never trusts a submitted role. The exercise is finishable in a day for a typical site, and it generalizes: the same checklist applies to the next plugin you install, and the one after that.
How Celvex catches this
Find. Prove. Fix. Verify.
The scanner enumerates every reachable frontend AJAX and REST handler on the WordPress estate in scope and probes each, unauthenticated and benignly, for a privileged response, with every request carrying the X-Celvex-Probe attribution header.
For a confirmed authorization gap we ship a signed Proof Capsule: the exact handler, the unauthenticated request, the privileged response that proves the missing check, and a non-destructive demonstration reproducible offline.
The Capsule's remediation block points at the frontend-handler contract control scoped to the gap: the current_user_can() line to add, the byte-level upload validation, or the registration role allow-list.
After the fix lands, the unauthenticated probe returns the gated response and the privileged action no longer fires. The finding closes automatically and the verified-fix event is recorded for the audit trail.
Where we sit on the autonomy curve: at L1.5 today, our web-application track fingerprints WordPress frontend handlers and tests each for the missing-capability, client-trusted-content-type, and open-role-registration patterns as a class, version-matched against any published CVE but not dependent on one, per our no-false-positive rule. At L2 within 90 days, the corpus extends the same handler-enumeration probe to the REST permission_callback surface and to common headless-WordPress configurations. At L3 within twelve months, the scanner synthesizes handler-specific probes for unfamiliar plugins it fingerprints in customer environments, before any CVE exists. We do not claim L3 today. We do claim L1.5 catches the class above, on every run, and ships a reproducible signed Capsule for each instance. See the web application testing capability for the full method.
Bottom line
The WordPress plugin CVE stream is endless by construction, and a scanner that matches plugin names against published CVEs is always one breach behind the next one. CVE-2026-9067 and CVE-2025-6254 are this week's reminders, an unauthenticated arbitrary file upload and an unauthenticated escalation to administrator, but they are two instances of one class: a frontend handler that skipped the capability check, trusted the client's content type, or trusted the client's chosen role. The fix is a contract, not a patch: check the capability before every privileged action, validate uploads by their actual bytes, allow-list the roles a visitor may self-assign, and verify the gate by probing it unauthenticated. Test the class and you protect the customer against the plugins that have not been disclosed yet. Test the CVE and you protect them against last week. We ship the class test, refreshed every run, with a signed Proof Capsule for every instance it finds.
Verifiable security. Find it. Prove it. Fix it. Verify the fix held. That is what we ship.
Sources
- NVD: CVE-2026-9067 (Schema & Structured Data for WP & AMP, unauthenticated arbitrary file upload)
- WPScan: CVE-2026-9067 vulnerability report (client-supplied Content-Type bypass)
- NVD: CVE-2025-6254 (Doctreat Core, unauthenticated privilege escalation via unrestricted role registration)
- CWE-434: Unrestricted Upload of File with Dangerous Type
- CWE-269: Improper Privilege Management
- CWE-862: Missing Authorization
- OWASP: File Upload Cheat Sheet (content type cannot be trusted)
- WordPress Developer: Checking User Capabilities (current_user_can)
- WordPress Developer: Nonces (not a substitute for authorization)
- CELVEX Group: Proof Capsule format
Probe your WordPress handlers before the next CVE drops.
Free Exposure Check, no signup required. We enumerate your frontend AJAX and REST handlers, probe each one unauthenticated, and ship a signed Proof Capsule for the highest-confidence authorization gap.
Run a Free Scan →