This is less a front-door break-in than a valet key left in the wrong PHP app flow
CVE-2026-34084 is a stream-wrapper injection bug in PhpOffice\PhpSpreadsheet\IOFactory::load() and related reader paths. Affected versions are <=1.30.2, 2.0.0-2.1.14, 2.2.0-2.4.3, 3.3.0-3.10.3, and 4.0.0-5.5.0; fixed versions are 1.30.3, 2.1.15, 2.4.4, 3.10.4, and 5.6.0 per the GitHub advisory and NVD. If an application passes a user-controlled filename string into IOFactory::load(), an attacker can feed wrapper paths like phar://, ftp://, or ssh2.sftp://; that can yield SSRF, and phar:// can trigger PHAR metadata deserialization that *may* become RCE if the surrounding app provides a usable gadget chain.
The 9.8/CRITICAL label overstates what defenders will face in real enterprise fleets. The bug lives in a library, not an Internet-facing service, and exploitation depends on a very specific integration mistake: the app must accept attacker-controlled path strings and hand them straight to PhpSpreadsheet. On top of that, the advisory explicitly says PhpSpreadsheet alone does not provide an obvious standalone gadget chain for RCE, which turns the scary outcome into a conditional one. Real risk is still meaningful because the package is massively deployed and SSRF is easier than RCE, but this is a conditional app-usage flaw, not a universal one-shot edge RCE.
5 steps from start to impact.
Find an app flow that accepts a filename or path string
IOFactory::load(). Typical weaponization starts with a normal HTTP client such as curl against a custom import or reporting workflow, not against PhpSpreadsheet directly. The vulnerable surface is the *application's integration pattern*, not the package in isolation.- The target uses
phpoffice/phpspreadsheetin a reachable code path - A remote user can influence the filename/path argument rather than only upload a local file blob
- The application does not normalize or reject PHP stream wrapper schemes
- Many apps accept uploaded files and save them locally, never exposing raw path control to the user
- Secure wrappers around imports often call
realpath(), enforce temp directories, or map uploaded IDs to server-side paths - This is hard to find with perimeter scanning because the vulnerable behavior is business-logic specific
IOFactory::load(); SCA only tells you the dependency version, not whether the dangerous call pattern exists.Abuse PHP stream wrappers to bypass the file check
File::assertFile() relies on is_file(), which is wrapper-aware for schemes implementing stat(). Using tools as simple as curl or a browser request, the attacker supplies ftp://, ssh2.sftp://, or phar:// instead of a normal filesystem path. That gets the input past validation intended for local files.- The chosen wrapper is enabled and supported in the PHP runtime
- The application reaches the reader path shown in the advisory
- No allowlist blocks non-local schemes before
is_file()executes
ssh2.sftp://requires the SSH2 extension and environment support- Some environments block outbound connections entirely, which kills the SSRF branch
- Input validation, WAF normalization, or simple scheme checks break this step cleanly
phar:// or ftp:// in import parameters. Runtime detections should focus on suspicious wrapper strings and unexpected outbound connection attempts during import workflows.Take the easier win: SSRF with ftp:// or ssh2.sftp://
ftp://127.0.0.1:21/test is enough to make the server initiate a connection, demonstrated with ncat. In real deployments this can pivot into metadata services, internal admin panels, or other RFC1918 targets if egress and routing permit it. This branch is materially easier than full RCE because it does not require a gadget chain.- Outbound network access exists from the PHP worker
- Internal targets of value are reachable from that host
- The app path is reachable by the attacker
- Egress filtering, proxy requirements, and metadata protections often limit SSRF blast radius
- Modern cloud and enterprise environments increasingly alert on strange outbound traffic from web tiers
- Many import workers sit in segmented subnets with limited routing
Escalate to PHAR deserialization
phar:// path so PHP deserializes PHAR metadata during file handling. The GHSA PoC uses php to build a malicious PHAR and demonstrates code execution when a suitable gadget object is available. This is the branch driving the scary CVSS score, but it is also the branch with the most deployment friction.- The attacker can make the app reference a PHAR path
- A usable POP gadget chain exists somewhere in the full application dependency graph
- The app path is reachable and not already rejecting wrapper schemes
- The advisory explicitly notes PhpSpreadsheet alone does not provide an obvious standalone gadget chain
- Real RCE often depends on unrelated app/framework classes being present and reachable
- PHAR exploitation is brittle across app stacks and deployment packaging choices
phar:// in request parameters, audit logs, and PHP exception traces. SAST/Semgrep-style rules for user-controlled input flowing into IOFactory::load() are high-value here.Land impact inside the hosting app
- The web worker or queue worker has meaningful filesystem, secret, or network access
- The application stack supplies the final primitive needed for the chosen branch
- Least-privilege workers, containers, and short-lived credentials can sharply reduce post-exploit value
- Even successful SSRF may dead-end against segmentation and identity controls
The supporting signals.
| In-the-wild status | No confirmed active exploitation found in the sources reviewed as of 2026-05-30. The CVE is not in CISA KEV. |
|---|---|
| Proof-of-concept availability | Public PoC exists in the GitHub advisory from reporter calligraf0, including both an SSRF demo with ncat and a PHAR-based deserialization/RCE demo. |
| EPSS | 0.00226 from the user intel block. That is a low exploitation-likelihood signal; percentile was not independently confirmed in the sources reviewed. |
| KEV status | Not listed in the CISA Known Exploited Vulnerabilities Catalog as of 2026-05-30. |
| CVSS vector reality check | Vendor/NVD-style scoring uses AV:N/AC:L/PR:N/UI:N/..., but that assumes the vulnerable function is directly reachable. In practice the decisive prerequisite is attacker control of the filename/path passed to IOFactory::load(). |
| Affected versions | <=1.30.2, 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 versions | 1.30.3, 2.1.15, 2.4.4, 3.10.4, 5.6.0. Packagist currently shows newer releases such as 5.7.0, so patched upgrade targets exist. |
| Exposure population | The package is widely deployed on Packagist with roughly 307M installs and 1,417 dependents, which is a strong amplifier. But exposure is not directly Internet-enumerable with Shodan/Censys because this is a PHP dependency, not a fingerprintable network appliance. |
| Disclosure timeline | GitHub advisory published 2026-04-28 / 2026-04-29 UTC depending on source rendering; NVD published the CVE on 2026-05-05. |
| Reporter | The GHSA credits calligraf0 as the reporter. |
noisgate verdict.
The single biggest downward pressure is that exploitation requires an application-specific unsafe integration: user-controlled path input must be passed into IOFactory::load(). That sharply narrows reachable population compared with a true edge-service pre-auth RCE, even though the technical consequence can be severe in the worst case.
Why this verdict
- Downgrade for attacker position: this is not exploit-against-the-library-over-the-network; the attacker needs a reachable app flow that passes user-controlled filenames into
IOFactory::load(). - Downgrade for outcome friction: the advisory itself says PhpSpreadsheet does not appear to provide an obvious standalone gadget chain, so the RCE branch depends on the surrounding app's classes and dependencies.
- Upgrade from LOW because blast radius can still matter: the SSRF branch is easier than RCE, and the package is extremely common in PHP estates, so unsafe integrations are not hypothetical.
- Downgrade for exposure population: you cannot assume 100% of installations are remotely reachable in the dangerous pattern; many deployments only read local temp files produced by upload handlers or internal jobs.
- Downgrade for exploitation evidence: no KEV listing, no confirmed active campaign evidence in reviewed sources, and the supplied EPSS is very low.
Why not higher?
A higher rating would fit a flaw that is directly reachable on exposed services with no app-specific misuse required. Here, exploitation hinges on a custom integration error and, for RCE, usually a second dependency on a usable gadget chain. That is too much real-world friction for CRITICAL.
Why not lower?
This should not be waved away as harmless library noise. If you do have a file-import or report-generation path that accepts attacker-controlled filenames, the SSRF branch is straightforward and the PHAR branch can become serious in dependency-rich PHP apps. The package's install base is large enough that some enterprise apps will absolutely have the risky pattern.
What to do — in priority order.
- Block wrapper schemes at the application boundary — Reject any filename/path beginning with multi-character URI schemes such as
phar://,ftp://, orssh2.sftp://before it reaches PhpSpreadsheet. For a MEDIUM verdict there is no mitigation SLA, but if you cannot patch quickly this is the best temporary control to add during the remediation window. - Force imports through server-side temp files — Accept uploaded content, store it in an application-owned temp directory, and pass only the generated local path to
IOFactory::load(). This removes raw user control over the path string and is the safest integration pattern while you work toward patched package versions. - Constrain outbound egress from PHP workers — Deny FTP/SFTP and unnecessary internal destinations from web and queue workers so the SSRF branch cannot pivot into the network. Even though there is no mitigation SLA at MEDIUM, this control reduces impact immediately and helps other SSRF classes too.
- Hunt for dangerous call sites — Run SAST or grep for
IOFactory::load(and trace whether request parameters, queue payloads, or form fields can reach it unsanitized. This is how you separate mere package presence from actual exposure across a 10,000-host fleet. - Upgrade the dependency — Move to
1.30.3,2.1.15,2.4.4,3.10.4,5.6.0, or newer depending on branch support. For a MEDIUM verdict, there is no mitigation SLA — go straight to the 365-day remediation window unless your code review proves a reachable unsafe path, in which case treat that app as higher priority internally.
stream_is_local()is not a safe filter here; the advisory specifically notes it still treatsphar://as local.- Plain perimeter vulnerability scanning will not tell you whether your app passes user input into
IOFactory::load(); this is an integration flaw, not a banner-grabbable service bug. - Relying on the absence of PhpSpreadsheet gadget chains is not enough; PHAR deserialization can still become exploitable via other classes in the application dependency graph.
Crowdsourced verification payload.
Run this on the application host, build workspace, or CI runner against a PHP app root or directly against its composer.lock: python3 check_cve_2026_34084.py /var/www/myapp or python3 check_cve_2026_34084.py /var/www/myapp/composer.lock. It needs read access only; no admin privileges are required.
#!/usr/bin/env python3
# check_cve_2026_34084.py
# Exit codes:
# 0 = PATCHED / not present
# 1 = VULNERABLE
# 2 = UNKNOWN / could not determine
import json
import os
import re
import sys
from typing import Any, Dict, List, Optional, Tuple
PKG = 'phpoffice/phpspreadsheet'
def usage() -> None:
print('Usage: python3 check_cve_2026_34084.py <app_root|composer.lock|installed.json>')
def read_json(path: str) -> Optional[Any]:
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return None
def find_manifest(target: str) -> Tuple[Optional[str], Optional[str]]:
target = os.path.abspath(target)
candidates: List[Tuple[str, str]] = []
if os.path.isdir(target):
candidates.extend([
('composer.lock', os.path.join(target, 'composer.lock')),
('installed.json', os.path.join(target, 'vendor', 'composer', 'installed.json')),
])
else:
base = os.path.basename(target)
if base == 'composer.lock':
candidates.append(('composer.lock', target))
elif base == 'installed.json':
candidates.append(('installed.json', target))
for kind, path in candidates:
if os.path.isfile(path):
return kind, path
return None, None
def normalize_version(v: str) -> Optional[str]:
if not v:
return None
v = v.strip()
if v.startswith('v'):
v = v[1:]
v = v.split('@', 1)[0]
v = v.split('+', 1)[0]
if any(tag in v for tag in ['dev', 'alpha', 'beta', 'rc']):
return None
m = re.match(r'^(\d+)(?:\.(\d+))?(?:\.(\d+))?', v)
if not m:
return None
parts = [m.group(1) or '0', m.group(2) or '0', m.group(3) or '0']
return '.'.join(parts)
def to_tuple(v: str) -> Tuple[int, int, int]:
a, b, c = v.split('.')
return int(a), int(b), int(c)
def cmpv(a: str, b: str) -> int:
ta, tb = to_tuple(a), to_tuple(b)
return (ta > tb) - (ta < tb)
def in_range(v: str, lo: Optional[str], hi: Optional[str]) -> bool:
if lo is not None and cmpv(v, lo) < 0:
return False
if hi is not None and cmpv(v, hi) > 0:
return False
return True
def classify(v: str) -> str:
# Vulnerable ranges from advisory
vulnerable = [
(None, '1.30.2'),
('2.0.0', '2.1.14'),
('2.2.0', '2.4.3'),
('3.3.0', '3.10.3'),
('4.0.0', '5.5.0'),
]
for lo, hi in vulnerable:
if in_range(v, lo, hi):
return 'VULNERABLE'
# Clearly unaffected/patched windows
patched = [
('1.30.3', None),
('2.1.15', '2.1.999'),
('2.4.4', '2.999.999'),
('3.0.0', '3.2.999'),
('3.10.4', '3.999.999'),
('5.6.0', None),
]
for lo, hi in patched:
if in_range(v, lo, hi):
return 'PATCHED'
return 'UNKNOWN'
def extract_version_from_composer_lock(data: Any) -> Optional[str]:
if not isinstance(data, dict):
return None
packages = []
for key in ('packages', 'packages-dev'):
if isinstance(data.get(key), list):
packages.extend(data[key])
for pkg in packages:
if isinstance(pkg, dict) and pkg.get('name') == PKG:
return pkg.get('version') or pkg.get('pretty_version')
return None
def extract_version_from_installed_json(data: Any) -> Optional[str]:
packages = []
if isinstance(data, list):
packages = data
elif isinstance(data, dict):
if isinstance(data.get('packages'), list):
packages = data['packages']
elif isinstance(data.get('installed'), list):
packages = data['installed']
for pkg in packages:
if isinstance(pkg, dict) and pkg.get('name') == PKG:
return pkg.get('version') or pkg.get('pretty_version')
return None
def main() -> int:
if len(sys.argv) != 2:
usage()
print('UNKNOWN')
return 2
kind, path = find_manifest(sys.argv[1])
if not path:
print('UNKNOWN')
print('Reason: could not find composer.lock or vendor/composer/installed.json')
return 2
data = read_json(path)
if data is None:
print('UNKNOWN')
print(f'Reason: unable to parse JSON from {path}')
return 2
raw_version = None
if kind == 'composer.lock':
raw_version = extract_version_from_composer_lock(data)
elif kind == 'installed.json':
raw_version = extract_version_from_installed_json(data)
if not raw_version:
print('PATCHED')
print(f'Reason: package {PKG} not present in {path}')
return 0
version = normalize_version(raw_version)
if version is None:
print('UNKNOWN')
print(f'Reason: found {PKG} version {raw_version!r}, but could not normalize it safely')
return 2
result = classify(version)
print(result)
print(f'Package: {PKG}')
print(f'Version: {raw_version} (normalized: {version})')
if result == 'VULNERABLE':
print('Fixed versions: 1.30.3 / 2.1.15 / 2.4.4 / 3.10.4 / 5.6.0 or newer on supported branches')
return 1
elif result == 'PATCHED':
return 0
else:
return 2
if __name__ == '__main__':
sys.exit(main())
If you remember one thing.
phpoffice/phpspreadsheet is actually present and whether any app accepts attacker-controlled filename strings into IOFactory::load(); second, queue dependency upgrades to the fixed branches. Because this is a MEDIUM reassessment and there is no mitigation SLA — go straight to the 365-day remediation window under the noisgate mitigation SLA / noisgate remediation SLA model, but do not wait that long for any app you confirm has a reachable unsafe import path: add wrapper-scheme blocking immediately and patch those specific apps in the current engineering cycle.Sources
- GitHub Security Advisory GHSA-q4q6-r8wh-5cgh
- NVD CVE-2026-34084
- OSV entry for GHSA-q4q6-r8wh-5cgh / CVE-2026-34084
- Packagist phpoffice/phpspreadsheet package page
- GitHub releases for PHPOffice/PhpSpreadsheet
- PHP manual: Supported Protocols and Wrappers
- CISA Known Exploited Vulnerabilities Catalog
- FIRST EPSS overview and data access
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.