This blog post is about an Elementor plugin vulnerability. If you’re an Elementor user, please update the plugin to at least version 3.16.5.
You can sign up for the Patchstack Community plan to be notified about vulnerabilities as soon as they become disclosed.
For plugin developers, we have security audit services and Threat Intelligence Feed API for hosting companies.
About the Elementor Plugin
The plugin Elementor (versions <= 3.16.4, free version), which has over 5 million active installations, is known as the most popular website builder plugin in WordPress.
Elementor is known to be the leading website-building platform for WordPress, enabling web creators to build professional, pixel-perfect websites with an intuitive visual builder. The plugin could quickly create amazing websites for clients or businesses with complete control over every piece, without writing a single line of code.
The security vulnerability in Elementor Plugin
This plugin suffers from an authenticated cross-site scripting (XSS) vulnerability. It allows a user with a minimum user role of Contributor to inject arbitrary JavaScript code into the website and could result from stealing sensitive information to, in this case, privilege escalation on the WordPress site.
This vulnerability occurs because the code that handles SVG file render from the library doesn’t properly check the file contents or type and directly reflects the content as an HTML string. The mentioned scenario combined with the Contributor user role can render arbitrary attachment files on the WordPress site. The described vulnerability was fixed in version 3.16.5 and assigned CVE-2023-47504 and CVE-2023-47505.
Contributor+ Cross-Site Scripting (XSS)
The underlying vulnerability is located in the get_inline_svg
function:
/**
* Get Inline SVG
*
* @since 3.5.0
* @access public
* @static
*
* @param $attachment_id
* @return bool|mixed|string
*/
public static function get_inline_svg( $attachment_id ) {
$svg = get_post_meta( $attachment_id, self::META_KEY, true );
if ( ! empty( $svg ) ) {
return $svg;
}
$attachment_file = get_attached_file( $attachment_id );
if ( ! file_exists( $attachment_file ) ) {
return '';
}
$svg = Utils::file_get_contents( $attachment_file );
if ( ! empty( $svg ) ) {
update_post_meta( $attachment_id, self::META_KEY, $svg );
}
return $svg;
}
This function is fairly simple, it will first fetch the corresponding attached file path of an attachment by using the attachment id. The function then will fetch the file content and return the raw string of the file content.
The function itself can be called from the render_uploaded_svg_icon
function which acts as the function that is supposed to render an uploaded SVG icon:
public static function render_uploaded_svg_icon( $value ) {
if ( ! isset( $value['id'] ) ) {
return '';
}
return Svg::get_inline_svg( $value['id'] );
}
The render_uploaded_svg_icon
is just one of the two options that can be called from the get_icon_html
function:
/**
* @param $icon
* @param $attributes
* @param $tag
* @return bool|mixed|string
*/
public static function try_get_icon_html( $icon, $attributes = [], $tag = 'i' ) {
if ( empty( $icon['library'] ) ) {
return '';
}
return static::get_icon_html( $icon, $attributes, $tag );
}
/**
* @param array $icon
* @param array $attributes
* @param $tag
* @return bool|mixed|string
*/
private static function get_icon_html( array $icon, array $attributes, $tag ) {
/**
* When the library value is svg it means that it's a SVG media attachment uploaded by the user.
* Otherwise, it's the name of the font family that the icon belongs to.
*/
if ( 'svg' === $icon['library'] ) {
$output = self::render_uploaded_svg_icon( $icon['value'] );
} else {
$output = self::render_font_icon( $icon, $attributes, $tag );
}
return $output;
}
The get_icon_html
function is wrapped by the try_get_icon_html
function which will act as the main function to handle the icon object fetch. It has two options; the user can choose an uploaded SVG file or just use one from the default icon library.
This icon feature is available on multiple widgets such as Accordion and Tabs:
The fetched content of the icon file itself can be reflected in several areas of the code. For example, it will be reflected in the render_accordion_icons
function if we decide to use the Accordion widget:
private function render_accordion_icons( $settings ) {
$icon_html = Icons_Manager::try_get_icon_html( $settings['accordion_item_title_icon'], [ 'aria-hidden' => 'true' ] );
$icon_active_html = $this->is_active_icon_exist( $settings )
? Icons_Manager::try_get_icon_html( $settings['accordion_item_title_icon_active'], [ 'aria-hidden' => 'true' ] )
: $icon_html;
ob_start();
?>
<span class='e-n-accordion-item-title-icon'>
<span class='e-opened' ><?php echo $icon_active_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
<span class='e-closed'><?php echo $icon_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
</span>
<?php
return ob_get_clean();
}
Note that in order to upload an SVG file to a WordPress site, the administrator needs to give the unfiltered_upload
permission to a user, which is not available by default.
Additionally, the lower privileged role on the WordPress site, which is the Subscriber and Contributor role, doesn’t have any default capability of uploading a file to the site. But it turns out that we canupload a file as a Contributor role by utilizing the Draft Template feature by Elementor itself.
Elementor provides a Template feature that allows users to import a page template using a formatted JSON file. Fortunately, the Contributor role user is able to access the feature but only by importing a template which will be in a draft state.
The import template process can be seen from the import
function:
/**
* Import image.
*
* Import a single image from a remote server, upload the image WordPress
* uploads folder, create a new attachment in the database and updates the
* attachment metadata.
*
* @since 1.0.0
* @since 3.2.0 New `$parent_post_id` option added
* @access public
*
* @param array $attachment The attachment.
* @param int $parent_post_id Optional
*
* @return false|array Imported image data, or false.
*/
public function import( $attachment, $parent_post_id = null ) {
if ( isset( $attachment['tmp_name'] ) ) {
// Used when called to import a directly-uploaded file.
$filename = $attachment['name'];
$file_content = Utils::file_get_contents( $attachment['tmp_name'] );
} else {
// Used when attachment information is passed to this method.
if ( ! empty( $attachment['id'] ) ) {
$saved_image = $this->get_saved_image( $attachment );
if ( $saved_image ) {
return $saved_image;
}
}
// Extract the file name and extension from the url.
$filename = basename( $attachment['url'] );
$request = wp_safe_remote_get( $attachment['url'] );
// Make sure the request returns a valid result.
if ( is_wp_error( $request ) || ( ! empty( $request['response']['code'] ) && 200 !== (int) $request['response']['code'] ) ) {
return false;
}
$file_content = wp_remote_retrieve_body( $request );
}
if ( empty( $file_content ) ) {
return false;
}
$filetype = wp_check_filetype( $filename );
// If the file type is not recognized by WordPress, exit here to avoid creation of an empty attachment document.
if ( ! $filetype['ext'] ) {
return false;
}
if ( 'svg' === $filetype['ext'] ) {
// In case that unfiltered-files upload is not enabled, SVG images should not be imported.
if ( ! Uploads_Manager::are_unfiltered_uploads_enabled() ) {
return false;
}
$svg_handler = Plugin::$instance->uploads_manager->get_file_type_handlers( 'svg' );
$file_content = $svg_handler->sanitizer( $file_content );
};
$upload = wp_upload_bits(
$filename,
null,
$file_content
);
$post = [
'post_title' => $filename,
'guid' => $upload['url'],
];
$info = wp_check_filetype( $upload['file'] );
if ( $info ) {
$post['post_mime_type'] = $info['type'];
} else {
// For now just return the origin attachment
return $attachment;
// return new \WP_Error( 'attachment_processing_error', esc_html__( 'Invalid file type.', 'elementor' ) );
}
$post_id = wp_insert_attachment( $post, $upload['file'], $parent_post_id );
apply_filters( 'elementor/template_library/import_images/new_attachment', $post_id );
// On REST requests.
if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
require_once ABSPATH . '/wp-admin/includes/image.php';
}
if ( ! function_exists( 'wp_read_video_metadata' ) ) {
require_once ABSPATH . '/wp-admin/includes/media.php';
}
wp_update_attachment_metadata(
$post_id,
wp_generate_attachment_metadata( $post_id, $upload['file'] )
);
update_post_meta( $post_id, '_elementor_source_image_hash', $this->get_hash_image( $attachment['url'] ) );
$new_attachment = [
'id' => $post_id,
'url' => $upload['url'],
];
if ( ! empty( $attachment['id'] ) ) {
$this->_replace_image_ids[ $attachment['id'] ] = $new_attachment;
}
return $new_attachment;
}
The import
function can accept an $attachment['url']
parameter and will fetch the file content using the wp_safe_remote_get
function which is already secure from an SSRF attack vector. It will check if the imported file type is a SVG file and will deny the import. But, since the initial vulnerable function on get_inline_svg
doesn’t check what filetype is the rendered attachment, we can just upload any valid image file such as PNG or JPEG and append the XSS payload on the image metadata.
After the image payload is uploaded as an attachment, the Contributor role user just needs to draft a post with an example of the Accordion widget and put the uploaded attachment as the SVG icon chosen through the library upload. The XSS can be triggered from the drafted post preview:
Note that this vulnerability can be reproduced with the Contributor role on a default installation of the Elementor plugin without any additional conditions or requirements.
The patch in Elementor Plugin
The Elementor team decided to apply a complex and custom SVG content sanitizer from SVG_Sanitizer::sanitize_file()
function. We and the Elementor team also considered it as a fix for the Contributor+ arbitrary attachment read since the returned value only contains valid and non-malicious SVG content. The patch can be seen here:
Conclusion
In some conditions, a plugin or theme needs to deal with the process of attachment and SVG files. For the attachment process, make sure that only privileged users can view or modify the targeted attachment ID. For the SVG files, make sure to only allow the user to upload if the unfiltered_upload
is enabled for the user and applies a robust sanitizer method to the SVG file contents if it will be directly displayed as HTML.
Timeline
Help us make the Internet a safer place
Making the WordPress ecosystem more secure is a team effort, and we believe that plugin developers and security researchers should work together.
- If you’re a plugin developer, join our mVDP program that makes it easier to report, manage and address vulnerabilities in your software.
- If you’re a security researcher, join Patchstack Alliance to report vulnerabilities & earn rewards.