Critical Vulnerabilities Found in XStore Theme and Plugin

Published 14 May 2024
Updated 15 May 2024
Rafie Muhammad
Security Researcher at Patchstack
Table of Contents

This blog post is about the XStore theme and plugin vulnerabilities. If you're an XStore user, please update the theme to at least version 9.3.9 and the plugin to at least version 5.3.9.

All paid Patchstack users are protected from this vulnerability. Sign up for the free Community account first, to scan for vulnerabilities and apply protection for only $5 / site per month with Patchstack.

For plugin developers, we have security audit services and Enterprise API for hosting companies.

About the XStore Theme and Plugin

The theme XStore (premium version), which has over 44,000 sales, is known as the more popular WooCommerce-focused premium theme in WordPress. This theme is bundled with a required XStore Core plugin. This theme is developed by 8theme.

This premium WordPress theme is a theme that helps users to build stunning online stores with WordPress and WooCommerce.  This theme provides over 130+ pre-made demos and can be used to customize brands and products.

The security vulnerability

The XStore theme itself suffers from multiple critical vulnerabilities.

The first vulnerability is Unauthenticated Local File Inclusion. This vulnerability allows any unauthenticated user to include arbitrary PHP files that are available on the server. In the worst case, this could lead to code execution if the user can fully or partially control some content on the PHP files on the server.

The second vulnerability is Unauthenticated SQL Injection. This vulnerability allows any unauthenticated user to inject a malicious SQL query into a WordPress database query execution.

The third vulnerability is Authenticated Arbitrary Option Update. This vulnerability allows any authenticated user to update arbitrary WP Options and in the worst-case lead to a privilege escalation. The described vulnerabilities were fixed in version 9.3.9 and assigned CVE-2024-33560, CVE-2024-33559, and CVE-2024-33564 respectively.

The required XStore Core plugin also suffers from multiple critical vulnerabilities.

The first vulnerability is Unauthenticated SQL Injection. This vulnerability allows any unauthenticated user to inject a malicious SQL query into a WordPress database query execution.

The second vulnerability is Unauthenticated PHP Object Injection. This vulnerability allows any unauthenticated user to pass ad-hoc serialized strings to a vulnerable unserialize call, resulting in an arbitrary PHP object(s) injection into the application scope and could result in a Remote Code Execution in a worst case.

The third vulnerability is Unauthenticated Account Takeover. This vulnerability allows any unauthenticated user to set any user's password and take over their account. The described vulnerabilities were fixed in version 5.3.9 and assigned CVE-2024-33551, CVE-2024-33552, and CVE-2024-33553 respectively.

XStore Theme: Unauthenticated Local File Inclusion

The underlying vulnerable code exists in the require_class function:

public function require_class($class=''){
    if (! $class){
        return;
    }
    require_once( apply_filters('etheme_file_url', ETHEME_CODE . 'panel/classes/'.$class.'.php') );
}

The function itself is called in many areas of the code, one of which is located on the main_construct function:

