Skip to content

WhitespacePathNormalizer's control-character (CorruptedPathDetected) check is bypassed by malformed UTF-8 in the path, affecting every adapter

Moderate
frankdejonge published GHSA-cxf4-7mrp-vvpr Sep 4, 2026

Package

composer league/flysystem (Composer)

Affected versions

<= 3.35.2

Patched versions

3.35.3

Description

Related public issue (context, not a duplicate)

Closed issue #1429 ("Handle non-UTF-8 paths", 2024-03-24) raised exactly this general concern and
even suggested detection via preg_match('//u', $path) !== 1 -- note the reporter's suggested check
explicitly compares !== 1, which would correctly treat PCRE's false return as "reject." The
maintainer's reply pointed to the PathNormalizer interface as the place to implement this. The
control-character check that ended up shipping in WhitespacePathNormalizer
(if (preg_match('#\p{C}+#u', $unixPath))) addresses the general concern but does not use the
!== 1-style comparison the original issue suggested -- it uses a bare truthy check, which is exactly
the gap this report demonstrates. So this is not a duplicate of #1429; it's a concrete bypass
surviving in the fix that issue's concern led to.

Vulnerability Details

File: src/WhitespacePathNormalizer.php, lines 22-28 (normalizePath()) -- the default
PathNormalizer used by Filesystem for every adapter (Local, FTP, SFTP, S3, AsyncAwsS3, Azure, GCS,
ZipArchive, GridFS, InMemory) unless the application supplies a custom one.
CWE: CWE-20 (root cause -- Improper Input Validation); CWE-150 (demonstrated concrete consequence
-- Improper Neutralization of Escape/Control Sequences)
Severity: Medium
CVSS: 4.3 -- CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:L/A:N

Root Cause

public function normalizePath(string $path): string
{
    $unixPath = str_replace('\\', '/', $path);

    if (preg_match('#\p{C}+#u', $unixPath)) {
        throw CorruptedPathDetected::forPath($path);
    }
    ...

preg_match() returns false (a PHP engine error) rather than 0 when the subject string is not
valid UTF-8 and the pattern uses the /u modifier -- PCRE can't even attempt the match. false and
0 are both falsy in PHP, and if (preg_match(...)) does not distinguish them. So a path containing
any single invalid UTF-8 byte anywhere in the string makes preg_match() fail with a "Malformed
UTF-8 characters" engine error, the if evaluates false, and CorruptedPathDetected is silently not
thrown -- even when the same string also contains literal control characters this exact check exists
to catch.

I verified this is a genuine bypass by confirming the identical payload IS correctly rejected once it's
valid UTF-8:

$n->normalizePath("foo\x1bbar");         // valid UTF-8, contains ESC -> throws CorruptedPathDetected (correct)
$n->normalizePath("foo\x80\x1bbar");    // 0x80 = invalid lone UTF-8 continuation byte -> NOT thrown (bypass)

I also confirmed path-traversal protection is unaffected -- it's exact byte-string comparison on
/-delimited segments, independent of UTF-8 validity:

$n->normalizePath("\x80/../../etc/passwd");   // still throws PathTraversalDetected

What I checked and ruled out (tested against a real interpreter, not assumed)

Two more severe-sounding consequences were my first hypotheses; I tested both directly and both are
independently blocked by PHP itself -- documenting for accuracy:

  1. FTP command injection via FtpAdapter::fetchMetadata()'s ftp_raw('STAT ' . $location): tested
    end-to-end against a real FTP control-channel server with an embedded \r\n in the path. PHP's own
    ftp_raw() already refuses to send any command string containing an embedded \n -- returns NULL
    without writing anything to the wire. Confirmed via the test server's connection log showing the
    STAT command was never received.
  2. NUL-byte path truncation in LocalFilesystemAdapter::write(): modern PHP's file_put_contents()
    throws ValueError: ... must not contain any null bytes -- also independently blocked.

Attack Scenario (confirmed) -- terminal escape-sequence injection via stored filename

  1. Application preserves user-chosen filenames on upload (common pattern), stored via any adapter.
  2. Attacker uploads a file named: one invalid UTF-8 byte + raw ANSI escape sequences (\x1b[8m conceal,
    \x1b[2K erase line, \x1b[1A cursor up) + a decoy filename appended after.
  3. Filesystem::write() accepts and stores it -- CorruptedPathDetected never fires.
  4. An administrator later runs any tool that lists/logs the raw filenames Flysystem returns (bin/console
    storage commands, ops scripts, log viewers, terminal file managers) in a real terminal.
  5. The raw escape sequences execute live instead of displaying as text -- lines erased/rewritten, cursor
    manipulated, files hidden/spoofed in a listing (CWE-150).

Recommended Fix

public function normalizePath(string $path): string
{
    $unixPath = str_replace('\\', '/', $path);
    $matched = preg_match('#\p{C}+#u', $unixPath);

    if ($matched !== 0) {
        // $matched === false means malformed UTF-8 -- must also be treated as corrupted,
        // not silently allowed through.
        throw CorruptedPathDetected::forPath($path);
    }
    ...

Verification

Dynamically confirmed on league/flysystem HEAD 6837e1d / tag 3.35.2, PHP 8.4.22 CLI, end-to-end
through LocalFilesystemAdapter:

[1] write() succeeded -- normalizer did NOT reject the path.
[2] Actual bytes on disk: ...801b5b386d6e6f726d616c2d6c6f6f6b696e672d66696c652e7478741b5b306d...
[3] Filesystem::listContents() path contains raw ESC (0x1b): YES
[4] Raw terminal output (via `cat -v`): M-^@^[[8mnormal-looking-file.txt^[[0m^[[2K^[[1Aurgent-invoice.pdf

I'm happy to send a PR with the fix once you confirm the direction works for you.

Severity

Moderate

CVE ID

CVE-2026-102601

Weaknesses

No CWEs

Credits