
Modern Events Calendar Lite <= 7.33.0 — Inyección SQL no autenticada
mec_list_load_more (atts[include] / atts[exclude])| Detalle | Valor |
|---|---|
| Plugin | Modern Events Calendar Lite |
| Slug | modern-events-calendar-lite |
| Autor | Webnus |
| Afectado | <= 7.33.0 (la versión Lite actual distribuida por el proveedor). El fallo está presente en todo el rango posterior a w.org; confirmado en laboratorio en 6.5.6 y 7.33.0, confirmado estáticamente en 5.21.2. 6.5.6 = última compilación de wordpress.org (congelada en el cierre del 2022-05-11); 7.33.0 = compilación actual distribuida desde mec.webnus.net |
| Instalaciones activas | Conteo de wordpress.org oculto desde el cierre; históricamente más de 100.000. Aún distribuida/actualizada activamente por el proveedor (Lite vía mec.webnus.net; la misma base de código 7.x subyace al MEC Pro vendido activamente) |
| CWE | CWE-89 (Inyección SQL) |
| Vulnerabilidad | Inyección SQL ciega no autenticada (basada en tiempo / booleana / en errores) |
| Privilegio requerido | Ninguno (wp_ajax_nopriv_* — pre-autenticación) |
| Interacción del usuario | Ninguna |
| CVSS v3.1 | 7.5 (Alta) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| Estado | Verificado en laboratorio de extremo a extremo en 7.33.0 (actual) y 6.5.6 (WordPress 6.6.5, MariaDB 10.x) |
| CVE / GHSA | CVE-2026-11349 |
Modern Events Calendar Lite registra una familia de acciones "load more" no autenticadas en admin-ajax.php para sus skins de listado de eventos (list, grid, masonry, agenda, timeline, tile, custom). Cada manejador lee el array de solicitud atts controlado por el atacante, lo pasa a través de un helper llamado sanitize_deep_array() que — cuando se llama con sus argumentos por defecto — no realiza ninguna sanitización — y luego concatena los valores de atts['include'] (y atts['exclude']) crudos dentro de un fragmento SQL post_id IN (...) que se ejecuta con $wpdb->get_results() y sin $wpdb->prepare().
Dado que los puntos de entrada están registrados en wp_ajax_nopriv_*, no se requiere autenticación, cuenta, nonce ni interacción del usuario. Un atacante remoto no autenticado puede inyectar SQL arbitrario en la cláusula WHERE de un SELECT contra wp_mec_dates y leer cualquier dato en la base de datos de WordPress (hashes de contraseñas de usuarios, secretos/claves de wp_options, datos de otros plugins) mediante técnicas ciegas basadas en tiempo / booleanas / en errores.
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;
}
El guard (is_array($excludes) and !count($excludes)) convierte la función en una operación nula completa cuando $excludes es el array vacío por defecto — cada valor se copia tal cual. La intención era evidentemente "si hay una lista de exclusión, omitir esas claves"; la lógica booleana, en cambio, omite todo cuando no se proporciona ninguna lista de exclusión.
$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']) se llama con un único argumento → $excludes toma por defecto array() → se ejecuta la rama de operación nula anterior → $atts es el $_REQUEST['atts'] crudo y no confiable.
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']).")";
Los elementos del array se pasan directamente a implode() dentro de la cadena SQL sin conversión a entero ni escape. (absint()/(int) en cada elemento habría cerrado esto.)
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 cadena completamente construida se entrega tal cual a $wpdb->get_results().
Flujo de la contaminación (solicitud → sumidero):
$_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
El registro nopriv hace que el endpoint sea alcanzable antes de la autenticación. La misma forma de load_more() y el constructor de consultas compartido en skins.php están presentes en los skins hermanos, cada uno con su propia acción wp_ajax_nopriv_*, por lo que la misma inyección es alcanzable a través de cualquiera de:
Acción AJAX (nopriv) | Skin manejador |
|---|---|
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 |
No se comprueba ningún nonce en load_more(), y la acción no requiere que haya ningún shortcode del plugin en una página — los manejadores AJAX se registran incondicionalmente en el init.
Todas las solicitudes son no autenticadas (sin cookie, sin nonce). El valor inyectado se coloca en atts[include][]; el payload cierra los dos paréntesis abiertos del grupo IN ((...) AND (... IN ( y agrega un OR <sleep> de nivel superior para que la condición se evalúe para cada fila escaneada, y luego comenta el )) ORDER BY ... final:
TARGET='https://victim.example' # plain permalinks: use admin-ajax.php directly