Human
Machine
REPRO-2026-00150 CRITICAL SSRF
Verified
PhpSpreadsheet: SSRF via unsafe stream wrapper in IOFactory::load()
phpoffice/phpspreadsheet (composer) May 22, 2026
What's the vulnerability?
PhpSpreadsheet's IOFactory::load() validates the supplied filename through
File::assertFile(), which relies on PHP's is_file(). is_file() returns
true for PHP stream wrappers — phar://, ftp://, ssh2.sftp:// — so a
disallowed wrapper is not rejected before the library opens the path.
When the filename is user-controlled this produces two impacts from one root cause:
- SSRF — an
ftp://path causes the library to open an outbound connection to an attacker-chosen host. - PHAR deserialization — a
phar://path triggers deserialization of the PHAR archive's metadata, enabling PHP object injection.
Any code path that forwards an attacker-influenced filename string into
IOFactory::load() (or IOFactory::identify()) is exploitable.
Root Cause Analysis
# 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:<port>/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', <port>)`
- `logs/vuln_php.log`: `EXCEPTION: File "ftp://127.0.0.1:<port>/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:<port>/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.
One Command
Verify with pruva-verify
Run the Pruva CLI to automatically fetch and execute the reproduction script.
pruva-verify REPRO-2026-00150 or
pruva-verify GHSA-q4q6-r8wh-5cgh or
pruva-verify CVE-2026-34084 Install:
curl -fsSL https://pruva.dev/install.sh | sh Or Run Manually
1
Download the script
curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00150/artifacts/reproduction_steps.sh 2
Make executable
chmod +x reproduction_steps.sh 3
Run the script
./reproduction_steps.sh Run in a VM, container, or disposable environment. This exploits a real vulnerability.
How Pruva Reproduced This
Watch the AI agent's step-by-step process.
Loading session...
Artifacts
No artifacts available