Arbitrary Attachment Render to XSS in Elementor Plugin

Published 8 November 2023
Updated 6 December 2023
Rafie Muhammad
Security Researcher at Patchstack
Table of Contents

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 Plugin

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:

Elementor Plugin

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

13 September, 2023We found the vulnerability and reached out to the Elementor security team.
09 October, 2023Elementor version 3.16.5 released to patch the reported issues.
08 November, 2023Added the vulnerabilities to the Patchstack vulnerability database. Security advisory article publicly released.

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.

The latest in Security advisories

Looks like your browser is blocking our support chat widget. Turn off adblockers and reload the page.
crossmenu