
Modern Events Calendar Lite <= 7.33.0 — SQL Injection non autenticata
mec_list_load_more (atts[include] / atts[exclude])| Dettaglio | Valore |
|---|---|
| Plugin | Modern Events Calendar Lite |
| Slug | modern-events-calendar-lite |
| Autore | Webnus |
| Versioni interessate | <= 7.33.0 (l'attuale release Lite distribuita dal vendor). Il bug è presente nell'intero arco post-w.org; confermato in laboratorio su 6.5.6 e 7.33.0, confermato staticamente in 5.21.2. 6.5.6 = ultima build wordpress.org (congelata alla chiusura del 2022-05-11); 7.33.0 = build attuale distribuita da mec.webnus.net |
| Installazioni attive | Conteggio wordpress.org nascosto dalla chiusura; storicamente 100.000+. Ancora attivamente distribuito/aggiornato dal vendor (Lite tramite mec.webnus.net; lo stesso codebase 7.x è alla base del MEC Pro attivamente venduto) |
| CWE | CWE-89 (SQL Injection) |
| Vulnerabilità | Iniezione SQL cieca non autenticata (basata sul tempo / booleana / basata sugli errori) |
| Privilegio richiesto | Nessuno (wp_ajax_nopriv_* — pre-autenticazione) |
| Interazione utente | Nessuna |
| CVSS v3.1 | 7.5 (Alto) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| Stato | Verificato in laboratorio end-to-end su 7.33.0 (attuale) e 6.5.6 (WordPress 6.6.5, MariaDB 10.x) |
| CVE / GHSA | CVE-2026-11349 |
Modern Events Calendar Lite registra una famiglia di azioni "load more" non autenticate su admin-ajax.php per le sue skin di elenco eventi (list, grid, masonry, agenda, timeline, tile, custom). Ogni handler legge l'array atts controllato dall'attaccante dalla richiesta, lo passa a un helper chiamato sanitize_deep_array() che — quando chiamato con i suoi argomenti predefiniti — non esegue alcuna sanitizzazione — e poi concatena i valori di atts['include'] (e atts['exclude']) grezzi in un frammento SQL post_id IN (...) eseguito con $wpdb->get_results() e senza $wpdb->prepare().
Poiché i punti di ingresso sono registrati su wp_ajax_nopriv_*, non è richiesta alcuna autenticazione, account, nonce o interazione utente. Un attaccante remoto non autenticato può iniettare SQL arbitrario nella clausola WHERE di una SELECT contro wp_mec_dates e leggere qualsiasi dato nel database WordPress (hash delle password utente, segreti/chiavi di wp_options, dati di altri plugin) tramite tecniche cieche basate sul tempo / booleane / basate sugli errori.
app/libraries/main.php:9607:
public function sanitize_deep_array($inputs, $type = 'text', $excludes = array(), $path = '')
{
if(!is_array($inputs)) return $inputs;
$sanitized = array();
foreach($inputs as $key => $val)
{
$p = $path.$key.'.';
if((is_array($excludes) and in_array(trim($p, '. '), $excludes))
or (is_array($excludes) and !count($excludes))) // line 9615
{
$sanitized[$key] = $val; // <-- RAW passthrough, no sanitization
continue;
}
// ... (sanitize_text_field / (int) / esc_url / ... only reached when $excludes is non-empty)
}
return $sanitized;
}
Il controllo (is_array($excludes) and !count($excludes)) rende la funzione una completa no-op quando $excludes è l'array vuoto predefinito — ogni valore viene copiato così com'è. L'intento era evidentemente "se esiste una lista di esclusione, salta quelle chiavi"; la logica booleana salta invece tutto ogni volta che non viene fornita alcuna lista di esclusione.
$excludesapp/skins/list.php:499-501 (load_more()):
$this->sf = (isset($_REQUEST['sf']) and is_array($_REQUEST['sf']))
? $this->main->sanitize_deep_array($_REQUEST['sf']) : array();
$apply_sf_date = isset($_REQUEST['apply_sf_date']) ? sanitize_text_field($_REQUEST['apply_sf_date']) : 1;
$atts = $this->sf_apply(((isset($_REQUEST['atts']) and is_array($_REQUEST['atts']))
? $this->main->sanitize_deep_array($_REQUEST['atts']) : array()), $this->sf, $apply_sf_date); // line 501
sanitize_deep_array($_REQUEST['atts']) viene chiamato con un singolo argomento → $excludes assume il valore predefinito array() → il ramo no-op di cui sopra → $atts è il grezzo, non attendibile $_REQUEST['atts'].
IN (...)app/libraries/skins.php:
// line 603 (exclude → NOT IN)
if(isset($this->atts['exclude']) and is_array($this->atts['exclude']) and count($this->atts['exclude']))
$where_AND .= " AND `post_id` NOT IN (".implode(',', $this->atts['exclude']).")";
// line 606 (include → IN)
if(isset($this->atts['include']) and is_array($this->atts['include']) and count($this->atts['include']))
$where_AND .= " AND `post_id` IN (".implode(',', $this->atts['include']).")";
Gli elementi dell'array vengono passati a implode() direttamente nella stringa SQL senza cast a intero e senza escaping. (absint()/(int) su ogni elemento avrebbe chiuso questa falla.)
app/libraries/db.php:79:
public function select($query, $result = 'loadObjectList')
{
$query = $this->_prefix($query); // only swaps `#__` for the table prefix
$database = $this->get_DBO();
if($result == 'loadObjectList') return $database->get_results($query, OBJECT_K); // line 87 — no prepare()
// ...
}
La stringa completamente costruita viene passata così com'è a $wpdb->get_results().
Flusso del taint (richiesta → sink):
$_REQUEST['atts'] (attacker-controlled, unauthenticated)
→ app/skins/list.php:501 sanitize_deep_array($atts) (NO-OP: default empty $excludes)
→ MEC_skin::initialize($atts) ($this->atts = raw atts)
→ app/libraries/skins.php:606 "... post_id IN (".implode(',', $this->atts['include']).")"
→ app/libraries/db.php:87 $wpdb->get_results($query) (no prepare)
app/skins/list.php:51-52:
$this->factory->action('wp_ajax_mec_list_load_more', array($this, 'load_more'));
$this->factory->action('wp_ajax_nopriv_mec_list_load_more', array($this, 'load_more')); // <-- unauthenticated
La registrazione nopriv rende l'endpoint raggiungibile prima dell'autenticazione. La stessa struttura di load_more() e il query builder condiviso in skins.php sono presenti nelle skin sorelle, ciascuna con la propria azione wp_ajax_nopriv_*, quindi la stessa iniezione è raggiungibile tramite una qualsiasi di:
Azione AJAX (nopriv) | Handler della skin |
|---|---|
mec_list_load_more | app/skins/list.php:497 |
mec_grid_load_more | app/skins/grid.php:497 |
mec_masonry_load_more | app/skins/masonry.php:229 |
mec_agenda_load_more | app/skins/agenda.php:242 |
mec_timeline_load_more | app/skins/timeline.php:242 |
mec_tile_load_more | app/skins/tile.php:446 |
mec_custom_load_more | app/skins/custom.php:233 |
Nessun nonce viene verificato in load_more(), e l'azione non richiede che alcuno shortcode del plugin sia presente in una pagina: gli handler AJAX vengono registrati incondizionatamente all'init.
Tutte le richieste sono non autenticate (nessun cookie, nessun nonce). Il valore iniettato viene inserito in atts[include][]; il payload chiude le due parentesi aperte del gruppo IN ((...) AND (... IN ( e aggiunge un OR <sleep> di livello superiore così che la condizione venga valutata per ogni riga scansionata, poi commenta i )) ORDER BY ... finali:
TARGET='https://victim.example' # plain permalinks: use admin-ajax.php directly