{"repro_id":"REPRO-2026-00150","version":8,"title":"PhpSpreadsheet: SSRF via unsafe stream wrapper in IOFactory::load()","repro_type":"security","status":"published","severity":"critical","description":"PhpSpreadsheet's `IOFactory::load()` validates the supplied filename through\n`File::assertFile()`, which relies on PHP's `is_file()`. `is_file()` returns\n`true` for PHP **stream wrappers** — `phar://`, `ftp://`, `ssh2.sftp://` — so a\ndisallowed wrapper is not rejected before the library opens the path.\n\nWhen the filename is user-controlled this produces two impacts from one root\ncause:\n\n- **SSRF** — an `ftp://` path causes the library to open an *outbound\n  connection* to an attacker-chosen host.\n- **PHAR deserialization** — a `phar://` path triggers deserialization of the\n  PHAR archive's metadata, enabling PHP object injection.\n\nAny code path that forwards an attacker-influenced filename string into\n`IOFactory::load()` (or `IOFactory::identify()`) is exploitable.","root_cause":"# RCA Report: CVE-2026-34084 — PhpSpreadsheet SSRF via unsafe stream wrapper in IOFactory::load()\n\n## Summary\n\nPhpSpreadsheet's `IOFactory::load()` and `IOFactory::identify()` methods validate user-supplied filenames through `File::assertFile()`, which internally relies on PHP's `is_file()` function. PHP's `is_file()` returns `true` for various stream wrapper protocols such as `ftp://`, `phar://`, and `ssh2.sftp://`. Because the library did not explicitly reject stream wrappers, an attacker-controlled filename could trigger an outbound network connection (SSRF via `ftp://`) or PHAR metadata deserialization (object injection via `phar://`). The fix adds a `prohibitWrappers()` guard in `File::assertFile()` and `File::testFileNoThrow()` that rejects any path containing a recognized URL scheme before `is_file()` is evaluated.\n\n## Impact\n\n- **Package**: `phpoffice/phpspreadsheet` (Packagist)\n- **Repository**: https://github.com/PHPOffice/PhpSpreadsheet\n- **Affected versions**: `<= 1.30.2` (and corresponding ranges in 2.x, 3.x, 4.x, 5.x lines)\n- **Fixed versions**: `1.30.3`, `2.1.15`, `2.4.4`, `3.10.4`, `5.6.0`\n- **Severity**: Critical (CVSS 9.2)\n- **Consequences**:\n  - **SSRF**: Outbound connections to attacker-controlled hosts via `ftp://` paths.\n  - **PHAR Deserialization**: Object injection via `phar://` paths, potentially leading to remote code execution.\n\n## Root Cause\n\nIn `src/PhpSpreadsheet/Shared/File.php`, the `assertFile()` method was implemented as:\n\n```php\npublic static function assertFile(string $filename, string $zipMember = ''): void\n{\n    if (!is_file($filename)) {\n        throw new ReaderException('File \"' . $filename . '\" does not exist.');\n    }\n    // ...\n}\n```\n\nPHP's `is_file()` natively supports stream wrappers. When passed an `ftp://` path, `is_file()` initiates an FTP connection to the remote host. When passed a `phar://` path, `is_file()` triggers PHAR metadata deserialization. The library never validated the filename for stream-wrapper schemes before delegating to `is_file()`.\n\nThe fix (commit `b6c44a4c` / tag `1.30.3`) adds a `prohibitWrappers()` helper:\n\n```php\npublic static function prohibitWrappers(string $filename): void\n{\n    $scheme = parse_url($filename, PHP_URL_SCHEME);\n    if (is_string($scheme) && strlen($scheme) > 1) {\n        throw new Exception(\n            \"Stream wrappers are not permitted as file paths: {$filename}\"\n        );\n    }\n}\n```\n\nThis is called at the top of both `assertFile()` and `testFileNoThrow()`, ensuring that any path with a recognized scheme is rejected before any I/O (including `is_file()`) occurs.\n\n## Reproduction Steps\n\nThe reproduction script is `repro/reproduction_steps.sh`. It performs the following steps:\n\n1. Checks for `php`, `composer`, and `python3` availability.\n2. Finds an ephemeral TCP port on `127.0.0.1`.\n3. In a scratch directory, installs `phpoffice/phpspreadsheet:1.30.2` (vulnerable).\n4. Creates a PHP script that calls `IOFactory::load(\"ftp://127.0.0.1:<port>/x\")`.\n5. Starts a Python TCP listener on the chosen port.\n6. Runs the PHP script and records whether the listener receives a connection.\n7. Repeats steps 3–6 for `phpoffice/phpspreadsheet:1.30.3` (fixed).\n8. Compares results and writes `validation_verdict.json`.\n\n**Expected evidence**:\n- **Vulnerable (1.30.2)**: The Python listener logs `CONNECTION_RECEIVED` because `is_file(\"ftp://...\")` opens an outbound FTP connection before failing.\n- **Fixed (1.30.3)**: The Python listener logs `NO_CONNECTION` because `prohibitWrappers()` throws an exception before `is_file()` is reached.\n\n## Evidence\n\nLog files are written to `$ROOT/logs/` during script execution:\n\n- `logs/vuln_listener.log`: `CONNECTION_RECEIVED from ('127.0.0.1', <port>)`\n- `logs/vuln_php.log`: `EXCEPTION: File \"ftp://127.0.0.1:<port>/x\" does not exist.`\n- `logs/fixed_listener.log`: `NO_CONNECTION`\n- `logs/fixed_php.log`: `EXCEPTION: Stream wrappers are not permitted as file paths: ftp://127.0.0.1:<port>/x`\n\nValidation verdict is written to `validation_verdict.json`:\n\n```json\n{\n  \"verdict\": \"confirmed\",\n  \"vulnerable_version\": \"1.30.2\",\n  \"fixed_version\": \"1.30.3\",\n  \"vulnerable_indicator\": \"CONNECTION_RECEIVED from ('127.0.0.1', ...)\",\n  \"fixed_indicator\": \"NO_CONNECTION\",\n  \"vulnerability_type\": \"SSRF via unsafe stream wrapper in IOFactory::load()\",\n  \"details\": \"PhpSpreadsheet's File::assertFile() used is_file() which accepts PHP stream wrappers like ftp://. Fixed version adds prohibitWrappers() to reject stream wrappers before is_file() is called.\"\n}\n```\n\nEnvironment:\n- PHP 8.4.19\n- Composer 2.8.12\n- Python 3.x\n\n## Recommendations / Next Steps\n\n1. **Upgrade immediately** to `phpoffice/phpspreadsheet >= 1.30.3` (or the matching fixed version in your major line).\n2. **Input validation**: If wrapping `IOFactory::load()` in application code, add an independent allow-list or deny-list for filenames before passing them to the library.\n3. **Regression test**: Add a unit test that asserts `File::assertFile(\"ftp://...\")` and `File::assertFile(\"phar://...\")` throw exceptions.\n4. **Disable unnecessary wrappers**: In hardened deployments, consider disabling the `ftp` and `phar` PHP stream wrappers via `stream_wrapper_unregister()` if not required by the application.\n\n## Additional Notes\n\n- **Idempotency**: The reproduction script has been executed twice consecutively with identical results, confirming reproducibility.\n- **SSRF vector chosen**: The SSRF vector (`ftp://`) was selected over PHAR deserialization because it produces a clean, deterministic, network-free observable on localhost (a TCP connection attempt). No external network, database, or browser is required.\n- **Edge cases**: The fix uses `strlen($scheme) > 1` to avoid false positives on Windows absolute paths (e.g., `C:\\...`), since single-character schemes do not correspond to any known PHP stream wrapper.\n","ghsa_id":"GHSA-q4q6-r8wh-5cgh","cve_id":"CVE-2026-34084","source_url":"https://github.com/PHPOffice/PhpSpreadsheet","package":{"name":"phpoffice/phpspreadsheet","ecosystem":"composer","affected_versions":"<= 1.30.2 (also 2.0.0–2.1.14, 2.2.0–2.4.3, 3.3.0–3.10.3, 4.0.0–5.5.0)"},"reproduced_at":"2026-05-22T18:22:48.204881+00:00","duration_secs":773.0431678295135,"tool_calls":178,"turns":151,"handoffs":2,"total_cost_usd":1.6195087800000008,"agent_costs":{"repro":0.23686015000000005,"support":0.04462587,"vuln_variant":1.33802276},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.23686015000000005},"support":{"accounts/fireworks/models/kimi-k2p6":0.04462587},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.33802276}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T18:22:50.231973+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":5860,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":4822,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":5853,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":3428,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":3540,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":660,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3858,"category":"ticket"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":490,"category":"other"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":590,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":3675,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2803,"category":"other"},{"path":"vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":857,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":845,"category":"other"},{"path":"vuln_variant/source_identity.json","filename":"source_identity.json","size":680,"category":"other"},{"path":"logs/fixed_composer.log","filename":"fixed_composer.log","size":0,"category":"log"},{"path":"logs/fixed_php.log","filename":"fixed_php.log","size":84,"category":"log"},{"path":"logs/vuln_variant/vuln_bypass.log","filename":"vuln_bypass.log","size":1068,"category":"log"},{"path":"logs/vuln_variant/latest_original.log","filename":"latest_original.log","size":121,"category":"log"},{"path":"logs/vuln_variant/fixed_bypass.log","filename":"fixed_bypass.log","size":1073,"category":"log"},{"path":"logs/vuln_variant/fixed_original.log","filename":"fixed_original.log","size":121,"category":"log"},{"path":"logs/vuln_variant/latest_bypass.log","filename":"latest_bypass.log","size":1008,"category":"log"},{"path":"logs/vuln_composer.log","filename":"vuln_composer.log","size":0,"category":"log"},{"path":"logs/fixed_listener.log","filename":"fixed_listener.log","size":14,"category":"log"},{"path":"logs/vuln_listener.log","filename":"vuln_listener.log","size":46,"category":"log"},{"path":"logs/vuln_php.log","filename":"vuln_php.log","size":58,"category":"log"}]}