The WordPress plugin WooCommerce runs on approximately 2,300,000 live websites and is currently the most prominent eCommerce platform used on the Web. During our research we discovered a PHP Object Injection vulnerability in WooCommerce (CVE-2017-18356) that allows to escalate privileges with a unique and interesting injection technique.

Who is affected

Installations with the following requirements are affected by this vulnerability:

WooCommerce version < 3.2.4

WordPress version >= 4.8.3

Impact - What can an attacker do

The vulnerability discussed in the following can only be exploited by an attacker that already benefits of some higher privileges. The ability to edit/add products in WooCommerce are required but not a full administration account that would allow to execute code anyway. That means that the vulnerability can be used to escalate an already realized attack or account takeover.

Being able to successfully inject an arbitrary PHP object during runtime can lead to different malicious actions. In the worst case, an attacker can execute arbitrary code on the server and completely take over the shop with all its sensitive data.

If you are interested in the technical details of the vulnerability and want to learn a new fancy trick for PHP object injections, continue reading on. If you would like to skip the technical part, we recommend updating your WooCommerce version at least :)

Technical Background

Before we start with the vulnerability, we need to briefly look into two things: The new add_placeholder_escape() method introduced to the WPDB class in WordPress 4.8.3 as a security fix, and the structure of serialized objects in PHP.

The latest WordPress WPDB class dilemma

The security release 4.8.3 of WordPress addressed a problem with the prepare() method of wpdb that, in some occasions, could lead to SQL injection.

In summary, the problem was with what happens when wpdb::prepare() is applied more than once. The example below demonstrates this behavior:

1 2 3 4 5 6 7 $query = $wpdb -> prepare ( "SELECT * FROM table WHERE column1 = %s" , $_GET [ 'c1' ] ); if ( isset ( $_GET [ 'c2' ]) ) { $query = $wpdb -> prepare ( $query . " AND column2 = %s" , $_GET [ 'c2' ] ); } $wpdb -> query ( $query );

Since both values that stem from user input ( c1 and c2 ) are bound through wpdb::prepare() , one might think that this is secure and that no SQL can be injected. But in WordPress 4.8.2 wpdb::prepare() would first quote the placeholders and then bind the parameters with vsprintf() . Let’s look at what would happen if user input is set as follows:

1 2 $_GET [ 'c1' ] = " %s " ; $_GET [ 'c2' ] = [ " OR 1=1 -- " , "" ];

In our example above, the first call to wpdb::prepare() would add quotes around the placeholder %s and escape the user input c1 . However, in our case the placeholder %s is replaced with the user input that again contains the placeholder %s . The $query would end up like the following:

1 SELECT * FROM table WHERE column1 = ' %s ' ;

In the second call to wpdb::prepare() , all placeholders in the query ( %s ) would be quoted again and this confuses the quoting:

1 SELECT * FROM table WHERE column1 = ' ' % s ' ' AND column2 = '%s' ;

Finally, when the values are bound with vsprintf() the query would look as follows:

1 SELECT * FROM table WHERE column1 = ' ' OR 1 = 1 -- ' ' AND column2 = '';

As you can see, this results in a SQL injection vulnerability. This issue was first documented by Slavco Mihajloski and later disclosed by Anthony Ferrara.

The applied fix in WordPress 4.8.3 for this problem is to replace every occurrence of percent signs in wpdb::prepare() with a random, 66 characters long placeholder after the values are bound. These placeholders will then be replaced back to percent signs just before the query is executed.

PHP’s unserialize()

For a better understanding of the actual vulnerability described in this post, let’s take a quick look at how the PHP interpreter turns serialized data back into its respective data type. For this intend, let’s examine the following example that represents a serialized object of type stdClass with two attributes firstname and mail :

1 O:8:"stdClass":2:{s:9:"firstname";s:4:"rips";s:4:"mail";s:13:"rips@mail.com";}

During deserialization with the PHP function unserialize() , this string will be interpreted one character at a time.

Seeing the first character O , the PHP interpreter knows that an object is being unserialized. For restoring the state of an object, the name of the class, the name of the attributes and their respective values are needed. The rest of the data gets interpreted as follows:

