
Modern Events Calendar Lite <= 7.33.0 — Injeção SQL Não Autenticada
mec_list_load_more (atts[include] / atts[exclude])| Detalhe | Valor |
|---|---|
| Plugin | Modern Events Calendar Lite |
| Slug | modern-events-calendar-lite |
| Autor | Webnus |
| Versões afetadas | <= 7.33.0 (a versão Lite atual distribuída pelo fornecedor). O bug está presente em toda a faixa pós-w.org; confirmado em laboratório nas versões 6.5.6 e 7.33.0, confirmado estaticamente na 5.21.2. 6.5.6 = último build do wordpress.org (congelado no fechamento de 2022-05-11); 7.33.0 = build atual distribuído a partir de mec.webnus.net |
| Instalações ativas | contagem no wordpress.org ocultada desde o fechamento; historicamente 100,000+. Ainda distribuído/atualizado ativamente pelo fornecedor (Lite via mec.webnus.net; o mesmo código 7.x serve de base para o MEC Pro, vendido ativamente) |
| CWE | CWE-89 (Injeção SQL) |
| Vulnerabilidade | Injeção SQL cega não autenticada (baseada em tempo / booleana / baseada em erro) |
| Privilégio necessário | Nenhum (wp_ajax_nopriv_* — pré-autenticação) |
| Interação do usuário | Nenhuma |
| CVSS v3.1 | 7.5 (Alto) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| Status | Verificado em laboratório de ponta a ponta na 7.33.0 (atual) e 6.5.6 (WordPress 6.6.5, MariaDB 10.x) |
| CVE / GHSA | CVE-2026-11349 |
O Modern Events Calendar Lite regista uma família de ações "load more" não autenticadas em admin-ajax.php para seus skins de listagem de eventos (list, grid, masonry, agenda, timeline, tile,
custom). Cada handler lê o array atts da requisição, controlado pelo atacante, e o passa por um
helper chamado sanitize_deep_array() que — quando chamado com seus argumentos padrão — não realiza nenhuma sanitização — e depois concatena os valores de atts['include'] (e atts['exclude']) crus em um fragmento SQL post_id IN (...) que é executado com $wpdb->get_results() e sem $wpdb->prepare().
Como os pontos de entrada são registrados em wp_ajax_nopriv_*, não é necessária autenticação, conta, nonce ou interação do usuário. Um atacante remoto não autenticado pode injetar SQL arbitrário na cláusula WHERE de um SELECT contra wp_mec_dates e ler qualquer dado no banco de dados do WordPress (hashes de senha de usuários, segredos/chaves de wp_options, dados de outros plugins) por meio de técnicas cegas baseadas em tempo / booleanas / baseadas em erro.
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;
}
A guarda (is_array($excludes) and !count($excludes)) faz com que a função seja uma operação nula completa quando $excludes é o array vazio padrão — cada valor é copiado literalmente. A intenção era evidentemente "se houver uma lista de exclusão, pule essas chaves"; a lógica booleana, em vez disso, pula tudo sempre que nenhuma lista de exclusão é fornecida.
$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']) é chamado com um único argumento → $excludes assume o padrão array() → o ramo de operação nula acima → $atts é o $_REQUEST['atts'] cru e não confiável.
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']).")";
Os elementos do array são passados por implode() diretamente para a string SQL, sem cast para inteiro e sem escape. (absint()/(int) em cada elemento teria corrigido isso.)
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()
// ...
}
A string totalmente montada é entregue a $wpdb->get_results() literalmente.
Fluxo de taint (requisição → 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
O registro nopriv torna o endpoint acessível antes da autenticação. O mesmo formato de load_more() e o construtor de consultas compartilhado skins.php estão presentes nos skins irmãos, cada um com sua própria ação wp_ajax_nopriv_*, portanto a mesma injeção é alcançável por qualquer um deles:
Ação AJAX (nopriv) | Handler do 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 |
Nenhum nonce é verificado em load_more(), e a ação não exige que qualquer shortcode do plugin esteja presente em uma página — os handlers AJAX são registrados incondicionalmente no init.
Todas as requisições são não autenticadas (sem cookie, sem nonce). O valor injetado é colocado em atts[include][]; o payload fecha os dois parênteses abertos do grupo IN ((...) AND (... IN ( e acrescenta um OR <sleep> de nível superior para que a condição seja avaliada para cada linha varrida e, em seguida, comenta o )) ORDER BY ... final:
TARGET='https://victim.example' # plain permalinks: use admin-ajax.php directly