public function main_construct(){
    add_action( 'admin_menu', array( $this, 'et_add_menu_page' ) );
    add_action( 'admin_head', array( $this, 'et_add_menu_page_target') );
    add_action( 'wp_ajax_et_ajax_panel_popup', array($this, 'et_ajax_panel_popup') );

    // Enable svg support
    add_filter( 'upload_mimes', [ $this, 'add_svg_support' ] );
    add_filter( 'wp_check_filetype_and_ext', array( $this, 'correct_svg_filetype' ), 10, 5 );

    if ( isset($_REQUEST['helper']) && $_REQUEST['helper']){
        $this->require_class($_REQUEST['helper']);
    }
------- CUT HERE -------

The main_construct function itself will be called when the framework/panel/panel.php file is loaded. Below is the code that tries to load the file:

if ( is_admin() ) {
	require_once( apply_filters('etheme_file_url', ETHEME_CODE . 'system-requirements.php') );

    require_once( apply_filters('etheme_file_url', ETHEME_CODE . 'patcher.php') );

	// require_once( apply_filters('etheme_file_url', ETHEME_CODE . 'thirdparty/fonts_uploader/etheme_fonts_uploader.php') );
	
	require_once( apply_filters('etheme_file_url', ETHEME_CODE . 'admin.php') );

	require_once( apply_filters('etheme_file_url', ETHEME_CODE . 'admin/widgets/class-admin-sidebasr.php') );

	require_once( apply_filters('etheme_file_url', ETHEME_CODE . 'panel/panel.php') );
------- CUT HERE -------

With this condition, users can trigger the require_class function by simply visiting the /wp-admin/admin-ajax.php endpoint. Back to the main_construct function, it will directly pass the $_REQUEST['helper'] parameter to the require_class function. Since there is no sanitization or filtering on the $class variable inside of the require_class function, users can simply do a path traversal to include arbitrary local PHP files.

XStore Theme: Unauthenticated SQL Injection

The underlying vulnerable code exists in the framework/woo.php file:

add_filter( 'posts_clauses', function ($clauses, $query){
	global $wpdb;
	if(isset($query->query_vars['single_variations_filter']) && $query->query_vars['single_variations_filter']=='yes'){
		$_chosen_attributes = \WC_Query::get_layered_nav_chosen_attributes();
		$min_price          = isset( $_GET['min_price'] ) ? wc_clean( wp_unslash( $_GET['min_price'] ) ) : 0; // WPCS: input var ok, CSRF ok.
		$max_price          = isset( $_GET['max_price'] ) ? wc_clean( wp_unslash( $_GET['max_price'] ) ) : 0; // WPCS: input var ok, CSRF ok.
		$rating_filter      = isset( $_GET['rating_filter'] ) ? array_filter( array_map( 'absint', explode( ',', wp_unslash( $_GET['rating_filter'] ) ) ) ) : array(); // 
------- CUT HERE -------
		if ( $min_price ) {
			$clauses['where'] .= " AND {$wpdb->posts}.ID IN (SELECT $wpdb->postmeta.post_id FROM $wpdb->postmeta WHERE meta_key = '_price' AND meta_value >= $min_price)";
		}
		if ( $max_price ) {
			$clauses['where'] .= " AND {$wpdb->posts}.ID IN (SELECT $wpdb->postmeta.post_id FROM $wpdb->postmeta WHERE meta_key = '_price' AND meta_value <= $max_price)";
		}
------- CUT HERE -------
		if ( isset($_GET['stock_status'])) {
			$stock = str_replace('_','', $_GET['stock_status']);
			$stock = explode(',',$stock);
			$stock_by = array();
			
			foreach ($stock as $stock_val) {
				$stock_by[] = "'".$stock_val."'";
			}
			$clauses['where'] .= "AND {$wpdb->posts}.ID IN (SELECT {$wpdb->postmeta}.post_id FROM {$wpdb->postmeta}
				WHERE ( meta_key = '_stock_status' AND meta_value IN (".implode(',', $stock_by).") ) ) ";
		}
------- CUT HERE -------

Notice that the code tries to add a filter of posts_clauses hook with some additional code. The hook itself allows a developer to filter all query clauses at once, for convenience. It also covers the WHERE, GROUP BY, JOIN, ORDER BY, DISTINCT, fields (SELECT), and LIMIT clauses. In the above code itself, variables such as $min_price, $max_price, and $stock_by are appended to the $clauses['where'] without proper sanitization. For $min_price and $max_price variables, using the wc_clean function alone is not enough to prevent SQL Injection.

XStore Theme: Authenticated Arbitrary Option Update

The underlying vulnerable code exists in the xstore_panel_settings_save function:

public function xstore_panel_settings_save() {
    $settings_name = isset( $_POST['settings_name'] ) ? $_POST['settings_name'] : $this->settings_name;
    $all_settings            = (array)get_option( $settings_name, array() );
    $local_settings          = isset( $_POST['settings'] ) ? $_POST['settings'] : array();
    if ( isset( $_POST['type'] ) ) {
        $local_settings_key = $_POST['type'];
    }
    else {
        switch ( $settings_name ) {
            case 'xstore_sales_booster_settings':
                $local_settings_key = 'fake_sale_popup';
                break;
            default:
                $local_settings_key = 'general';
        }
    }
    $updated                 = false;
    $local_settings_parsed   = array();

    foreach ( $local_settings as $setting ) {
//			$local_settings_parsed[ $local_settings_key ][ $setting['name'] ] = $setting['value'];
        // if ( $this->settings_name == 'xstore_sales_booster_settings' )
        $local_settings_parsed[ $local_settings_key ][ $setting['name'] ] = stripslashes( $setting['value'] );
    }

    $all_settings = array_merge( $all_settings, $local_settings_parsed );

    update_option( $settings_name, $all_settings );
------- CUT HERE -------

The function itself is registered via a wp_ajax action. Notice that there is no proper permission and nonce check on the function, resulting in any authenticated user being able to trigger the function. Also, notice that there is no filtering or whitelist check to the $settings_name value which is used on the update_option function. This could allow users to update arbitrary WP Options on the site. However, since the $all_settings value itself can't be fully controlled by the users, we are not able to achieve a worst-case impact which is a privilege escalation by enabling the user registration option and setting the default role of registration to the Administrator role.

XStore Core Plugin: Unauthenticated SQL Injection

The underlying vulnerable code exists in the get_status_count function:

public function get_status_count($type = 'outofstock', $k = '_stock_status', $q = '='){
	$filter_brand = isset( $_GET['filter_brand'] ) ? $_GET['filter_brand'] : '' ;
	$_chosen_attributes = \WC_Query::get_layered_nav_chosen_attributes();
	$min_price          = isset( $_GET['min_price'] ) ? wc_clean( wp_unslash( $_GET['min_price'] ) ) : 0; // WPCS: input var ok, CSRF ok.
	$max_price          = isset( $_GET['max_price'] ) ? wc_clean( wp_unslash( $_GET['max_price'] ) ) : 0; // WPCS: input var ok, CSRF ok.
	$rating_filter      = isset( $_GET['rating_filter'] ) ? array_filter( array_map( 'absint', explode( ',', wp_unslash( $_GET['rating_filter'] ) ) ) ) : array(); // WPCS: sanitization ok, input var ok, CSRF ok.
	$stock_status       = isset( $_GET['stock_status'] ) ? wc_clean( wp_unslash( $_GET['stock_status'] ) ) : 0;
	$sale_status       = isset( $_GET['sale_status'] ) ? wc_clean( wp_unslash( $_GET['sale_status'] ) ) : 0;

	global $wpdb;

	// new
	if ($this->request_type == 'new'){
		$isd = array();

------- CUT HERE -------

		// merge with filters
		$isd = array_merge(
			$isd,
			$this->get_price_ids($wpdb, $min_price, $max_price), // price filter
			$this->get_rating_ids($wpdb, $rating_filter), // rating_filter
			$this->get_brand_ids($wpdb, $filter_brand), // filter_brand
			$this->get_attributes_ids($wpdb, $_chosen_attributes), // chosen_attributes
			$this->get_taxonomy_ids($wpdb) // $taxonomy
		);

------- CUT HERE -------

		if (count($isd)){
			$outofstock .= "AND p.ID IN (".implode(',', $isd).")";
		}

		return count($wpdb->get_results( $outofstock, OBJECT_K ));
	}
	else {
------- CUT HERE -------
		// $min_price
		if ($min_price){
			$outofstock .= "
			INNER JOIN $wpdb->postmeta pm3
			on p.ID = pm3.post_id
			AND pm3.meta_key='_price'
			AND pm3.meta_value >= $min_price
		";
		}

		// $max_price
		if ($max_price){
			$outofstock .= "
			INNER JOIN $wpdb->postmeta pm4
			on p.ID = pm4.post_id
			AND pm4.meta_key='_price'
			AND pm4.meta_value <= $max_price
		";
		}
------- CUT HERE -------
		return count($wpdb->get_results( $outofstock, OBJECT_K ));
	}
}

The function itself can be called from Product Status Filters custom widget and it can be triggered by an unauthenticated user. Notice that similar to the vulnerability on the XStore Theme, the $min_price and $max_price variables are not properly sanitized and it will be passed insecurely to the $outofstock variable. Additionally, the variables also passed to the get_price_ids function which is also vulnerable to SQL Injection:

private function get_price_ids($wpdb, $min_price, $max_price){
    $min_price_ids = array();
    $max_price_ids = array();
    // $min_price
    if ($min_price){
        $min_price_ids = "SELECT ID FROM $wpdb->posts as p 
            INNER JOIN $wpdb->postmeta pm
            on p.ID = pm.post_id
            and  pm.meta_key='_price'
            AND pm.meta_value  >= $min_price
            AND p.post_status = 'publish'
            AND p.post_type = 'product'
        ";
        $min_price_ids = array_keys($wpdb->get_results( $min_price_ids, OBJECT_K  ));
    }

    // $max_price
    if ($max_price){
        $max_price_ids = "SELECT ID FROM $wpdb->posts as p 
            INNER JOIN $wpdb->postmeta pm
            on p.ID = pm.post_id
            and  pm.meta_key='_price'
            AND pm.meta_value  <= $max_price
            AND p.post_status = 'publish'
            AND p.post_type = 'product'
        ";
        $max_price_ids = array_keys($wpdb->get_results( $max_price_ids, OBJECT_K  ));
    }

    return array_merge($min_price_ids, $max_price_ids);
}

XStore Core Plugin: Unauthenticated PHP Object Injection

The underlying vulnerable code exists in the process_callback function:

public function process_callback() {
	if (
		isset($_GET['error'])
		&& isset($_GET['error_description'])
		&& isset($_GET['error_reason'])
		&& isset($_GET['error_code'])
	){
		$page = ( is_checkout() ) ? 'checkout' : 'myaccount';
		wp_safe_redirect(wc_get_page_permalink($page));
		exit;
	}

	if( empty( $_GET['opauth'] ) ) return;

	$redirect = true;

	$opauth = unserialize(etheme_decoding($_GET['opauth']));
------- CUT HERE -------

The function is attached to the template_redirect hook and can be triggered by an unauthenticated user. Let's take a look at etheme_decoding function:

function etheme_decoding( $val ) {
	return base64_decode( $val );
}

With this condition, the user can simply pass a chained serialized object with base64 encoded format and trigger the PHP Object Injection. Until this advisory article was out, we weren't able to chain a necessary object to achieve RCE.

XStore Core Plugin: Unauthenticated Account Takeover

The underlying vulnerable code exists in the process_callback function:

public function process_callback() {
	if (
		isset($_GET['error'])
		&& isset($_GET['error_description'])
		&& isset($_GET['error_reason'])
		&& isset($_GET['error_code'])
	){
		$page = ( is_checkout() ) ? 'checkout' : 'myaccount';
		wp_safe_redirect(wc_get_page_permalink($page));
		exit;
	}

	if( empty( $_GET['opauth'] ) ) return;

	$redirect = true;

	$opauth = unserialize(etheme_decoding($_GET['opauth']));

	if( empty( $opauth['auth']['info'] ) ) {
		$error = sprintf(
			"%s %s %s",
			esc_html__( 'Can\'t login with.', 'xstore-core' ),
			( isset($opauth['auth']) && isset($opauth['auth']['provider']) ) ? $opauth['auth']['provider'] : 'undefined',
			esc_html__( 'Please, try again later.', 'xstore-core' )
		);
		wc_add_notice( $error, 'error' );
		return;
	}

	$info = $opauth['auth']['info'];

	if( empty( $info['email'] ) ) {
		$error = sprintf(
			"%s %s",
			$opauth['auth']['provider'],
			esc_html__( 'doesn\'t provide your email. Try to register manually.', 'xstore-core' )
		);
		wc_add_notice( $error, 'error' );
		return;
	}

	add_filter('dokan_register_nonce_check', '__return_false');
	add_filter('pre_option_woocommerce_registration_generate_username', array($this,'generate_username_option'), 10);

	$password = wp_generate_password();

	if ( ! empty( $info['first_name'] ) && ! empty( $info['last_name'] ) ) {
		$udata = array(
			'first_name' => $info['first_name'],
			'last_name' => $info['last_name']
		);
	} else {
		$udata = array();
	}

	$customer = wc_create_new_customer( $info['email'], '', $password, $udata);

	$user = get_user_by('email', $info['email']);

	$image = false;

	if (isset($info['image'])){
		$image = $info['image'];
	} elseif (isset($info['picture'])){
		$image = $info['picture'];
	}

	if (get_theme_mod( 'load_social_avatar_value', 'off' ) === 'on' && $image){
		$this->setup_avatar($user, $image);
	}

	if( is_wp_error( $customer ) ) {
		if( isset( $customer->errors['registration-error-email-exists'] ) ) {
			wc_set_customer_auth_cookie( $user->ID );
		}
	} else {
		wc_set_customer_auth_cookie( $customer );
	}

This function itself handles the process of third-party login. Notice that the function will try to call wc_set_customer_auth_cookie which will log in a customer and set the necessary authentication cookie. There is no proper check on how the $user object is built. We can simply set the $info['email'] value to any user's email to take over their account and login as that user.

The patch

XStore Theme patch

For the Unauthenticated Local Inclusion vulnerability, the vendor applies a sanitize_file_name function to prevent path traversal. The patch can be seen below:

For the Unauthenticated SQL Injection vulnerability, the vendor applies a floatval casting to the $_GET['min_price'] and $_GET['max_price'] variables as well as applying esc_sql function to the $stock_val variable. The patch can be seen below:

For the Authenticated Arbitrary Option Update vulnerability, the vendor decided to apply a nonce check (where the nonce value could only be fetched by a privileged user) and add a whitelist check to the $settings_name variable. The patch can be seen below:

XStore Core Plugin patch

For the Unauthenticated SQL Injection vulnerability, the vendor applies a floatval casting to the $_GET['min_price'] and $_GET['max_price'] variables. The patch can be seen below:

For the Unauthenticated PHP Object Injection vulnerability, the vendor decided to remove the unserialize function and replace it with the json_decode function instead. The patch can be seen below:

For the Authenticated Account Takeover vulnerability, the vendor decided to store all of the necessary user data to the et_auth-signature-* transient and use the stored value to log in the user instead of directly from user input. The patch can be seen below:

Conclusion

The vulnerabilities discussed here underscore the importance of securing all aspects of a plugin.

In the context of SQL query execution, we recommend developers to force cast the value to an integer or float if the intended value is indeed an integer or float value before constructing the value to the SQL query. We can also use $wpdb->prepare() statements by specifying "%d" or "%f" as the input format.

In the context of local file inclusion, we recommend applying a sanitization using sanitize_file_name function to prevent path traversal and additionally apply a strict whitelist check to only allow certain files to be included.

In the context of PHP object injection, we recommend entirely not to use a unserialize process and use other data formats such as JSON to store and fetch more complex data.

In the context of arbitrary option updates, we recommend applying a proper permission and nonce check to the related function or process and also applying a whitelist check on what option key could be updated.

Lastly, in the context of account takeover, especially case of third-party login, we recommend properly checking and verifying user data via the related third-party endpoint and not directly accepting data from user input for the login process.

Timeline

08 March, 2024Vulnerabilities found, reports generated.
09 March, 2024Vendor notified about vulnerabilities.
25 April, 2024No response from the vendor. Added the vulnerabilities to the Patchstack vulnerability database.
03 May, 2024Vendor replied.
04-06 May, 2024Vendor submits a proposed patch and we perform a patch validation.
07 May, 2024XStore theme version 9.3.9 alongside XStore Core plugin version 5.3.9 released to patch the reported issues.
14 May, 2024Security advisory article published.

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