The name of the class consisting of 8 characters is stdClass .

characters is . The object has 2 attributes.

attributes. The content of the first attribute is of type string ( s ), has a length of 9 and a value of firstname .

), has a length of and a value of . The content of the second attribute is of type string ( s ), has a length of 4 and a value of rips .

), has a length of and a value of . … and so on.

The key point here is that values are always accompanied by their respective length. If the content of the first attribute would not be rips but rips";: , the special characters would not need any special encoding and would not break anything. Only the length of the value would need adjustment to 7 and the PHP interpreter would know that these characters need to be interpreted as part of the value.

Exploitation

With the technical background in mind, let’s get to the interesting exploitation part.

The Idea

The main idea behind the exploitation technique is demonstrated with the following code sample:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Object_Injection { public function __wakeup () { die ( 'Object injected' ); } } $input_1 = 'abc_xxxxxxxxxxxxxxxxxxx_' ; $input_2 = 'whatever";i:1;O:16:"Object_Injection":0:{}' ; $a = array ( $input_1 , $input_2 ); $serialized = serialize ( $a ) ; // a:2:{i:0;s:24:"abc_xxxxxxxxxxxxxxxxxxx_";i:1;s:42:"whatever";i:1;O:16:"Object_Injection":0:{}";} $serialized = preg_replace ( '/\_[^\_]*\_/' , '%' , $serialized ); // critical modification $unserialized = unserialize ( $serialized );

In line 12 a simple array with two elements of type string gets serialized. Unserializing the content of $serialized at this point would simply restore the original array. The critical part in this example is that the regular expression in line 15 modifies the serialized data. It replaces _xxxxxxxxxxxxxxxxxxx_ with % . This results in the following malformed serialized data:

