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
•Technically reviewed by Anupam Mediratta•Published September 7, 2026•Reviewed 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.

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.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #48

Related Articles

critical

path.resolve() Path Traversal in Node CLI's Dynamic import()

A Node.js CLI script for validating expression definitions took a file path from `process.argv[2]`, resolved it with `path.resolve()`, and passed the result straight into a dynamic `import()` — with no check that the resolved path stayed inside the working directory. An attacker (or a malicious skill/plugin invocation) could supply traversal sequences to load and execute arbitrary `.js` files from anywhere on disk. The fix adds a boundary check with `path.relative()` and an extension allowlist b

high

bookDir() Path Traversal via Unsanitized bookId Parameter

The `bookDir()` function accepted unsanitized `bookId` values derived from user-created book titles, enabling path traversal attacks through `../` sequences. A fix was applied that validates the identifier using `path.basename()` and throws on mismatch, ensuring all resolved paths remain within `LIBRARY_DIR`.

high

Express `app.get('*')` Wildcard Handler Path Traversal in watch.js

A first-party Express server's wildcard route handler used `req.url.indexOf('font.woff2')` to gate access to a font file, allowing attackers to bypass the substring check with crafted paths. The fix replaces the catch-all handler with explicit route registration.

high

updateCardBg() Follows Unvalidated 302 Location Headers

A background-image updater fetched a configured image URL with manual redirect handling and then re-issued the request to whatever `Location` header came back, with no scheme or host checks. A redirect to `http://169.254.169.254/` or `http://127.0.0.1:<port>/` would have been followed with the original fetch options attached, and the response body written to disk as an image asset. The fix resolves the redirect target against `imgDownloadUrl` and rejects anything that is not HTTPS on the same ho

high

markitdown_bridge.py Path Traversal: Arbitrary File Read via sys.argv

The markitdown_bridge.py script, used by MDView for DOCX-to-Markdown conversion, accepted file paths directly from command-line arguments without validating they stayed within intended directories. An attacker could exploit this to read arbitrary files from the filesystem by passing path traversal sequences in the source_path parameter.

high

SHACL Viewer Path Traversal in graph3d(): Unvalidated `path`

The `graph3d()` and `graph2d()` request handlers in SHACL Viewer directly concatenated user-supplied `path` parameters into filesystem paths, enabling directory traversal outside the intended `/shapes/` directory. The fix introduces `_resolve_shapes_path()` with `os.path.realpath()` validation to enforce containment within the shapes directory.