
Modern Events Calendar Lite <= 7.33.0 — Неаутентифицированная SQL-инъекция
mec_list_load_more (atts[include] / atts[exclude])| Деталь | Значение |
|---|---|
| Плагин | Modern Events Calendar Lite |
| Slug | modern-events-calendar-lite |
| Автор | Webnus |
| Затронутые версии | <= 7.33.0 (текущая распространяемая вендором Lite-версия). Ошибка присутствует во всем диапазоне после w.org; лабораторно подтверждена на 6.5.6 и 7.33.0, статически подтверждена в 5.21.2. 6.5.6 = последняя сборка на wordpress.org (заморожена с момента закрытия 2022-05-11); 7.33.0 = текущая сборка, распространяемая с mec.webnus.net |
| Активные установки | Количество на wordpress.org скрыто с момента закрытия; исторически более 100 000. По-прежнему активно распространяется и обновляется вендором (Lite через mec.webnus.net; та же кодовая база 7.x лежит в основе активно продаваемого MEC Pro) |
| CWE | CWE-89 (SQL-инъекция) |
| Уязвимость | Неаутентифицированная слепая SQL-инъекция (time-based / boolean / error-based) |
| Требуемые привилегии | Отсутствуют (wp_ajax_nopriv_* — без аутентификации) |
| Взаимодействие пользователя | Не требуется |
| CVSS v3.1 | 7.5 (High) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| Статус | Лабораторно подтверждена от начала до конца на 7.33.0 (текущая) и 6.5.6 (WordPress 6.6.5, MariaDB 10.x) |
| CVE / GHSA | CVE-2026-11349 |
Modern Events Calendar Lite регистрирует семейство неаутентифицированных действий admin-ajax.php «load more» для своих скинов списка событий (list, grid, masonry, agenda, timeline, tile, custom). Каждый обработчик считывает управляемый атакующим массив atts из запроса, передает его через вспомогательную функцию с именем sanitize_deep_array(), которая — при вызове со своими аргументами по умолчанию — не выполняет никакой санитации вообще — а затем конкатенирует значения atts['include'] (и atts['exclude']) в сыром виде во фрагмент SQL post_id IN (...) который выполняется с помощью $wpdb->get_results() и без $wpdb->prepare().
Поскольку точки входа зарегистрированы на wp_ajax_nopriv_*, не требуется ни аутентификации, ни учетной записи, ни nonce, ни взаимодействия с пользователем. Неаутентифицированный удаленный атакующий может внедрить произвольный SQL в предложение WHERE запроса SELECT к wp_mec_dates и прочитать любые данные в базе данных WordPress (хэши паролей пользователей, секреты/ключи wp_options, данные других плагинов) с помощью слепых time-based / boolean / error-based техник.
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))) // строка 9615
{
$sanitized[$key] = $val; // <-- Прямая передача без санитации
continue;
}
// ... (sanitize_text_field / (int) / esc_url / ... достигается только когда $excludes не пуст)
}
return $sanitized;
}
Условие (is_array($excludes) and !count($excludes)) делает функцию полностью бездействующей, когда $excludes является пустым массивом по умолчанию — каждое значение копируется без изменений. Очевидно, намерением было «если есть список исключений, пропустить эти ключи»; по факту булева логика пропускает всё, если список исключений не предоставлен.
$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); // строка 501
sanitize_deep_array($_REQUEST['atts']) вызывается с одним аргументом → $excludes по умолчанию равен array() → срабатывает ветка бездействия выше → $atts — это сырой, недоверенный $_REQUEST['atts'].
IN (...)app/libraries/skins.php:
// строка 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']).")";
// строка 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']).")";
Элементы массива напрямую подставляются в SQL-строку через implode() без приведения к целому числу и без экранирования. (absint()/(int) для каждого элемента решили бы проблему.)
app/libraries/db.php:79:
public function select($query, $result = 'loadObjectList')
{
$query = $this->_prefix($query); // только заменяет `#__` на префикс таблиц
$database = $this->get_DBO();
if($result == 'loadObjectList') return $database->get_results($query, OBJECT_K); // строка 87 — нет prepare()
// ...
}
Полностью собранная строка передаётся в $wpdb->get_results() без изменений.
Поток заражения (запрос → сток):
$_REQUEST['atts'] (управляется атакующим, без аутентификации)
→ app/skins/list.php:501 sanitize_deep_array($atts) (БЕЗДЕЙСТВИЕ: пустой $excludes по умолчанию)
→ MEC_skin::initialize($atts) ($this->atts = сырые аттрибуты)
→ app/libraries/skins.php:606 "... post_id IN (".implode(',', $this->atts['include']).")"
→ app/libraries/db.php:87 $wpdb->get_results($query) (нет 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')); // <-- без аутентификации
Регистрация nopriv делает конечную точку доступной до аутентификации. Такая же структура load_more() и общий построитель запросов в skins.php присутствуют в родственных скинах, каждый со своим действием wp_ajax_nopriv_*, поэтому та же инъекция доступна через любой из:
AJAX-действие (nopriv) | Обработчик скина |
|---|---|
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 |
В load_more() nonce не проверяется, и для действия не требуется наличие шорткода плагина на странице — AJAX-обработчики регистрируются безусловно при инициализации.
Все запросы неаутентифицированные (без cookie, без nonce). Внедряемое значение помещается в atts[include][]; полезная нагрузка закрывает две открывающие скобки IN ((...) AND (... IN ( и добавляет OR <sleep> верхнего уровня, чтобы условие оценивалось для каждой сканируемой строки, затем комментирует завершающие )) ORDER BY ...:
TARGET='https://victim.example' # обычные permalink'и: используйте admin-ajax.php напрямую