WooCommerce 3.6.4 - CSRF Bypass to Stored XSS

by dennis brinkrolf|

WooCommerce Stored XSS

WooCommerce is the most popular e-commerce plugin for WordPress with over 5 million installations. A flaw in the way WooCommerce handles imports of products results in a stored cross-site scripting vulnerability (XSS) that can be exploited through cross-site request forgery (CSRF).

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():


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

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.


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

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.


199    public function do_ajax_product_import() {
200        global $wpdb;
202        check_ajax_referer( 'wc-product-import', 'security' );
204        if ( ! $this->import_allowed() || ! isset( $_POST['file'] ) ) {
205            wp_send_json_error(array('message' => __('Insufficient privileges to import products.', 'woocommerce' )));
206        }
208        // Begin import process here
209    }

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.


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


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.