
Elementor CSRF: one link, one new admin account
Elementor CSRF: one link, one new admin account
One link, opened by a logged-in WordPress administrator, was enough to hand an attacker a second administrator account on the site. No JavaScript, no form, no page under the attacker's control. Just a URL, and a stranger clicking it while logged in.
That is the whole of CVE-2026-62062, disclosed on 25 September 2026 in Elementor Website Builder, the drag-and-drop page builder running on more than ten million WordPress sites. The CVE record scores it CVSS 3.1 at 8.8, HIGH, with the vector AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H and CWE-352. Patchstack's own triage entry labels the same bug "medium priority", which is a useful reminder that vendor severity labels and CVSS are separate conversations.
The bug was reported to Elementor on 22 September 2026 by the researcher Saggre, through Patchstack. Elementor shipped the fix two days later, in 4.3.2. Only versions 4.3.0 and 4.3.1 are vulnerable, and WordPress.org statistics put the number of sites running one of those two at up to two million.
A hidden experiment, not a visible setting
The vulnerable code belongs to Elementor's Editor Events module, which proxies the editor's telemetry. Patchstack's write-up found it gated behind an experiment, and this is where the story gets uncomfortable: the experiment is declared hidden, and it defaults to on for any site whose first Elementor installation was 3.32.0 or later.
// 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',
],
hidden => true means the toggle never appears on Elementor's Experiments screen, so there is nothing for a site owner to find, read, or turn off. A default install of 4.3.0 or 4.3.1, with no settings changed and nothing else installed, is affected. The module's constructor also registers its filter unconditionally, so merely loading the module is enough — no telemetry preference in the settings stops it. Releases before 4.3.0 do not ship the Editor Events proxy at all and are not affected by this bug.
The check ran on the wrong string
What the module wanted was simple: exempt its own telemetry requests from WordPress's REST nonce check. To do that it hooks rest_authentication_errors at priority 0 and returns true when it thinks the request is its own.
// 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 timing is the problem. 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 value available, and reached for the loosest possible test on it: an unanchored strpos() across the whole of $_SERVER['REQUEST_URI'].
But REQUEST_URI is not the route. It is the path and the query string, and the query string is entirely composed by whoever wrote the link. The parameter name does not matter, and neither does its position. Every one of these satisfies the check while the request 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 also stronger than skipping a check. rest_authentication_errors is a filter whose value travels down a chain, and every well-behaved handler starts by asking whether an earlier handler already decided. WordPress core's own nonce check is written that way, so a true from priority 0 is an answer, not a shrug.
Why a plain link is enough
Most REST CSRF findings need a page that submits a form or calls fetch(), which limits delivery to somewhere the attacker can place markup. This one does not, because WordPress core accepts a _method query parameter that overrides the HTTP verb. A GET navigation is therefore enough to perform a write, and the attack fits entirely inside a URL — the same mechanism covered here for the HTTP QUERY method itself.
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
Drop that last parameter and the same request is rejected as it should be: rest_cannot_create_user with HTTP 401. Add it and the server answers HTTP 201 with 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 explanation other than the substring test.
The blast radius is every REST route
This was never only about Elementor's own endpoints. The filter sits in core's authentication chain, so bypassing it disables nonce validation for whatever route the request actually reaches, including endpoints belonging to plugins that did nothing wrong.
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 response body. Patchstack's framing is the accurate one: one link, opened by a logged-in WordPress user, makes that user carry out any REST API action their account is already permitted to perform. Authorization still holds — the victim really is an administrator. Only the proof of intent is gone.
What to do about it
Update to 4.3.2 or later; that is the whole fix, and it is a two-line change. Then do the boring part, because a bug this cheap to weaponise is worth assuming was probed. Audit the user list for administrators you cannot account for, and check whether any of them arrived recently through /wp-json/wp/v2/users. Patchstack has issued mitigation rules for the vulnerability, which is the sensible stopgap for anyone who cannot patch immediately — the same reasoning that applies when a WAF is the only thing between you and a bad week. Neither write-up reports exploitation in the wild, so this is currently a patch job rather than an incident response.
The part worth stealing
Three lessons travel well past this plugin. First, hidden => true experiments are a security surface that documentation will never mention: a feature can be off-limits to the site owner and on by default at the same time, and if the feature registers a filter in its constructor, the site owner's settings have no bearing on it. Second, never test attacker-composed input with an unanchored substring match — compare the resolved route, not the raw URI, because the query string belongs to the attacker. Third, returning true from an authentication filter is a decision, not a skip, and it will be read as a decision by every handler downstream.
The 4.3.2 replacement shows what the correct version of that check looks like: read $wp->query_vars['rest_route'], the route WordPress actually resolved with the query string already separated out, and anchor the match at position 0.
// 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 . '/' );
}
Two days from report to patch is good work. The interesting part is not the delay or the CVE, it is that a telemetry convenience shipped a privilege-escalation primitive that nobody could see, let alone switch off.
Sources
// author
Editor & Operator
Mohd Firdaus Abu Bakar (MrDodgerX) is an enterprise geospatial systems engineer in Malaysia and the editor behind hejes.my. He runs the briefing pipeline, curates and edits every draft, verifies each factual claim against primary sources, and presses the publish button — nothing ships without his review. He writes about AI, security and Malaysia's tech scene from a builder's perspective. Professional profile and code: the links below.
// sources //
fact-check verified · 26 SEPT 2026 · hejes-content-fact-check
- Cross-Site Request Forgery in Elementor Plugin affecting 2 million sitesPatchstack · accessed 26 SEPT 2026
- WordPress Elementor Website Builder Plugin 4.3.0-4.3.1 Cross-Site Request Forgery (CSRF)Patchstack · accessed 26 SEPT 2026
- Elementor WordPress flaw lets attackers create admin accountsBleepingComputer · accessed 26 SEPT 2026
- CVE-2026-62062cve.report · accessed 26 SEPT 2026
related //

LMCache CVE-2026-105192: Unpatched 9.8 RCE
LMCache unpickles data straight off an unauthenticated socket. One message is RCE, your exposure is one flag wide, and no patch exists.

FortiMail Zero-Day: Disable IBE and Hunt
A 9.8 path traversal plus NULL byte flaw in FortiMail is under attack. The workaround is three CLI lines, and it does nothing for boxes already written to.

MikroTik Pre-Auth RCE: Patch RouterOS to 7.24
A 9.8 pre-auth integer underflow in RouterOS web management. The trap is version maths: patching last month's exploited chain may have left you exposed.
// join the feed
one fresh insight per week. no spam, ever.
weekly digest. unsubscribe anytime.