1 a:2:{i:0;s:24:"abc%";i:1;s:42:"whatever";i:1;O:16:"Object_Injection":0:{}";}

Notice the length of the first array element being 24 . However, this was the length of the element before we made our replacement. The content of the first array element now reads the 24 characters abc%";i:1;s:42:"whatever due to its length not being adapted to the replacement we made. Thus, the second element will be O:16:"Object_Injection":0:{}" which is our successfully injected PHP object.

Granted, this is a fairly crafted example and one might have doubts if such a situation can occur in real code. However, the replacement we made in the example above does not differ much from the placeholder replacement done in wpdb before a query is executed.

The WooCommerce Vulnerability

The vulnerable code in WooCommerce is in WC_Shortcode_Products::get_products() .

woocommerce/includes/shortcodes/class-wc-shortcode-products.php

1 2 3 4 5 6 7 8 9 10 11 12 13 14 protected function get_products () { $transient_name = ... ; $products = get_transient ( $transient_name ); if ( false === $products || ! is_a ( $products , 'WP_Query' ) ) { ⋮ $products = new WP_Query ( $this -> query_args ); ⋮ set_transient ( $transient_name , $products , DAY_IN_SECONDS * 30 ); } ⋮ return $products ; }

The WordPress function set_transient() is often used for caching (line 10). It saves given data for a specified period of time in the database. If the data is an array or an object it will be serialized. This data can subsequently be retrieved with get_transient() which deserializes it (line 3). The developers used this method to cache the whole WP_Query object created in line 7 to save resources. The WP_Query() object contains in its $posts attribute the content of the products retrieved and was cached at this point to save complex database queries.

Besides containing the content of the retrieved posts/products, the WP_Query object also holds the $request attribute. This attribute contains the SQL query that was executed to retrieve the posts. The SQL query is constructed from various arguments passed to WP_Query and still has the 66 characters long placeholders in place (see explanation above). When set_transient() saves the object to the database, these placeholders get removed after the object is serialized. So if an attacker can get percent signs into the $request attribute, they will be 66 characters long placeholders when the object is serialized but will be replaced back to one character when the serialized string is saved to the database. Let’s see how this can be exploited.

Adding the WooCommerce shortcode [products skus="testsku, test%%"] to a post or a page will cause a WP_Query object with the following $request attribute to be serialized and cached in WC_Shortcode_Products::get_products() .

1 2 3 4 5 6 7 SELECT wp_posts . * FROM wp_posts INNER JOIN wp_postmeta ON ( wp_posts . ID = wp_postmeta . post_id ) WHERE 1 = 1 AND ( ⋮ AND ( ( wp_postmeta . meta_key = '_sku' AND wp_postmeta . meta_value IN ( 'testsku' , 'test{9016c2295825bd198c342af50fd2da2e4a1f72f2f23ab4ea5f381c41b99440ba}{9016c2295825bd198c342af50fd2da2e4a1f72f2f23ab4ea5f381c41b99440ba}' ) ) ) AND ⋮

Notice how test%% was transformed with placeholders to test{9016c22…}{9016c22…} . Now when the serialized object is saved to the database, it will look like this:

1 2 3 4 5 6 7 8 9 10 11 12 O : 8 : "WP_Query" : 49 : { ⋮ s : 7 : "request" ; s : 590 : "SELECT wp_posts.* FROM wp_posts INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id ) WHERE 1=1 AND ( ⋮ AND ( ( wp_postmeta.meta_key = '_sku' AND wp_postmeta.meta_value IN ('testsku','test%%') ) ) AND wp_posts.post_type = 'product' AND ((wp_posts.post_status = 'publish')) GROUP BY wp_posts.ID ORDER BY wp_posts.post_title ASC " ; s : 5 : "posts" ; a : 1 : { i : 0 ; O : 7 : "WP_Post" : 24 : { s : 2 : "ID" ; i : 207 ; s : 11 : "post_author" ; s : 1 : "1" ; s : 9 : "post_date" ; s : 19 : "2013-06-07 11:38:12" ; s : 13 : "post_date_gmt" ; s : 19 : "2013-06-07 11:38:12" ; s : 12 : "post_content" ; s : 27 : "Pellentesque habitant morbi" ... }} ⋮ }

Notice in line 7 how test%% was transformed back. However, the specified length of $request is still 590 . During deserialization, more characters will be treated as the value of $request which can go up to the content of the $post_content attribute of the first post retrieved. The latter is the content of a product retrieved with the shortcode. This can be adjusted by an attacker and, with proper alignment, a PHP object can be injected.

We refrain from releasing a full working exploit at this moment.

Strictly speaking, the underlying issue that made this exploitation possible is more a WordPress core issue than it is a WooCommerce one. The issue has been reported to the WordPress security team but remains unpatched as the time of writing. Code in which the whole WP_Query object is cached with the transient API and a user can get percent signs into the executed SQL query, might be susceptible to this vulnerability. Slavco Mihajloski has also identified the same vulnerability in the WordPress plugin WP Job Manager. Even though caching the WP_Query object with the transient API is given as a main example of usage in the WordPress codex, we strongly advise against this practice and instead, recommend resorting to caching the IDs of the retrieved posts to avoid complex database queries on subsequent requests.

Time Line

What 2017/11/13 Reported the vulnerability to Automattic on Hackerone 2017/11/15 Automattic acknowledged vulnerability 2017/11/16 Automattic released security patch for WooCommerce with version 3.2.4 2018/01/08 Informed WordPress on H1 about the issue and its relation to a practice advocated in the WordPress codex 2018/01/10 WordPress acknowledged the issue 2018/01/10 Asked WordPress about time plan. No response 2018/01/23 Asked WordPress about status. No response 2018/01/31 Automattic patches similar issue in WP Job Manager v1.29.3

Summary

Passing unvalidated user input to PHP’s unserialize() function is known to be the root cause for PHP object injections. In this blog post we demonstrated a new pitfall that can lead to object injection even though the data being deserialized is serialized before. Specifically, we showed how the modification of serialized data can have dramatic security consequences. A real-world example of this kind of attack was demonstrated with the popular WordPress eCommerce plugin WooCommerce.

We would like to thank the security team of Automattic for their professional collaboration and for quickly resolving the issues with the release of version 3.2.4. If you are still using an older version, we encourage to update.