Start trial

Cross-Site Request Forgery in Elementor Plugin Affecting 2 Million+ Sites

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, make

PublishedSeptember 25, 2026
Dave Jong avatar
Dave Jong
Security Research Lead at Patchstack

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?

Web developers

Mitigate vulnerabilities in real-time without changing code.

See pricing
Plugin developers

Identify vulnerabilities in your plugins and get recommendations for fixes.

Request audit
Hosting companies

Protect your users, improve server health and earn additional revenue.

Patchstack for hosts

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.

Get started for free
Hosting company?

Protect your users too! Improve server health and earn added revenue with proactive security.

Patchstack for hosts
Security researcher?

Report vulnerabilities to our gamified bug bounty program to earn monthly cash rewards.

Learn more

Like it? Share it.

Related articles