Cross-Site Request Forgery in Elementor Plugin Affecting 2 Million+ Sites
Elementor 4.3.0 and 4.3.1 have a CVSS 8.8 CSRF bug that can create a new administrator account.
Patchstack disclosed an unauthenticated cross-site request forgery flaw in Elementor Website Builder versions 4.3.0 and 4.3.1, rated CVSS 8.8. A single link opened by a logged-in WordPress user can invoke any REST API action that account may perform, including creating a second administrator if an admin clicks it. The plugin is active on more than ten million sites, and the report’s headline says more than two million are affected; releases before 4.3.0 do not include the vulnerable Editor Events proxy. Researcher Saggre reported the issue, and Patchstack has shipped mitigation rules. No CVE or observed exploitation is stated.
- Only Elementor 4.3.0 and 4.3.1 are affected; earlier releases are not.
- One clicked link can make a logged-in admin create an attacker administrator.
- Rated CVSS 8.8; no JavaScript or attacker-hosted page is required.
- A hidden Editor Events experiment is on by default for newer installs.
- Researcher Saggre reported it; Patchstack issued mitigation rules.
Full article1,646 words · extracted from patchstack.com · click to collapse

Elementor Website Builder
Unauthenticated Cross-Site Request Forgery to Privilege Escalation
10,000,000
CVSS 8.8
This blog post is about a Cross-Site Request Forgery vulnerability in the Elementor Website Builder plugin. One link, opened by a logged-in WordPress user, makes that user carry out any REST API action their account is permitted to perform. On a stock installation, an administrator clicking the link creates a second administrator account for the attacker.
The link needs no JavaScript, no form, and no page under the attacker’s control. It works as a plain anchor in an email, a chat message, or a comment. Patchstack has issued mitigation rules to protect against exploitation of this vulnerability.
This vulnerability was discovered and reported to Patchstack by Saggre.
✌️ Our users are protected from this vulnerability. Are yours?
Plugin developers
Identify vulnerabilities in your plugins and get recommendations for fixes.
Hosting companies
Protect your users, improve server health and earn additional revenue.
About the Elementor plugin
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 security vulnerability
In versions 4.3.0 and 4.3.1, and only those two releases, Elementor disables WordPress core’s only CSRF protection for cookie-authenticated REST API requests whenever the literal string elementor/v1/events/ appears anywhere in the request URI. Because the request URI includes the query string, and the query string is written by whoever composes the link, any REST request can opt itself out of that protection by appending a harmless-looking parameter.
The affected code belongs to the Editor Events module, which proxies Elementor’s editor telemetry. It is gated behind an experiment, but not in a way most site owners can see or influence:
// core/common/app.php - init_components()
if ( Plugin::$instance->experiments->is_feature_active( Events_Manager::EXPERIMENT_NAME ) ) {
$this->add_component( 'events-manager', new Events_Manager() );
}
// core/common/modules/events-manager/module.php - get_experimental_data()
'hidden' => true, // not shown on the Experiments screen
'default' => Experiments_Manager::STATE_INACTIVE,
'new_site' => [
'default_active' => true, // ...but on by default for new sites
'minimum_installation_version' => '3.32.0',
],
The experiment is marked hidden, so it does not appear on Elementor’s Experiments screen at all, and default_active turns it on for every site whose first Elementor installation was 3.32.0 or later. A default install of 4.3.0 or 4.3.1, with no settings changed and no other plugin present, is affected. Note also that the module’s constructor registers the filter unconditionally, so the module merely being loaded is enough; nothing about the site’s telemetry settings prevents it. Releases before 4.3.0 do not ship the Editor Events proxy at all and are not affected.
A route check that runs before there is a route
The events proxy registers two REST routes and wants its own requests exempted from the REST nonce check. To do that it hooks rest_authentication_errors at priority 0:
// core/common/modules/events-manager/rest-api/events-proxy-rest-api.php
// identical in 4.3.0 and 4.3.1
public function register_hooks() {
add_action( 'rest_api_init', fn() => $this->register_routes() );
add_filter( 'rest_authentication_errors', [ $this, 'bypass_nonce_check_for_own_routes' ], 0 );
// ...
}
public function bypass_nonce_check_for_own_routes( $result ) {
if ( $this->is_own_route_request() ) {
return true;
}
return $result;
}
private function is_own_route_request(): bool {
$request_uri = Utils::get_super_global_value( $_SERVER, 'REQUEST_URI' ) ?? '';
// API_NAMESPACE = 'elementor/v1', API_BASE = 'events'
return false !== strpos( $request_uri, self::API_NAMESPACE . '/' . self::API_BASE . '/' );
}
The problem is the timing. rest_authentication_errors fires inside WP_REST_Server::check_authentication(), which runs before dispatch. At that moment WordPress has not matched a route yet, so the callback has nothing to inspect except the raw URI string. The author reached for the only thing available, and reached for the loosest possible test on it: an unanchored strpos() over the entire URI.
$_SERVER['REQUEST_URI'] is not the route. It is the path and the query string, and the query string is entirely attacker-composed. The parameter name does not matter, and neither does its position. All of the following satisfy the check while the request itself is dispatched somewhere else entirely:
/wp-json/wp/v2/users?zzz=elementor/v1/events/
/wp-json/wp/v2/users?elementor/v1/events/ <- no parameter name at all
/wp-json/wp/v2/users?q=foo-elementor/v1/events/-bar <- not even a whole segment
Returning true is stronger than skipping a check
The second half of the problem is the return value. rest_authentication_errors is a filter whose value is passed down the chain, and every well-behaved handler on it begins by checking whether an earlier handler has already decided. WordPress core’s own nonce check is written exactly that way:
// wp-includes/rest-api.php - rest_cookie_check_errors()
function rest_cookie_check_errors( $result ) {
if ( ! empty( $result ) ) {
return $result; // Elementor returned true at priority 0, so this returns here
}
// ...never reached: the wp_rest nonce is never verified
$result = wp_verify_nonce( $nonce, 'wp_rest' );
// ...
}
Returning true means “authentication has already succeeded”, and because the filter runs at priority 0 it makes that claim before anything else gets a say. rest_cookie_check_errors() is the only CSRF protection WordPress applies to cookie-authenticated REST requests, and it is skipped. 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. The victim is logged in, so their cookie answers that question generously. Authorization still holds; only the proof of intent is gone.
No JavaScript required
Most REST CSRF findings need a page that submits a form or issues a fetch(), which limits delivery to somewhere 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 GET navigation is therefore sufficient to perform a write, and 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/ <- the only part that matters
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 also confirmed that a marker one character short of the real namespace (elementor/v1/event/) is rejected, which rules 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
It is worth being precise about what this affects, because the obvious framing understates it. 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 turning from HTTP 401 into HTTP 200 with the site settings in the body. Anything the victim’s account can reach over REST is in scope, including endpoints belonging to plugins that did nothing wrong.
The patch
Elementor fixed this in 4.3.2. The replacement is short, and both halves of it matter:
// 4.3.2 - is_own_route_request()
private function is_own_route_request(): bool {
global $wp;
$route = $wp->query_vars['rest_route'] ?? null;
if ( ! is_string( $route ) ) {
return false;
}
return 0 === strpos( $route, '/' . self::API_NAMESPACE . '/' . self::API_BASE . '/' );
}
The first change is the input. Instead of the raw URI, the check now reads $wp->query_vars['rest_route']: 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 second change is the comparison. false !== strpos(...) became 0 === strpos(...), so the namespace must be the beginning of the route rather than appearing somewhere inside it. This is the part that would be easy to leave out, and skipping it would have left a smaller version of the same bug: a route such as /otherplugin/v1/x/elementor/v1/events/ would still have matched. The is_string() guard closes the third door, where rest_route is submitted as an array.
Conclusion
The root cause here is a mismatch between a question and the data available to answer it. The code wanted to know “is this request for my route?”, but it asked at a point in the request lifecycle where WordPress had not decided that yet, and settled for a substring search over a string that the client controls. A check that cannot be answered correctly at the moment it runs is usually a sign that it is running in the wrong place.
Two things generalize beyond this plugin. First, $_SERVER['REQUEST_URI'] is attacker-controlled input, not routing metadata; any security decision taken on it needs the query string removed and the comparison anchored. Second, rest_authentication_errors is a shared channel, and returning a truthy value on it is not a local opt-out. It asserts success to every handler downstream, including WordPress core’s nonce verification. A filter that only ever needs to say “no opinion” should return $result unchanged.
We strongly recommend updating Elementor to version 4.3.2 or above.
Timeline
2026-09-22We received a report about the vulnerability from the researcher, affecting versions 4.3.0 and 4.3.1.
2026-09-24Elementor released version 4.3.2, which validates the resolved REST route instead of the raw request URI.
2026-09-25Security advisory article publicly released.
🤝 You can help us make the Internet a safer place
Plugin developer?
Streamline your disclosure process to fix vulnerabilities faster and comply with CRA.
Hosting company?
Protect your users too! Improve server health and earn added revenue with proactive security.
Security researcher?
Report vulnerabilities to our gamified bug bounty program to earn monthly cash rewards.