← Back to Feed CACHED · 2026-05-17 09:42:19 · cache_key CVE-2025-29912
CVE-2026-34084 · CWE-502 · Disclosed 2026-05-05

PhpSpreadsheet is a library for reading and writing spreadsheet files

ASSESSED — NOISGATE V0.5
Vendor
Reassessed
Verdict:
01 · The Real Story

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.

"Dangerous in the wrong app, but this is not a universal unauthenticated Internet RCE."
02 · The Attack Path

5 steps from start to impact.

STEP 01

Find an app flow that accepts a filename or path string

The attacker first needs an application endpoint, job queue, import feature, or API parameter that accepts a filename and ultimately passes it into 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.
Conditions required:
  • The target uses phpoffice/phpspreadsheet in 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
Where this breaks in practice:
  • 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
Detection/coverage: Commodity network scanners will mostly miss this. SAST can catch direct user input into IOFactory::load(); SCA only tells you the dependency version, not whether the dangerous call pattern exists.
STEP 02

Abuse PHP stream wrappers to bypass the file check

The GHSA PoC shows that 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.
Conditions required:
  • 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
Where this breaks in practice:
  • 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
Detection/coverage: Application logs may show unusual schemes like phar:// or ftp:// in import parameters. Runtime detections should focus on suspicious wrapper strings and unexpected outbound connection attempts during import workflows.
STEP 03

Take the easier win: SSRF with ftp:// or ssh2.sftp://

Per the advisory PoC, 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.
Conditions required:
  • 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
Where this breaks in practice:
  • 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
Detection/coverage: EDR/NDR/egress monitoring can catch web or worker processes initiating unexpected FTP or SFTP traffic. This is one of the strongest real-world brakes on impact.
STEP 04

Escalate to PHAR deserialization

For the higher-impact branch, the attacker supplies a 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.
Conditions required:
  • 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
Where this breaks in practice:
  • 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
Detection/coverage: Look for 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.
STEP 05

Land impact inside the hosting app

If the gadget chain exists, code runs in the security context of the PHP worker or job runner; otherwise the attacker still may have SSRF or at least a denied request. Impact is therefore highly tied to the privileges, secrets, and network reachability of the specific application using the library. This is why the blast radius varies wildly from one deployment to another.
Conditions required:
  • 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
Where this breaks in practice:
  • 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
Detection/coverage: Post-impact coverage depends on platform controls: EDR on PHP hosts, container runtime telemetry, web logs, and outbound network monitoring are all relevant.
03 · Intelligence Metadata

The supporting signals.

In-the-wild statusNo confirmed active exploitation found in the sources reviewed as of 2026-05-30. The CVE is not in CISA KEV.
Proof-of-concept availabilityPublic PoC exists in the GitHub advisory from reporter calligraf0, including both an SSRF demo with ncat and a PHAR-based deserialization/RCE demo.
EPSS0.00226 from the user intel block. That is a low exploitation-likelihood signal; percentile was not independently confirmed in the sources reviewed.
KEV statusNot listed in the CISA Known Exploited Vulnerabilities Catalog as of 2026-05-30.
CVSS vector reality checkVendor/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 versions1.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 populationThe 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 timelineGitHub advisory published 2026-04-28 / 2026-04-29 UTC depending on source rendering; NVD published the CVE on 2026-05-05.
ReporterThe GHSA credits calligraf0 as the reporter.
04 · The Call

noisgate verdict.

Final Verdict
DOWNGRADED to MEDIUM (6.3/10)

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.

HIGH Affected version ranges and fixed versions
MEDIUM Severity downgrade from vendor 9.8/CRITICAL
MEDIUM No-known-exploitation status as of 2026-05-30

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.

05 · Compensating Control

What to do — in priority order.

  1. Block wrapper schemes at the application boundary — Reject any filename/path beginning with multi-character URI schemes such as phar://, ftp://, or ssh2.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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
What doesn't work
  • stream_is_local() is not a safe filter here; the advisory specifically notes it still treats phar:// 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.
06 · Verification

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.

noisgate-verify.py
PYTHONREAD-ONLYSAFE
#!/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())
07 · Bottom Line

If you remember one thing.

TL;DR
Monday morning, do two things in parallel: first, use SAST/grep plus the verification script to identify where 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

  1. GitHub Security Advisory GHSA-q4q6-r8wh-5cgh
  2. NVD CVE-2026-34084
  3. OSV entry for GHSA-q4q6-r8wh-5cgh / CVE-2026-34084
  4. Packagist phpoffice/phpspreadsheet package page
  5. GitHub releases for PHPOffice/PhpSpreadsheet
  6. PHP manual: Supported Protocols and Wrappers
  7. CISA Known Exploited Vulnerabilities Catalog
  8. FIRST EPSS overview and data access
Peer Review

What defenders are saying.

Submit a review attribution: handle + country only
0 flags selected · stored anonymously
Validation Results

Crowdsourced verification outputs.

Results submitted by users who ran the verification payload against their environment.