Elementor Website Builder <= 4.0.4 - Authenticated (Contributor+) Stored Cross-Site Scripting via REST API
Description
The Elementor Website Builder plugin for WordPress is vulnerable to Stored Cross-Site Scripting via the _elementor_data meta field in versions up to, and including, 4.0.4. This is due to insufficient input sanitization when processing form-encoded REST API requests. The plugin registers the _elementor_data meta field with show_in_rest but omits a sanitize_callback, relying instead on a rest_pre_insert_post filter (sanitize_post_data function) that only sanitizes JSON-encoded request bodies. When a contributor sends a form-encoded PATCH request to the WordPress REST API, the json_decode() call on the raw body returns null, causing all sanitization to be skipped. The unsanitized data is then stored via update_post_meta() and later output without escaping through multiple widget sinks including the HTML widget's print_unescaped_setting() function. This makes it possible for authenticated attackers, with contributor-level access and above, to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:NTechnical Details
What Changed in the Fix
Changes introduced in v4.0.5
Source Code
WordPress.org SVNThis research plan outlines the steps required to demonstrate the Stored Cross-Site Scripting (XSS) vulnerability in the Elementor Website Builder plugin (<= 4.0.4). ### 1. Vulnerability Summary The vulnerability exists because Elementor registers the `_elementor_data` meta field for use in the Wor…
Show full research plan
This research plan outlines the steps required to demonstrate the Stored Cross-Site Scripting (XSS) vulnerability in the Elementor Website Builder plugin (<= 4.0.4).
1. Vulnerability Summary
The vulnerability exists because Elementor registers the _elementor_data meta field for use in the WordPress REST API but fails to provide a sanitize_callback during registration. Instead, it relies on a custom filter hooked to rest_pre_insert_post (and rest_pre_insert_page) named sanitize_post_data.
This filter attempts to decode the raw request body as JSON to sanitize the Elementor layout data. However, if an attacker sends a request with Content-Type: application/x-www-form-urlencoded, the json_decode() call on the raw body returns null. The function then exits early without performing any sanitization, allowing unsanitized JSON (containing XSS payloads) to be stored in the _elementor_data meta field.
2. Attack Vector Analysis
- Endpoint:
/wp-json/wp/v2/posts/<ID>or/wp-json/wp/v2/pages/<ID> - Method:
POST(with_method=PATCH) orPATCH - Authentication: Authenticated (Contributor level or higher).
- Vulnerable Parameter:
meta[_elementor_data] - Content-Type:
application/x-www-form-urlencoded - Payload Sink: The HTML widget within Elementor, which outputs data via
print_unescaped_setting().
3. Code Flow
- Entry Point: An authenticated user sends a
PATCHrequest to the WordPress REST API to update a post they have permission to edit. - Meta Registration: Elementor calls
register_meta( 'post', '_elementor_data', ... )with'show_in_rest' => true, but without a'sanitize_callback'. - Vulnerable Filter: The
rest_pre_insert_postfilter triggers Elementor'ssanitize_post_datafunction. - The Bypass:
sanitize_post_datacallsjson_decode( $request->get_body() ).- Because the request is
application/x-www-form-urlencoded, the raw body is a string likemeta%5B_elementor_data%5D=..., which is not valid JSON. json_decodereturnsnull.- The function returns the
$prepared_postdata unchanged.
- Storage: WordPress core processes the
metaarray in the request and updates the_elementor_datapost meta usingupdate_post_meta()because it is registered asshow_in_rest. - Rendering: When a user views the post, Elementor parses
_elementor_data. The HTML widget processes thehtmlsetting and renders it directly to the page without escaping.
4. Nonce Acquisition Strategy
The WordPress REST API requires a wp_rest nonce for authenticated requests.
- Identify Trigger: The REST nonce is globally available in the WordPress admin dashboard for logged-in users.
- Access Strategy:
- Log in as a Contributor.
- Navigate to the WordPress Dashboard (
/wp-admin/). - Use
browser_evalto extract the nonce from thewpApiSettingsobject.
- JavaScript Path:
window.wpApiSettings.nonce
5. Exploitation Strategy
- Preparation: Identify or create a post/page ID where the Contributor is the author.
- Payload Construction: Create a JSON string for
_elementor_datathat defines an HTML widget containing the XSS payload.[{"id":"poc_id","elType":"widget","widgetType":"html","settings":{"html":"<script>alert(document.domain)</script>"},"elements":[]}] - Request Execution: Use the
http_requesttool to send aPOSTrequest to the REST API with the_method=PATCHoverride.- URL:
https://<target>/wp-json/wp/v2/posts/<ID> - Headers:
X-WP-Nonce:<extracted_nonce>Content-Type:application/x-www-form-urlencoded
- Body:
meta[_elementor_data]=[{"id":"xss","elType":"widget","widgetType":"html","settings":{"html":"<script>alert(document.domain)</script>"},"elements":[]}]&meta[_elementor_edit_mode]=builder
- URL:
6. Test Data Setup
- User: Create a user with the
contributorrole. - Content: As the contributor, create a draft post:
wp post create --post_type=post --post_title='XSS Test' --post_status=draft --post_author=<user_id> - Permissions: Ensure the contributor can edit this specific post ID.
7. Expected Results
- The REST API should return a
200 OKresponse with the updated post data. - The
_elementor_datafield in the database should contain the raw, unsanitized JSON string with the<script>tag. - When navigating to the post URL (or previewing it), a browser alert box should appear showing the document domain.
8. Verification Steps
- Database Check: Use WP-CLI to verify the stored meta value:
wp post meta get <ID> _elementor_data
Confirmation: The output should contain the raw<script>payload. - Frontend Check: Use
browser_navigateto visit the post URL and check for the presence of the script in the DOM or the execution of the alert.
9. Alternative Approaches
If the standard posts endpoint is restricted, try the pages endpoint:
- Endpoint:
/wp-json/wp/v2/pages/<ID> - Payload Modification: If the HTML widget is somehow blocked, attempt to use the
text-editorwidget or theheadingwidget, though these may have more internal sanitization than thehtmlwidget. - JSON in Form Body: If the
metaparameter is ignored, try sending the payload as a single url-encoded parameter:_elementor_data=<payload>(though standard REST API updates specifically look for themetakey).
Summary
Elementor Website Builder (<= 4.0.4) is vulnerable to Stored Cross-Site Scripting via the _elementor_data meta field. Authenticated contributors can bypass the plugin's custom REST API sanitization filter by sending form-encoded requests, which causes a JSON decoding failure that allows unsanitized payloads to be stored and subsequently executed in the context of other users.
Security Fix
@@ -31,6 +31,10 @@ $cloud_kit_library_app = $this->get_cloud_kit_library_app(); + if ( $cloud_kit_library_app && ! $cloud_kit_library_app->is_connected() ) { + return Response::error( ImportExportCustomizationModule::MEDIA_PROCESSING_ERROR, 'Cloud Library is not connected' ); + } + $media_urls = $request->get_param( 'media_urls' ); $kit = $request->get_param( 'kit' ); $quota = null; @@ -23,6 +23,6 @@ __( 'Submit & Deactivate', 'elementor' ); __( 'Skip & Deactivate', 'elementor' ); __( 'New Template', 'elementor' ); +__( 'New Floating Elements', 'elementor' ); __( 'Sign Up', 'elementor' ); -__( 'Don\'t Show Again', 'elementor' ); -__( 'New Floating Elements', 'elementor' ); \ No newline at end of file +__( 'Don\'t Show Again', 'elementor' ); \ No newline at end of file ... (truncated)
Exploit Outline
1. Authenticate as a user with Contributor level permissions or higher. 2. Obtain a valid WordPress REST API nonce (found in the `wpApiSettings` JavaScript object on the dashboard). 3. Identify a post or page ID that the contributor has permission to edit. 4. Construct a malicious JSON payload for the `_elementor_data` meta field. This payload should define an HTML widget containing a Cross-Site Scripting (XSS) script in its 'html' setting: `[{"id":"xss","elType":"widget","widgetType":"html","settings":{"html":"<script>alert(document.domain)</script>"},"elements":[]}]`. 5. Send a `PATCH` (or `POST` with `_method=PATCH`) request to the WordPress REST API endpoint `/wp-json/wp/v2/posts/<ID>`. 6. Set the `Content-Type` header to `application/x-www-form-urlencoded`. 7. Include the malicious layout in the `meta[_elementor_data]` parameter. By using form-encoding, the attacker causes the plugin's `rest_pre_insert_post` filter to fail its `json_decode()` attempt on the raw body, which results in the sanitization logic being bypassed. 8. Once the request is successful, navigate to the modified post. The unsanitized script will execute via the Elementor HTML widget's unescaped output sink.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.