GHSA-2PHH-27FW-Q4GC
Vulnerability from github – Published: 2026-10-27 15:38 – Updated: 2026-10-27 15:38Summary
PhpSpreadsheet's Worksheet\Drawing::setPath() validates an external-image URL against an operator-supplied isWhitelisted callback once, on the initial URL only, then fetches it with file_get_contents() using a stream context that does not set follow_location => 0. A whitelisted host that returns an HTTP 302 Location: redirect to an internal target is therefore followed by PHP's HTTP wrapper (default follow_location=1, max_redirects=20) with no re-validation of the redirect hop — defeating the whitelist and yielding a blind SSRF from the server.
This is an unfixed variant of CVE-2026-59931 (GHSA-6hq5-7373-42rg, the WEBSERVICE redirect-whitelist-bypass): the follow_location => 0 fix (commit 7ef7b25e) was applied ONLY to Calculation/Web/Service.php; Worksheet/Drawing.php was never touched.
Root Cause
src/PhpSpreadsheet/Worksheet/Drawing.php:
- Scheme allowlist at :125 (/^(http|https|file|ftp|s3):/) permits http/https (the redirect-following schemes) and runs on the initial URL only.
- Opt-in gate if (!$allowExternal) at :128 (requires setAllowExternalImages(true); default false, BaseReader.php:55).
- Whitelist check if ($isWhitelisted !== null && !$isWhitelisted($path)) at :131 — validates the INITIAL URL only; never re-applied after a redirect.
- Stream context built at :140-152 sets user_agent + header (+ ssl.crypto_method for https) but omits follow_location. git grep follow_location on tag 5.9.0 yields a single hit — Service.php:45 (the CVE-2026-59931 fix) — confirming it was never added to Drawing.php.
- Sink file_get_contents($path, false, $ctx) at :154.
Impact
Blind SSRF from the server: reach and trigger internal GET-driven endpoints, coarse internal reachability/port probing, and reach cloud-metadata endpoints. The fetched body is written to a temp file, checked with isImage() (mime image/*, or application/octet-stream with bin/emf extension), only image dimensions are stored, and the temp file is unlinked — so the response body is not exfiltrated (non-image responses fail isImage()). This is a blind GET primitive, not full response read.
Proof of Concept
- Attacker server
https://trusted-whitelisted.example/redir.pngreturns302 Location: http://169.254.169.254/latest/meta-data/. evil.xlsxwithxl/drawings/_rels/drawing1.xml.rels:<Relationship ... Type=".../image" Target="https://trusted-whitelisted.example/redir.png" TargetMode="External"/>.- Victim:
$r = new Xlsx(); $r->setAllowExternalImages(true)->setIsWhitelisted(fn($p)=>str_contains($p,'trusted-whitelisted.example')); $r->load('evil.xlsx');-> the server-side GET is forced to the internal target despite the whitelist. HTML-reader variant:<img src="https://trusted-whitelisted.example/redir.png">loaded viaReader\Html(Html.php:1237) with the same reader settings.
Attack Chain
- Entry: crafted
evil.xlsx,xl/drawings/_rels/*.relsTargetMode="External",Target="https://trusted.example/redir.png"(whitelisted host). Guard: none (legal external-image relationship). Bypass proof: Target stored verbatim ->$urlatReader/Xlsx.php:1544. - Config: victim loads with
setAllowExternalImages(true)->setIsWhitelisted($cb)where$cballowstrusted.example. Guard:Drawing.php:128opt-in (satisfied) +:131whitelist. Bypass proof: initial URL is whitelisted -> returns true; check runs once on$path. - Fetch/redirect:
file_get_contents(:154) GETs the URL; whitelisted host returns302 Location: http://169.254.169.254/.... Guard: stream context (:140-152). Bypass proof: nofollow_locationkey -> PHP follows the redirect (defaultfollow_location=1,max_redirects=20) with no re-validation. - Impact: server issues a blind GET to the internal target (body temp-filed,
isImage()-checked, dimensions stored, temp file unlinked — body not exfiltrated).
Bypass Evidence
git grep follow_location 5.9.0 -- src/-> single hit atService.php:45;Drawing.phphas none.- CVE-2026-59931's own regression harness (
ServerTest.php+redirect.php: a whitelisted host 302-redirects todirect.php; only afterfollow_location => 0does the fetch stop following it) demonstrates the exact behavior. That fix is present inService.phpand absent inDrawing.php. git grep isWhitelisted 5.9.0 -- src/confirms the callback is applied only atDrawing.php:131(initial$path), never after the redirect.
Affected Versions
>= 5.5.0, <= 5.9.0. The isWhitelisted control being bypassed was introduced in 5.5.0 (commit 50a7552f); present and unfixed on the latest release (5.9.0) and on HEAD (05e99ebf). No post-release fix to Drawing.php.
Suggested Fix
Add 'follow_location' => 0 (and/or 'max_redirects' => 0) to $ctxArray['http'] in Drawing::setPath() (:140-148), mirroring the CVE-2026-59931 fix at Service.php:45; alternatively follow redirects manually and re-run $isWhitelisted on each hop.
Reported by zx (Jace) — GitHub: @manus-use
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 5.9.0"
},
"package": {
"ecosystem": "Packagist",
"name": "phpoffice/phpspreadsheet"
},
"ranges": [
{
"events": [
{
"introduced": "4.0.0"
},
{
"fixed": "5.10.0"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.10.7"
},
"package": {
"ecosystem": "Packagist",
"name": "phpoffice/phpspreadsheet"
},
"ranges": [
{
"events": [
{
"introduced": "3.3.0"
},
{
"fixed": "3.10.8"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.4.7"
},
"package": {
"ecosystem": "Packagist",
"name": "phpoffice/phpspreadsheet"
},
"ranges": [
{
"events": [
{
"introduced": "2.2.0"
},
{
"fixed": "2.4.8"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.1.18"
},
"package": {
"ecosystem": "Packagist",
"name": "phpoffice/phpspreadsheet"
},
"ranges": [
{
"events": [
{
"introduced": "2.0.0"
},
{
"fixed": "2.1.19"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.30.6"
},
"package": {
"ecosystem": "Packagist",
"name": "phpoffice/phpspreadsheet"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.30.7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-108360"
],
"database_specific": {
"cwe_ids": [
"CWE-863",
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-27T15:38:39Z",
"nvd_published_at": "2026-10-22T18:17:26Z",
"severity": "MODERATE"
},
"details": "## Summary\nPhpSpreadsheet\u0027s `Worksheet\\Drawing::setPath()` validates an external-image URL against an operator-supplied `isWhitelisted` callback **once**, on the initial URL only, then fetches it with `file_get_contents()` using a stream context that does **not** set `follow_location =\u003e 0`. A whitelisted host that returns an HTTP `302 Location:` redirect to an internal target is therefore followed by PHP\u0027s HTTP wrapper (default `follow_location=1`, `max_redirects=20`) with **no re-validation of the redirect hop** \u2014 defeating the whitelist and yielding a blind SSRF from the server.\n\nThis is an unfixed variant of CVE-2026-59931 (GHSA-6hq5-7373-42rg, the WEBSERVICE redirect-whitelist-bypass): the `follow_location =\u003e 0` fix (commit `7ef7b25e`) was applied ONLY to `Calculation/Web/Service.php`; `Worksheet/Drawing.php` was never touched.\n\n## Root Cause\n`src/PhpSpreadsheet/Worksheet/Drawing.php`:\n- Scheme allowlist at `:125` (`/^(http|https|file|ftp|s3):/`) permits `http`/`https` (the redirect-following schemes) and runs on the initial URL only.\n- Opt-in gate `if (!$allowExternal)` at `:128` (requires `setAllowExternalImages(true)`; default `false`, `BaseReader.php:55`).\n- Whitelist check `if ($isWhitelisted !== null \u0026\u0026 !$isWhitelisted($path))` at `:131` \u2014 validates the INITIAL URL only; never re-applied after a redirect.\n- Stream context built at `:140-152` sets `user_agent` + `header` (+ `ssl.crypto_method` for https) but **omits `follow_location`**. `git grep follow_location` on tag 5.9.0 yields a single hit \u2014 `Service.php:45` (the CVE-2026-59931 fix) \u2014 confirming it was never added to `Drawing.php`.\n- Sink `file_get_contents($path, false, $ctx)` at `:154`.\n\n## Impact\nBlind SSRF from the server: reach and trigger internal GET-driven endpoints, coarse internal reachability/port probing, and reach cloud-metadata endpoints. The fetched body is written to a temp file, checked with `isImage()` (mime `image/*`, or `application/octet-stream` with `bin`/`emf` extension), only image dimensions are stored, and the temp file is `unlink`ed \u2014 so the response body is **not** exfiltrated (non-image responses fail `isImage()`). This is a blind GET primitive, not full response read.\n\n## Proof of Concept\n1. Attacker server `https://trusted-whitelisted.example/redir.png` returns `302 Location: http://169.254.169.254/latest/meta-data/`.\n2. `evil.xlsx` with `xl/drawings/_rels/drawing1.xml.rels`: `\u003cRelationship ... Type=\".../image\" Target=\"https://trusted-whitelisted.example/redir.png\" TargetMode=\"External\"/\u003e`.\n3. Victim: `$r = new Xlsx(); $r-\u003esetAllowExternalImages(true)-\u003esetIsWhitelisted(fn($p)=\u003estr_contains($p,\u0027trusted-whitelisted.example\u0027)); $r-\u003eload(\u0027evil.xlsx\u0027);` -\u003e the server-side GET is forced to the internal target despite the whitelist.\nHTML-reader variant: `\u003cimg src=\"https://trusted-whitelisted.example/redir.png\"\u003e` loaded via `Reader\\Html` (`Html.php:1237`) with the same reader settings.\n\n## Attack Chain\n1. Entry: crafted `evil.xlsx`, `xl/drawings/_rels/*.rels` `TargetMode=\"External\"`, `Target=\"https://trusted.example/redir.png\"` (whitelisted host). Guard: none (legal external-image relationship). Bypass proof: Target stored verbatim -\u003e `$url` at `Reader/Xlsx.php:1544`.\n2. Config: victim loads with `setAllowExternalImages(true)-\u003esetIsWhitelisted($cb)` where `$cb` allows `trusted.example`. Guard: `Drawing.php:128` opt-in (satisfied) + `:131` whitelist. Bypass proof: initial URL is whitelisted -\u003e returns true; check runs once on `$path`.\n3. Fetch/redirect: `file_get_contents` (`:154`) GETs the URL; whitelisted host returns `302 Location: http://169.254.169.254/...`. Guard: stream context (`:140-152`). Bypass proof: no `follow_location` key -\u003e PHP follows the redirect (default `follow_location=1`, `max_redirects=20`) with no re-validation.\n4. Impact: server issues a blind GET to the internal target (body temp-filed, `isImage()`-checked, dimensions stored, temp file unlinked \u2014 body not exfiltrated).\n\n## Bypass Evidence\n- `git grep follow_location 5.9.0 -- src/` -\u003e single hit at `Service.php:45`; `Drawing.php` has none.\n- CVE-2026-59931\u0027s own regression harness (`ServerTest.php` + `redirect.php`: a whitelisted host 302-redirects to `direct.php`; only after `follow_location =\u003e 0` does the fetch stop following it) demonstrates the exact behavior. That fix is present in `Service.php` and absent in `Drawing.php`.\n- `git grep isWhitelisted 5.9.0 -- src/` confirms the callback is applied only at `Drawing.php:131` (initial `$path`), never after the redirect.\n\n## Affected Versions\n`\u003e= 5.5.0, \u003c= 5.9.0`. The `isWhitelisted` control being bypassed was introduced in 5.5.0 (commit `50a7552f`); present and unfixed on the latest release (5.9.0) and on HEAD (`05e99ebf`). No post-release fix to `Drawing.php`.\n\n## Suggested Fix\nAdd `\u0027follow_location\u0027 =\u003e 0` (and/or `\u0027max_redirects\u0027 =\u003e 0`) to `$ctxArray[\u0027http\u0027]` in `Drawing::setPath()` (`:140-148`), mirroring the CVE-2026-59931 fix at `Service.php:45`; alternatively follow redirects manually and re-run `$isWhitelisted` on each hop.\n\n---\nReported by **zx (Jace)** \u2014 GitHub: @manus-use",
"id": "GHSA-2phh-27fw-q4gc",
"modified": "2026-10-27T15:38:39Z",
"published": "2026-10-27T15:38:39Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-2phh-27fw-q4gc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-108360"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/commit/299993894dce37aa3b64aa23e9a3f4bb77ab0e66"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/commit/94590951d76a250a5fc1ecf69a0b3f7439a9af49"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/commit/980b5c294493856f1a25754f1e8a1d2185b93738"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/commit/9a0b48622ebfe93fa48cb9bc09ce57303bb07af9"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/commit/da13f40e8760d599b0e8a296017d4b028b20e86c"
},
{
"type": "PACKAGE",
"url": "https://github.com/PHPOffice/PhpSpreadsheet"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/1.30.7"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/2.1.19"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/2.4.8"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/3.10.8"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/5.10.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "PhpSpreadsheet: SSRF whitelist bypass via unre-validated HTTP redirect in Drawing::setPath external image loading"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.