Skip to content
Verified reproduction

CVE-2026-34084: PhpSpreadsheet: SSRF via unsafe stream wrapper in IOFactory::load()

CVE-2026-34084 is verified against phpoffice/phpspreadsheet · 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) vulnerability class: SSRF This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00150.

REPRO-2026-00150 phpoffice/phpspreadsheet · composer SSRF May 22, 2026 CVE entry .txt
Severity CRITICAL
Confidence HIGH
Reproduced in 12m 53s
Tool calls 178
Spend $1.62
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)
$ pruva-verify REPRO-2026-00150
or curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00150/artifacts/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
02 · 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 wrappersphar://, 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.

03 · 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:<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.
04 · Reproduction transcript

The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired. Phases: support triages the advisory · repro reproduces it · vuln_variant confirms the fix blocks it · judge verifies.

Loading session...

05 · Artifacts

Scripts, logs, diffs, and output captured during the reproduction.

No artifacts available