Topic: mcp server symlink attack security

MCP server symlink attack security — filesystem escape via symbolic links

A symlink attack exploits the kernel's transparent symlink-following behavior to make a file operation target a different file than the path string suggests. For MCP file-access tools that enforce access control by checking whether a path is within an allowed directory, symbolic links create a bypass: a symlink residing inside the allowed directory that points outside it passes the path check but accesses the target outside the allowed scope. The distinction from TOCTOU is that the symlink is present before the check — no race is required.

The static symlink escape

A project-browsing MCP tool declares that it will only read files within /projects/user123/. It validates paths by resolving to an absolute path and checking the prefix:

async function readFileTool({ path: inputPath }) {
  const abs = path.resolve(ALLOWED_ROOT, inputPath);

  if (!abs.startsWith(ALLOWED_ROOT + '/')) {
    throw new Error('Path outside allowed directory');
  }

  // abs is '/projects/user123/secret.txt'
  // passes the startsWith check
  // but secret.txt is a symlink to '/etc/passwd'
  return await fs.readFile(abs, 'utf8');
}

If the project directory contains a symlink secret.txt → /etc/passwd, the path check passes (the symlink's path is inside the allowed root) but the read follows the symlink to its target outside the root. In multi-user environments, a symlink placed by one user inside their project directory can be used by a separately-injected LLM session to read outside their scope.

Symlinks at write destinations

The attack is even simpler when the MCP server has a write tool. An LLM that has been injected with a two-step payload first creates a symlink at the intended write destination, then triggers a write operation. The tool's path check passes — the write destination path is within the allowed directory. But the write follows the symlink and writes to the target. If the target is a system file, config file, or authorized_keys, the write is a privilege escalation.

// Attacker's two-step injection:
// Step 1: create_symlink({ from: 'project/.env', to: '/root/.ssh/authorized_keys' })
// Step 2: write_file({ path: 'project/.env', content: 'attacker-ssh-key' })
// Result: authorized_keys now contains the attacker's key

The write tool validated that project/.env is within the project directory. It did not check whether the path is a symlink before writing.

Directory symlinks and subtree escapes

Symlink attacks against directories are harder to catch because the symlink itself may be several levels above the target file. A symlink at /projects/user123/node_modules pointing to /usr/lib/node_modules allows an MCP tool that reads any file within the project to access arbitrary files under /usr/lib/node_modules — which may include writable packages, configuration files, or files with predictable paths for further exploitation. The symlink is at the directory level; every path check that resolves the final component's string is bypassed.

The correct fix: realpath after join, validate before open

The fix is to call fs.realpath() after joining the path with the allowed root but before performing any file operation. realpath resolves all symlinks in all path components and returns the canonical filesystem path. Checking that the resolved canonical path starts with the allowed root catches both string-based traversal and symlink-based traversal:

async function readFileTool({ path: inputPath }) {
  const joined = path.join(ALLOWED_ROOT, inputPath);

  // Resolve ALL symlinks in the path — throws ENOENT if file doesn't exist
  let canonical;
  try {
    canonical = await fs.realpath(joined);
  } catch (err) {
    throw new Error('File not found');
  }

  // Now check the resolved path — symlinks already followed
  if (!canonical.startsWith(ALLOWED_ROOT + path.sep)) {
    throw new Error('Path outside allowed directory');
  }

  return await fs.readFile(canonical, 'utf8');
}

For write operations, use O_NOFOLLOW via the fs.open flag to reject the open if the final path component is a symlink — this prevents the write-through-symlink attack even when the destination doesn't yet exist as a file (so realpath would throw ENOENT):

// O_NOFOLLOW: reject if path is a symlink (Linux/macOS)
// Combined with O_WRONLY | O_CREAT
const fd = await fs.open(joinedPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW);
await fd.write(content);
await fd.close();

SkillAudit's static analysis flags file-reading and file-writing tools that use path prefix checks without a preceding realpath call, and writing tools that use fs.writeFile (which follows symlinks) without an O_NOFOLLOW guard.

Check your MCP server's file tools for symlink escape vulnerabilities.

Run a free audit →