Back to Blog
critical SEVERITY6 min read

How path traversal happens in PHP virtual filesystem adapters and how to fix it

A critical path traversal flaw in `VirtualAdapter.php`'s `resolveMount()` method allowed attackers to escape mounted directory boundaries using sequences like `../../../etc/passwd`. The fix introduces `PathPolicy::normalizeRelative()` to sanitize the remaining path segment before it ever reaches the underlying storage adapter.

O
By Orbis AppSec
Published September 7, 2026Reviewed September 7, 2026

Answer Summary

This is a path traversal vulnerability (CWE-22) in the PHP method `resolveMount()` within `app/Services/VFS/VirtualAdapter.php`, where user-supplied mount paths containing `../` sequences were passed unvalidated to filesystem operations. The fix adds `PathPolicy::normalizeRelative($remaining)` to canonicalize and strip traversal sequences before the resolved path is handed to any adapter, ensuring file operations stay within the declared root directory.

Vulnerability at a Glance

cweCWE-22
fixAdded `PathPolicy::normalizeRelative($remaining)` call before returning the mount/path pair, rejecting or sanitizing traversal sequences
riskAuthenticated attackers could delete or read files outside the mounted storage root (e.g., `/etc/passwd`)
languagePHP
root cause`resolveMount()` split paths on mount alias but never canonicalized or validated the remaining segment for `../` sequences
vulnerabilityPath Traversal

Introduction

The app/Services/VFS/VirtualAdapter.php file implements a virtual filesystem layer that maps logical "mount" aliases (like Test or storage) to real filesystem adapters. This kind of abstraction is common in applications that let users organize files across multiple backends — S3, local disk, network shares — behind a single, friendly path syntax such as mountname/documents/file.txt. The problem is that the method responsible for splitting that friendly path into a mount alias and a remaining sub-path, resolveMount(), never checked whether the "remaining" portion tried to climb back out of its assigned directory.

That gap meant a request like DELETE Test/../../../etc/passwd could pass straight through resolveMount(), hand ../../../etc/passwd to the underlying adapter, and potentially reach files far outside the intended storage root — a textbook CWE-22 path traversal, and one flagged as critical because the affected code path is reachable from an authenticated delete operation, not buried in test-only utilities.

The Vulnerability Explained

Before the fix, resolveMount() looked like this:

private function resolveMount(string $path): array
{
    $parts = explode('/', $path, 2);
    $alias = $parts[0];
    $remaining = $parts[1] ?? '';

    if (isset($this->mounts[$alias])) {
        return [$this->mounts[$alias], $remaining];
    }
    // ...
}

The method does exactly one thing to the path: it splits on the first / to separate the mount alias from everything after it. Whatever comes after that first slash — $remaining — is returned untouched and eventually passed down to delete(), readFile(), listDirectory(), or openReadStream() on the concrete adapter (local disk, S3, etc.). There is no check for .. segments, no canonicalization, and no verification that the resolved path stays under the mount's root.

Attack scenario: An authenticated user with access to the VFS delete endpoint sends:

DELETE /vfs/Test/../../../etc/passwd

resolveMount() splits this into alias Test and remaining ../../../etc/passwd. Since Test is a valid, mounted alias, the method happily returns [$this->mounts['Test'], '../../../etc/passwd']. The underlying LocalAdapter then concatenates its root directory with that remaining string and performs the delete — potentially outside the sandboxed storage root entirely, depending on how the adapter builds its final path.

The same weakness applies to any read or list operation reachable through resolveMount(), meaning an attacker isn't limited to deletion — arbitrary file disclosure via readFile() or directory enumeration via listDirectory() are equally exposed, all through the same unvalidated $remaining value.

The Fix

The PR adds a single, targeted line inside resolveMount():

// Before
if (isset($this->mounts[$alias])) {
    return [$this->mounts[$alias], $remaining];
}

// After
if (isset($this->mounts[$alias])) {
    $remaining = PathPolicy::normalizeRelative($remaining);
    return [$this->mounts[$alias], $remaining];
}

By routing $remaining through PathPolicy::normalizeRelative() before it's returned, every consumer of resolveMount()delete(), readFile(), listDirectory(), openReadStream(), and anything else built on top of it — now receives a path that has already been canonicalized and stripped of traversal sequences. This closes the vulnerability at its single point of origin rather than requiring every downstream operation to independently re-validate the path, which is exactly what you want when the goal is enforcing an invariant like "file operations never resolve paths outside the declared root directory."

The accompanying test changes in tests/unit/VirtualVfsTest.php reinforce this: a new testMountedPathTraversalIsRejectedBeforeAnyOperationReachesTheAdapter() test mounts a temporary local directory, then exercises listDirectory(), readFile(), and openReadStream() with Test/../outside style payloads, asserting that a RuntimeException containing "traversal" is thrown rather than silently returning data. The included regression test for resolveMount() itself also checks several encoding variants — classic ../../../etc/passwd, doubled ....//....//etc/passwd, and URL-encoded %2e%2e%2f... — to make sure the normalization logic isn't fooled by simple obfuscation.

Prevention & Best Practices

  • Canonicalize before you trust. Any time user-influenced input becomes part of a filesystem path, resolve . and .. segments (and decode percent-encoding first) before doing anything else with it.
  • Validate against the root, not just the string. After normalization, confirm the resulting absolute path is still a descendant of the intended root directory — string-level .. blacklisting alone is fragile against encoding tricks.
  • Centralize the check. As this fix demonstrates, putting the sanitization inside resolveMount() — the single chokepoint every mount-aware operation passes through — is far more robust than patching each of delete(), readFile(), and listDirectory() separately.
  • Write regression tests with real payloads. The PR's data provider (../../../etc/passwd, ....//....//etc/passwd, %2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd) is a good template for testing path-handling code against known bypass techniques.
  • Reference OWASP guidance. The OWASP Path Traversal cheat sheet and CWE-22 page both recommend canonicalization plus root-boundary checks as the standard mitigation — exactly the pattern applied here.

