PatchClock

PHP security digest · 29 Sep 2026

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

Low CVE-2026-102601

Package
league/flysystem
Fixed in
3.35.3
Published
Tue 29 Sep 2026, 19:10 LisbonUpdated Mon 26 Oct 2026, 03:26 Lisbon
Installs on Packagist
748M

In plain words

An attacker who can supply file paths can bypass Flysystem's control-character rejection when a path contains malformed UTF-8. This affects every adapter using the default WhitespacePathNormalizer, allowing paths containing control characters to pass that check. Path-traversal protection is unaffected.

Affected versions

PackageAffectedFixed in
league/flysystem<= 3.35.23.35.3

Am I affected?

Check composer.lock for league/flysystem at version <= 3.35.2; 3.35.3 is fixed. Check whether untrusted paths reach Filesystem using the default WhitespacePathNormalizer. If your application supplies a custom PathNormalizer, inspect its validation rather than assuming this default-normaliser flaw applies.

What to do

Check which version you have, then update to a fixed release for your branch (3.35.3):

composer show league/flysystem | grep versions
composer update league/flysystem

If your version constraint in composer.json keeps you below a fixed release, raise it first. Whether your configuration is affected, and the exact version ranges, are in the advisory itself.

If you cannot update yet

The description recommends changing the control-character check in src/WhitespacePathNormalizer.php to reject any preg_match() result other than 0, using $matched !== 0. This treats malformed UTF-8 errors as corrupted paths too.

The advisory's 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.

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.

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)

The 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

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

Weakness: CWE-150 Improper Neutralization of Escape, Meta, or Control Sequences. Reported by iaohkut, Unifex.

The advisory

From OSV.dev, which gathers the GitHub Advisory Database, FriendsOfPHP and Drupal security advisories; the summary is theirs. The description, affected versions, weakness and credits are from the GitHub Advisory Database (CC BY 4.0). "In plain words", "Am I affected?" and "If you cannot update yet" are based on that advisory, and checked to name no version it does not.