In WooCommerce shop managers and administrators have the ability to import (insert/update) products via a .csv file. Every product in WooCommerce has a product description where the shop manager can insert limited HTML, i.e. very basic HTML tags and attributes, such as the <a> tag in combination with the href attribute. It is important to mention that the administrator is able to use unfiltered HTML in the WordPress default installation.

An attacker can use CSRF to import (insert/update) any product via a .csv file. The attacker needs to upload a .csv file which is possible with a user of the role author or higher. If the attacker tricks an administrator of a targeted blog into visiting a malicious website set up by the attacker he can import products with unsanitized HTML in the product description via CSRF. Finally, this leads to a stored XSS in every product of the vulnerable shop.

Technical Analysis

The importer functionality consists of 4 steps which are processed in the given order:

  1. Upload a CSV file (upload)
  2. Column mapping (mapping)
  3. Import (import)
  4. Done! (done)

The words in the parentheses are used as function name in the WooCommerce product importer.

Bypassing the Nonce

The importer of WooCommerce uses the PHP function call_user_func() to call the different steps of the importing process. The first step of the importer (upload) is protected by a nonce (anti-CSRF token), however, the other steps are not protected.

The following code snippet shows the invokation of call_user_func():

/includes/admin/importers/class-wc-product-csv-importer-controller.php (Simplified code)

216
217
218
219
220
public function dispatch() {
    
    call_user_func( $this->steps[ $this->step ]['view'], $this );
    
}

The array $this->steps is a whitelist and consists of the different importer steps described above. The attacker controlled variable is $this->step, this means we can only call functions listed in the view field from an WC_Product_CSV_Importer_Controller ($this) object.

However, we can skip the upload step of the importer and go directly to the import() function from the import step.

CSRF with Self-Created Nonce

The import() function localizes and enqueues the wc-product-import JavaScript with attacker controlled inputs and a valid nonce which leads to CSRF.

/includes/admin/importers/class-wc-product-csv-importer-controller.php (Simplified code)

401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
public function import(){
    
    wp_localize_script(
        'wc-product-import',
        'wc_product_import_params',
        array(
            'import_nonce'    => wp_create_nonce( 'wc-product-import' ),
            'mapping'         => array(
                'from' => $mapping_from,
                'to'   => $mapping_to,
            ),
            'file'            => $this->file,
            'update_existing' => $this->update_existing,
            'delimiter'       => $this->delimiter,
        )
    );
    wp_enqueue_script( 'wc-product-import' );
    
}

The function wp_localize_script() localizes a registered script with data for a JavaScript variable. In simple terms, all the data in the wc_product_import_params variable are controlled by an attacker. Furthermore, a valid import_nonce is created with the wp_create_nonce() function in line 407 for the wc-product-import action. Finally, the JavaScript is enqueued in line 417 and sends an AJAX request to the WordPress backend with the attacker controlled $_POST variable and the valid nonce.

/includes/admin/class-wc-admin-importers.php (Simplified code)

199
200
201
202
203
204
205
206
207
208
209
public function do_ajax_product_import() {
    global $wpdb;

    check_ajax_referer( 'wc-product-import', 'security' );

    if ( ! $this->import_allowed() || ! isset( $_POST['file'] ) ) {
        wp_send_json_error( array( 'message' => __( 'Insufficient privileges to import products.', 'woocommerce' ) ) );
    }

    // Begin import process here
}

The invoked AJAX request calls the do_ajax_product_import() function. In line 202 the nonce check of the check_ajax_referer() function is bypassed via the self-created nonce described above. In line 204 the code checks if the current user has the privileges to import products. This is the case because the AJAX request is invoked by the victim’s browser (administrator). All used parameters like $_POST['file'] are provided by the wp_localize_script() described above. Finally, the products from the malicious .csv file are imported with the XSS payload in the product description.

Summary

The introduced vulnerabilities can lead to stored XSS in every product of the shop. This allows an attacker to execute arbitrary JavaScript code in the browser of the administrator who triggered the CSRF vulnerability on the target website or any visitor of the shop, and as a result to send HTTP requests using the session of the victim. All of the JavaScript execution happens in the background without the victims noticing. The mistake was to only protect the first step of the import functionality via a nonce, but not the others. At a first glance, it does not seem tragic but a sophisticated attacker could abuse this small mistake to compromise blogs. It should be noted that WordPress allows administrators of a blog to directly edit the .php files of themes and plugins from within the admin dashboard. By abusing the XSS vulnerability, the attacker can gain arbitrary PHP code execution on the remote server.

Timeline

Date What
2019/05/29 First contact with vendor
2019/05/29 Response of vendor
2019/06/27 Insufficient patch proposed
2019/06/29 Bypass #1 reported and acknowledged
2019/07/01 Vendor proposed a valid fix
2019/07/02 Fix with version 3.6.5 released