Elementor CSRF Flaw Turns a Clicked Link Into an Admin Account
A cross-site request forgery (CSRF) flaw in the Elementor Website Builder plugin lets a single link, opened by a logged-in WordPress user, make that user carry out any REST API action their account is permitted to perform. On a standard WordPress installation, an administrator who clicks the link creates a second administrator account for the attacker. The vulnerability affects exactly versions 4.3.0 and 4.3.1, and only those two releases. It was discovered and reported to Patchstack by security researcher Saggre, and Patchstack has issued mitigation rules to protect against exploitation. The flaw carries a CVSS score of 8.8, placing it in the high severity range.
Elementor is the most widely deployed page builder for WordPress, active on over ten million sites. It provides a drag-and-drop visual editor, a template library, and a theme builder for constructing pages without writing code. The affected code belongs to the Editor Events module, which proxies Elementor's editor telemetry. That module is gated behind an experiment, but not in a way most site owners can see or influence. The experiment is marked hidden, so it does not appear on Elementor's Experiments screen at all, and its default_active setting turns it on for every site whose first Elementor installation was version 3.32.0 or later. A default installation of 4.3.0 or 4.3.1, with no settings changed and no other plugin present, is affected. The module's constructor also registers the relevant filter unconditionally, so nothing about a site's telemetry settings prevents the vulnerability. Releases before 4.3.0 do not ship the Editor Events proxy at all and are not affected.
The events proxy registers two REST routes and wants its own requests exempted from the REST nonce check, which is WordPress core's built-in protection against CSRF. To do that, it hooks the rest_authentication_errors filter at priority 0. The problem is timing: this filter runs before WordPress has matched a route, so the callback can only inspect the raw request URI string. The code uses an unanchored strpos() search over the entire $_SERVER['REQUEST_URI'], which includes both the path and the query string. Because the query string is written by whoever composes the link, any REST request can opt itself out of the nonce check by appending a harmless-looking parameter containing the literal string elementor/v1/events/. For example, all of the following satisfy the check while the request is actually dispatched to a completely different endpoint: /wp-json/wp/v2/users?zzz=elementor/v1/events/, /wp-json/wp/v2/users?elementor/v1/events/ (no parameter name at all), and /wp-json/wp/v2/users?q=foo-elementor/v1/events/-bar (not even a whole segment).
Returning true from this filter is stronger than simply skipping a check. The rest_authentication_errors filter is a shared channel, and every well-behaved handler on it starts by checking whether an earlier handler has already made a decision. WordPress core's own nonce check is written exactly that way: it returns immediately if a previous filter returned a non-empty value. Because Elementor's filter runs at priority 0 and returns true, it claims that authentication has already succeeded before anything else gets a say. The core nonce verification is never reached, and any security plugin that hardens REST authentication through the same filter is skipped along with it. What remains is the route's own permission_callback, which asks what the current user is allowed to do. Because the victim is logged in, their cookie answers that question generously. Authorization still holds; only the proof of intent is gone.
Most REST CSRF findings need a page that submits a form or issues a fetch() request, which limits delivery to places where the attacker can place script or markup. This one does not, because WordPress core accepts a _method query parameter that overrides the HTTP verb. A simple GET navigation, such as clicking a plain link, is enough to perform a write. The entire attack fits inside a URL: https://example.com/wp-json/wp/v2/users?_method=POST&username=csrfadmin&email=csrfadmin%40example.test&password=...&roles%5B%5D=administrator&x=elementor/v1/events/. Without the final parameter, the same request returns rest_cannot_create_user with HTTP 401. With it, the server returns HTTP 201 and a user object whose roles array is ["administrator"]. The researcher confirmed that a marker one character short of the real namespace, elementor/v1/event/, is rejected, ruling out any other explanation for the difference. Because a single anchor tag is enough, the payload can be delivered anywhere a link can go: an email, a chat message, a forum post, a comment on an unrelated site. The victim sees a normal link and a JSON response.
The blast radius is every REST route. This is not a vulnerability in Elementor's own endpoints that happens to be reachable by CSRF. The bypass is evaluated before routing, so it applies to the entire REST API surface of the site: WordPress core routes, and the routes of every other plugin installed alongside it. Creating an administrator through /wp/v2/users is the clearest demonstration, but the researcher also showed GET /wp-json/wp/v2/settings changing from HTTP 401 to HTTP 200 with the site settings returned in the response body. Anything the victim's account can reach over REST is in scope, including endpoints belonging to plugins that did nothing wrong.
Elementor fixed the flaw in version 4.3.2. The replacement code reads $wp->query_vars['rest_route'] instead of the raw URI. That value is the route WordPress actually resolved, after rewriting, with the query string already separated out. There is no longer any attacker-composed text in the value being tested. The comparison also changed from false !== strpos(...) to 0 === strpos(...), so the namespace must appear at the beginning of the route rather than somewhere inside it. This second change is critical: skipping it would have left a smaller version of the same bug, because a route such as /otherplugin/v1/x/elementor/v1/events/ would still have matched. An added is_string() guard closes another door where rest_route could be submitted as an array.
The root cause is a mismatch between the question the code asked and the data available to answer it. The code wanted to know "is this request for my route?", but it asked at a point where WordPress had not decided that yet, and it settled for a substring search over a string the client controls. Two lessons generalize beyond this plugin. First, $_SERVER['REQUEST_URI'] is attacker-controlled input, not routing metadata; any security decision based on it needs the query string removed and the comparison anchored. Second, rest_authentication_errors is a shared channel, and returning a truthy value is not a local opt-out. It asserts success to every handler downstream, including WordPress core's nonce verification.