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:
- 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.
- 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
- Application preserves user-chosen filenames on upload (common pattern), stored via any adapter.
- 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.
Filesystem::write() accepts and stores it -- CorruptedPathDetected never fires.
- 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.
- 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.
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 checkexplicitly compares
!== 1, which would correctly treat PCRE'sfalsereturn as "reject." Themaintainer's reply pointed to the
PathNormalizerinterface as the place to implement this. Thecontrol-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 exactlythe 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 defaultPathNormalizerused byFilesystemfor 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
preg_match()returnsfalse(a PHP engine error) rather than0when the subject string is notvalid UTF-8 and the pattern uses the
/umodifier -- PCRE can't even attempt the match.falseand0are both falsy in PHP, andif (preg_match(...))does not distinguish them. So a path containingany single invalid UTF-8 byte anywhere in the string makes
preg_match()fail with a "MalformedUTF-8 characters" engine error, the
ifevaluates false, andCorruptedPathDetectedis silently notthrown -- 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:
I also confirmed path-traversal protection is unaffected -- it's exact byte-string comparison on
/-delimited segments, independent of UTF-8 validity: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:
FtpAdapter::fetchMetadata()'sftp_raw('STAT ' . $location): testedend-to-end against a real FTP control-channel server with an embedded
\r\nin the path. PHP's ownftp_raw()already refuses to send any command string containing an embedded\n-- returnsNULLwithout writing anything to the wire. Confirmed via the test server's connection log showing the
STAT command was never received.
LocalFilesystemAdapter::write(): modern PHP'sfile_put_contents()throws
ValueError: ... must not contain any null bytes-- also independently blocked.Attack Scenario (confirmed) -- terminal escape-sequence injection via stored filename
\x1b[8mconceal,\x1b[2Kerase line,\x1b[1Acursor up) + a decoy filename appended after.Filesystem::write()accepts and stores it --CorruptedPathDetectednever fires.bin/consolestorage commands, ops scripts, log viewers, terminal file managers) in a real terminal.
manipulated, files hidden/spoofed in a listing (CWE-150).
Recommended Fix
Verification
Dynamically confirmed on
league/flysystemHEAD6837e1d/ tag3.35.2, PHP 8.4.22 CLI, end-to-endthrough
LocalFilesystemAdapter:I'm happy to send a PR with the fix once you confirm the direction works for you.