# REPRO-2026-00150: PhpSpreadsheet: SSRF via unsafe stream wrapper in IOFactory::load() ## Summary Status: published Severity: critical Type: security Confidence: Unknown ## Identifiers REPRO ID: REPRO-2026-00150 GHSA: GHSA-q4q6-r8wh-5cgh CVE: CVE-2026-34084 ## Package Name: phpoffice/phpspreadsheet Ecosystem: composer Affected: <= 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) Fixed: Unknown ## Root Cause # RCA Report: CVE-2026-34084 — PhpSpreadsheet SSRF via unsafe stream wrapper in IOFactory::load() ## Summary PhpSpreadsheet'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. ## Impact - **Package**: `phpoffice/phpspreadsheet` (Packagist) - **Repository**: https://github.com/PHPOffice/PhpSpreadsheet - **Affected versions**: `<= 1.30.2` (and corresponding ranges in 2.x, 3.x, 4.x, 5.x lines) - **Fixed versions**: `1.30.3`, `2.1.15`, `2.4.4`, `3.10.4`, `5.6.0` - **Severity**: Critical (CVSS 9.2) - **Consequences**: - **SSRF**: Outbound connections to attacker-controlled hosts via `ftp://` paths. - **PHAR Deserialization**: Object injection via `phar://` paths, potentially leading to remote code execution. ## Root Cause In `src/PhpSpreadsheet/Shared/File.php`, the `assertFile()` method was implemented as: ```php public static function assertFile(string $filename, string $zipMember = ''): void { if (!is_file($filename)) { throw new ReaderException('File "' . $filename . '" does not exist.'); } // ... } ``` PHP'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()`. The fix (commit `b6c44a4c` / tag `1.30.3`) adds a `prohibitWrappers()` helper: ```php public static function prohibitWrappers(string $filename): void { $scheme = parse_url($filename, PHP_URL_SCHEME); if (is_string($scheme) && strlen($scheme) > 1) { throw new Exception( "Stream wrappers are not permitted as file paths: {$filename}" ); } } ``` This 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. ## Reproduction Steps The reproduction script is `repro/reproduction_steps.sh`. It performs the following steps: 1. Checks for `php`, `composer`, and `python3` availability. 2. Finds an ephemeral TCP port on `127.0.0.1`. 3. In a scratch directory, installs `phpoffice/phpspreadsheet:1.30.2` (vulnerable). 4. Creates a PHP script that calls `IOFactory::load("ftp://127.0.0.1:/x")`. 5. Starts a Python TCP listener on the chosen port. 6. Runs the PHP script and records whether the listener receives a connection. 7. Repeats steps 3–6 for `phpoffice/phpspreadsheet:1.30.3` (fixed). 8. Compares results and writes `validation_verdict.json`. **Expected evidence**: - **Vulnerable (1.30.2)**: The Python listener logs `CONNECTION_RECEIVED` because `is_file("ftp://...")` opens an outbound FTP connection before failing. - **Fixed (1.30.3)**: The Python listener logs `NO_CONNECTION` because `prohibitWrappers()` throws an exception before `is_file()` is reached. ## Evidence Log files are written to `$ROOT/logs/` during script execution: - `logs/vuln_listener.log`: `CONNECTION_RECEIVED from ('127.0.0.1', )` - `logs/vuln_php.log`: `EXCEPTION: File "ftp://127.0.0.1:/x" does not exist.` - `logs/fixed_listener.log`: `NO_CONNECTION` - `logs/fixed_php.log`: `EXCEPTION: Stream wrappers are not permitted as file paths: ftp://127.0.0.1:/x` Validation verdict is written to `validation_verdict.json`: ```json { "verdict": "confirmed", "vulnerable_version": "1.30.2", "fixed_version": "1.30.3", "vulnerable_indicator": "CONNECTION_RECEIVED from ('127.0.0.1', ...)", "fixed_indicator": "NO_CONNECTION", "vulnerability_type": "SSRF via unsafe stream wrapper in IOFactory::load()", "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." } ``` Environment: - PHP 8.4.19 - Composer 2.8.12 - Python 3.x ## Recommendations / Next Steps 1. **Upgrade immediately** to `phpoffice/phpspreadsheet >= 1.30.3` (or the matching fixed version in your major line). 2. **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. 3. **Regression test**: Add a unit test that asserts `File::assertFile("ftp://...")` and `File::assertFile("phar://...")` throw exceptions. 4. **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. ## Additional Notes - **Idempotency**: The reproduction script has been executed twice consecutively with identical results, confirming reproducibility. - **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. - **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. ## Reproduction Details Reproduced: 2026-05-22T18:22:48.204Z Duration: 773 seconds Tool calls: 178 Turns: 151 Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00150 pruva-verify GHSA-q4q6-r8wh-5cgh pruva-verify CVE-2026-34084 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00150&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00150/artifacts/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - GitHub Advisory: https://github.com/advisories/GHSA-q4q6-r8wh-5cgh - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-34084 - Source: https://github.com/PHPOffice/PhpSpreadsheet ## Artifacts - repro/rca_report.md (analysis, 5860 bytes) - repro/reproduction_steps.sh (reproduction_script, 4822 bytes) - vuln_variant/rca_report.md (analysis, 5853 bytes) - vuln_variant/reproduction_steps.sh (reproduction_script, 3428 bytes) - bundle/context.json (other, 3540 bytes) - bundle/metadata.json (other, 660 bytes) - bundle/ticket.md (ticket, 3858 bytes) - repro/validation_verdict.json (other, 490 bytes) - vuln_variant/root_cause_equivalence.json (other, 590 bytes) - vuln_variant/patch_analysis.md (documentation, 3675 bytes) - vuln_variant/variant_manifest.json (other, 2803 bytes) - vuln_variant/runtime_manifest.json (other, 857 bytes) - vuln_variant/validation_verdict.json (other, 845 bytes) - vuln_variant/source_identity.json (other, 680 bytes) - logs/fixed_composer.log (log, 0 bytes) - logs/fixed_php.log (log, 84 bytes) - logs/vuln_variant/vuln_bypass.log (log, 1068 bytes) - logs/vuln_variant/latest_original.log (log, 121 bytes) - logs/vuln_variant/fixed_bypass.log (log, 1073 bytes) - logs/vuln_variant/fixed_original.log (log, 121 bytes) - logs/vuln_variant/latest_bypass.log (log, 1008 bytes) - logs/vuln_composer.log (log, 0 bytes) - logs/fixed_listener.log (log, 14 bytes) - logs/vuln_listener.log (log, 46 bytes) - logs/vuln_php.log (log, 58 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00150 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00150/artifacts/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00150 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev