CVSS 6.4 MEDIUM Truy cập trái phép Lộ dữ liệu

PhpSpreadsheet: SSRF whitelist bypass via unre-validated HTTP redirect in Drawing::setPath external image loading

Mã khác
GHSA-2phh-27fw-q4gc
Công bố
Cập nhật
Lần đầu thấy
Nguồn
GitHub Advisory, OSV.dev

Mô tả

Summary

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
  1. Attacker server https://trusted-whitelisted.example/redir.png returns 302 Location: http://169.254.169.254/latest/meta-data/.
  2. evil.xlsx with xl/drawings/_rels/drawing1.xml.rels: <Relationship ... Type=".../image" Target="https://trusted-whitelisted.example/redir.png" TargetMode="External"/>.
  3. 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 via Reader\Html (Html.php:1237) with the same reader settings.
Attack Chain
  1. 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 -> $url at Reader/Xlsx.php:1544.
  2. Config: victim loads with setAllowExternalImages(true)->setIsWhitelisted($cb) where $cb allows trusted.example. Guard: Drawing.php:128 opt-in (satisfied) + :131 whitelist. Bypass proof: initial URL is whitelisted -> returns true; check runs once on $path.
  3. 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 -> PHP follows the redirect (default follow_location=1, max_redirects=20) with no re-validation.
  4. 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 at Service.php:45; Drawing.php has none.
  • CVE-2026-59931's own regression harness (ServerTest.php + redirect.php: a whitelisted host 302-redirects to direct.php; only after follow_location => 0 does the fetch stop following it) demonstrates the exact behavior. That fix is present in Service.php and absent in Drawing.php.
  • git grep isWhitelisted 5.9.0 -- src/ confirms the callback is applied only at Drawing.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

Điểm và phân loại

CVSS
6.4 MEDIUM v3.1
Nguồn điểm
GitHub Advisory
Mức độ (theo nguồn)
MEDIUM
EPSS
0.34%

Vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N

Ảnh hưởng

Package Bản bị ảnh hưởng Bản đã sửa
composer phpoffice/phpspreadsheet >= 4.0.0, <= 5.9.0 5.10.0
composer phpoffice/phpspreadsheet >= 3.3.0, <= 3.10.7 3.10.8
composer phpoffice/phpspreadsheet >= 2.2.0, <= 2.4.7 2.4.8
composer phpoffice/phpspreadsheet >= 2.0.0, <= 2.1.18 2.1.19
composer phpoffice/phpspreadsheet <= 1.30.6 1.30.7
composer phpoffice/phpspreadsheet >= 4.0.0, < 5.10.0 5.10.0
composer phpoffice/phpspreadsheet >= 3.3.0, < 3.10.8 3.10.8
composer phpoffice/phpspreadsheet >= 2.2.0, < 2.4.8 2.4.8
composer phpoffice/phpspreadsheet >= 2.0.0, < 2.1.19 2.1.19
composer phpoffice/phpspreadsheet < 1.30.7 1.30.7

Vì sao bị lọc

  • CVSS 6.4 < 8.0 và không thuộc CISA KEV

Loại tác động

Truy cập trái phép
CWE-863
Lộ dữ liệu
CWE-918; từ khoá "ssrf"

Dữ liệu riêng của nguồn

OSV.dev · ID
GHSA-2phh-27fw-q4gc

Tham khảo