
Modern Events Calendar Lite <= 7.33.0 — Injection SQL non authentifiée
mec_list_load_more (atts[include] / atts[exclude])| Détail | Valeur |
|---|---|
| Extension | Modern Events Calendar Lite |
| Slug | modern-events-calendar-lite |
| Auteur | Webnus |
| Versions affectées | <= 7.33.0 (la version Lite actuellement distribuée par le fournisseur). Le bug est présent sur toute la gamme post-w.org ; confirmé en laboratoire sur 6.5.6 et 7.33.0, confirmé statiquement en 5.21.2. 6.5.6 = dernière version wordpress.org (figée lors de la fermeture du 2022-05-11) ; 7.33.0 = version actuelle distribuée depuis mec.webnus.net |
| Installations actives | Compteur wordpress.org masqué depuis la fermeture ; historiquement 100 000+. Toujours activement distribué/mis à jour par le fournisseur (Lite via mec.webnus.net ; le même code 7.x sous-tend le MEC Pro commercialisé) |
| CWE | CWE-89 (Injection SQL) |
| Vulnérabilité | Injection SQL aveugle non authentifiée (temporelle / booléenne / basée sur les erreurs) |
| Privilège requis | Aucun (wp_ajax_nopriv_* — pré-authentification) |
| Interaction utilisateur | Aucune |
| CVSS v3.1 | 7.5 (Élevé) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| Statut | Vérifié en laboratoire de bout en bout sur 7.33.0 (actuel) et 6.5.6 (WordPress 6.6.5, MariaDB 10.x) |
| CVE / GHSA | CVE-2026-11349 |
Modern Events Calendar Lite enregistre une famille d'actions admin-ajax.php "load more" non authentifiées pour ses skins de liste d'événements (list, grid, masonry, agenda, timeline, tile, custom). Chaque gestionnaire lit le tableau de requête atts contrôlé par l'attaquant, le fait passer par une fonction auxiliaire nommée sanitize_deep_array() qui — lorsqu'elle est appelée avec ses arguments par défaut — n'effectue aucune désinfection du tout — puis concatène les valeurs atts['include'] (et atts['exclude']) brutes dans un fragment SQL post_id IN (...) exécuté avec $wpdb->get_results() et sans $wpdb->prepare().
Comme les points d'entrée sont enregistrés sur wp_ajax_nopriv_*, aucune authentification, aucun compte, nonce ou interaction utilisateur n'est requis. Un attaquant distant non authentifié peut injecter du SQL arbitraire dans la clause WHERE d'un SELECT sur wp_mec_dates et lire toutes les données de la base WordPress (empreintes de mots de passe utilisateur, secrets/clés wp_options, données d'autres extensions) via des techniques aveugles temporelles / booléennes / basées sur les erreurs.
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;
}
La garde (is_array($excludes) and !count($excludes)) fait de la fonction une pure opération nulle lorsque $excludes est le tableau vide par défaut — chaque valeur est recopiée telle quelle. L'intention était visiblement "s'il existe une liste d'exclusion, ignorer ces clés" ; la logique booléenne ignore au contraire tout lorsqu'aucune liste d'exclusion n'est fournie.
$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']) est appelée avec un seul argument → $excludes prend par défaut array() → la branche d'opération nulle ci-dessus → $atts est la valeur brute et non fiable de $_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']).")";
Les éléments du tableau sont directement intégrés via implode() dans la chaîne SQL, sans cast entier ni échappement. (absint()/(int) sur chaque élément aurait fermé cette faille.)
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 chaîne entièrement construite est transmise telle quelle à $wpdb->get_results().
Flux de contamination (requête → puits) :
$_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
L'enregistrement nopriv rend le point d'accès atteignable avant toute authentification. La même structure de load_more() et le constructeur de requête partagé skins.php sont présents dans les skins frères, chacun avec sa propre action wp_ajax_nopriv_*, de sorte que la même injection est atteignable via l'une quelconque des :
Action AJAX (nopriv) | Gestionnaire du 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 |
Aucun nonce n'est vérifié dans load_more(), et l'action n'exige la présence d'aucun shortcode d'extension sur une page — les gestionnaires AJAX sont enregistrés inconditionnellement à l'initialisation.
Toutes les requêtes sont non authentifiées (aucun cookie, aucun nonce). La valeur injectée est placée dans atts[include][] ; la charge utile ferme les deux parenthèses ouvertes du groupe IN ((...) AND (... IN ( et ajoute un OR <sleep> de niveau supérieur pour que la condition soit évaluée pour chaque ligne analysée, puis commente la fin )) ORDER BY ... :