← all articles

criticalSecurity Advisory

[CVE-2026-52848] MFA Bypass via REQUEST_URI Substring Match in GLPI

isAPI() matches api.php anywhere in REQUEST_URI, so appending ?x=api.php to the login endpoint skips the MFA gate. A valid password alone yields a fully authenticated session.

CWE-287 · Improper Authentication · CVSS 9.2 / Critical · GLPI 11.0.0 to 11.0.7 (fixed in 11.0.8) · CVE-2026-52848

  • : Reported to GLPI
  • : Fixed in GLPI 11.0.8, flagged [SECURITY - CRITICAL] MFA bypass
  • : Advisory GHSA-hgr8-qvp5-mhch published; CVE-2026-52848 assigned

GLPI is an open-source IT-management platform, very popular in France. It does two big jobs at once: it keeps a live inventory of everything technical a company owns (every laptop, server, phone, software licence and contract, and how they all connect), and it runs the helpdesk where staff open support tickets and the IT team works through them. All self-hosted, written in PHP on a MySQL or MariaDB database.

Organisations that use GLPI From GLPI's own reference wall: Airbus, Deezer, La Poste, Météo France, Burger King, Transavia, Telefónica, even France's Conseil d'État and its Ministry of Agriculture.

It's French, built by Teclib', and the state recommends it outright: GLPI sits in the SILL, the catalogue of free software the French state officially recommends to its public administrations. So it runs the IT behind countless French universities, hospitals, town halls and ministries: 15,000+ organisations and 11 million users worldwide by Teclib's count, but overwhelmingly concentrated in France.

And being a big, long-lived PHP web app cuts both ways: a huge attack surface (hundreds of endpoints, years of accumulated features) and a matching CVE history of SQL injection, cross-site scripting and authentication bugs.

GLPI had just fixed its first MFA bypass, CVE-2026-25937, where the enrolment flow could be re-run to overwrite an account's existing second factor, silently resetting it. That patch blocked it: a new is2FAEnabled() check makes enrolment refuse to run when MFA is already configured. But it only stopped that one trick. It never touched the code that decides whether GLPI asks for a second factor at all. That is the sharper question: not whether the secret can be reset, but whether the check can be skipped entirely. So I went to read that gate.

The gate

Whether GLPI prompts for a second factor comes down to one boolean in src/Auth.php:

// src/Auth.php
$check_mfa = $this->auth_succeded
    && !isAPI()              // skipped when REQUEST_URI contains 'api.php'
    && !isCommandLine()
    && $this->auth_type !== self::COOKIE;

Read that !isAPI() and the intent is reasonable: API clients authenticate programmatically, they can't sit through an interactive TOTP prompt, so don't force one on them. MFA is opt-out for API requests. Which means the whole security of the second factor rests on one question: how sure is GLPI that a request really is an API request? So I opened isAPI().

// src/autoload/misc-functions.php
function isAPI(): bool {
    return str_contains($_SERVER['REQUEST_URI'], 'api.php')
        || str_contains($_SERVER['REQUEST_URI'], 'apirest.php');
}

There it was. isAPI() doesn't check the route, the script name, or a flag set by the API listener. It does a substring search for api.php in REQUEST_URI, and REQUEST_URI is the raw request line: the path and the query string. Nothing stops me from putting api.php in the query string of an otherwise ordinary login.

The exploit is a query parameter

That's the entire bug. Take the normal interactive web login and bolt a harmless-looking query string onto it:

POST /front/login.php?x=api.php HTTP/1.1

login_name=<user>&login_password=<password>&auth=local&_glpi_csrf_token=<warmed>

?x=api.php makes str_contains(REQUEST_URI, 'api.php') return true, isAPI() flips to true, the check_mfa gate short-circuits, and GLPI treats my very-much-interactive browser login as an API call that needs no second factor. Side by side:

BASELINE  POST /front/login.php           -> 302 /MFA/Prompt         (MFA required)
EXPLOIT   POST /front/login.php?x=api.php  -> 302 /front/central.php   (MFA skipped)
          GET  /front/central.php          -> 200 "Standard interface - GLPI"

The session cookie that comes back is fully authenticated. No TOTP, no WebAuthn, no backup code. The demo I like best needs nothing but the browser: open the login page, pop the console, rewrite the form's action in place, and log in normally.

document.querySelector('form').action = '/front/login.php?x=api.php'

You watch the MFA prompt you were expecting simply never show up.

Same root cause, three doors

Once you see that MFA enforcement is opt-out keyed on a loose string match, the flaw reappears anywhere isAPI() mis-classifies:

  • Web login (POST /front/login.php?x=api.php): the one above. Always reachable, needs no API feature enabled.
  • OAuth password grant (POST /api.php/v2/token, grant_type=password): UserRepository::getUserEntityByUserCredentials only ever calls Auth::validateLogin(...), never Auth::login(), so the MFA branch isn't even on the code path. Returns a full-scope access token.
  • Legacy REST (GET /apirest.php/initSession, Basic auth): here Auth::login() does run and reach the MFA branch, but isAPI() is already true because the path genuinely starts with apirest.php. Same skip, returns a session token.

Three doors, one broken lock: MFA is opt-out (!isAPI()) instead of opt-in, so every path where isAPI() guesses wrong is a bypass.

Fix

GLPI fixed it in 11.0.8. The durable shape is to match the API entry point by what it is, not by what the URL happens to contain: compare the resolved script name against api.php / apirest.php and ignore the query string entirely:

function isAPI(): bool {
    $script = basename($_SERVER['SCRIPT_NAME'] ?? '');
    return $script === 'api.php' || $script === 'apirest.php';
}

Better still, don't infer it from the URL at all: have the API request listener set an explicit session flag or route name and read that. As defence-in-depth, Auth::login() should enforce MFA whenever a user has it configured, and let API entry points handle the challenge as part of their contract, so one string match is never the only thing standing between a password and a session.

What it taught me

CVE-2026-25937 was real, and its fix was fine for the instance it fixed. But it patched a symptom (re-enrolment overwrite) and left the organ untouched (the gate that decides whether MFA runs at all). The habit that found this one is unglamorous and I recommend it to anyone: when a project patches an auth bug, don't stop at the advisory, read the diff, then go read the code the diff didn't touch. The second bug is often right next to the first.