Key Takeaways

  • resolveMount() in VirtualAdapter.php was the single unguarded chokepoint feeding unvalidated paths to every VFS operation — fixing it in one place secured delete(), readFile(), listDirectory(), and openReadStream() simultaneously.
  • The vulnerable code trusted the second half of explode('/', $path, 2) completely, with no check for .. segments before handing it to the underlying adapter.
  • PathPolicy::normalizeRelative() is now the required gatekeeper for any relative path used inside the VFS layer — new adapters or operations should call it, not reimplement their own sanitization.
  • The regression suite specifically targets encoded and doubled traversal payloads (%2e%2e%2f, ....//), a reminder that naive str_contains($path, '..') checks are insufficient.
  • This was reachable via an authenticated DELETE request, proving that "requires login" is not the same as "safe" — authorization and path validation are separate controls.

How Orbis AppSec Detected This

  • Source: The mount-relative path segment supplied by an authenticated client in a DELETE (or read/list) request, e.g. Test/../../../etc/passwd.
  • Sink: The tuple [$this->mounts[$alias], $remaining] returned from resolveMount() in app/Services/VFS/VirtualAdapter.php:60, consumed by filesystem operations like delete().
  • Missing control: No canonicalization or root-boundary validation of the $remaining path segment before it was forwarded to the underlying storage adapter.
  • CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
  • Fix: Added $remaining = PathPolicy::normalizeRelative($remaining); in resolveMount() so traversal sequences are neutralized before any adapter operation executes.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

A single unchecked line in resolveMount() was enough to turn a virtual filesystem abstraction into a path outside its intended sandbox. The fix here is small — one call to PathPolicy::normalizeRelative() — but its placement matters: by sanitizing the path at the exact point where mount aliases are resolved, every current and future operation built on top of resolveMount() inherits the protection automatically. If your codebase has similar "split the path, trust the rest" logic, treat this as a prompt to add the same canonicalization-and-boundary-check pattern before your next filesystem operation ships.

References

Frequently Asked Questions

What is path traversal?

Path traversal (CWE-22) is a vulnerability where an application accepts a file path from user input and uses it in a filesystem operation without properly validating that it stays within an intended directory, allowing attackers to use sequences like `../` to escape the sandboxed root.

How do you prevent path traversal in PHP?

Normalize and canonicalize every user-supplied path segment (resolving `../` and `.` sequences) before use, then verify the resulting absolute path is still a subpath of the intended root directory — never trust string concatenation of untrusted input into filesystem calls.

What CWE is path traversal?

Path traversal is classified as CWE-22: "Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')".

Is blacklisting '../' enough to prevent path traversal?

No. Blacklisting raw `../` strings misses encoded variants (`%2e%2e%2f`), double-encoding, and backslash traversal on some filesystems; proper normalization and boundary checks against a canonical root are required.

Can static analysis detect path traversal?

Yes — static analysis and taint-tracking tools like Semgrep, CodeQL, and multi-agent AI scanners can flag places where unsanitized input reaches filesystem sink functions such as `resolveMount()`, `fopen()`, or `unlink()`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #48

Related Articles

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

high

How Trust-Prefix Bypass via Path Traversal Happens in Python Copier and How to Fix It

CVE-2026-53951 is a high-severity path traversal vulnerability in Copier 9.15.0 that allowed attackers to bypass trust-prefix checks and execute tasks without user confirmation. Upgrading to Copier 9.15.2 eliminates this attack vector by properly validating file paths before task execution.

critical

How Command Injection happens in Python subprocess calls and how to fix it

A critical command injection vulnerability was discovered in `spider/php/crawler.py` where the `PHPBridge.call()` method passed unvalidated external arguments directly to `subprocess.run()`. An attacker controlling the `spider_path` or `method` parameters could execute arbitrary PHP scripts or inject malicious method names. The fix adds strict input validation — requiring `method` to be a valid Python identifier and `spider_path` to resolve to an existing `.php` file — before any subprocess exec

critical

How Path Traversal in basic-ftp Leads to File Overwrite Attacks and How to Fix It

CVE-2026-27699 is a critical path traversal vulnerability in basic-ftp versions before 5.3.1 that allows attackers to overwrite arbitrary files on the system by crafting malicious file paths. This vulnerability was fixed by upgrading basic-ftp and enforcing strict version constraints across dependent packages. Understanding this attack and its mitigation is essential for developers using FTP libraries in production environments.

critical

How Command Injection Vulnerabilities Happen in Python Subprocess Calls and How to Fix Them

A critical command injection vulnerability was discovered in `src/unused/server/fft.py` where external binaries like `oggenc` and `cocoa_text` were executed with file path parameters that could be manipulated by user input. Although `shell=False` was used, the lack of input validation allowed attackers to potentially trigger processing of arbitrary files or cause denial of service. This fix implements proper path validation to prevent exploitation.

critical

How SQL injection happens in Python DuckDB view creation and how to fix it

A critical SQL injection flaw in `python/src/idx/api.py:265` built five DuckDB `CREATE VIEW` statements with Python f-strings, interpolating a filesystem path directly into SQL text. The fix replaces the interpolated path with a bound parameter (`read_parquet(?)`) and moves the view names into a hardcoded, non-interpolated statement map — eliminating any path where filenames or directory values can alter SQL structure.