Topic: mcp server path traversal

MCP server path traversal — directory traversal, symlink escapes, and zip slip

MCP servers that read, write, or extract files using paths from tool call arguments are common — file reading is one of the most useful tool categories in the MCP ecosystem. But an LLM that constructs a path argument, or a prompt-injected attacker who controls the LLM's tool arguments, can escape the intended directory boundary using four techniques: directory traversal (../../etc/passwd), symlink escape (a symlink inside an allowed directory that points outside it), zip slip (archive entries with traversal paths), and null byte injection (path truncation in legacy code). Each requires a specific defense.

Attack 1: Directory traversal

The directory traversal attack uses ../ sequences in a path argument to climb out of the intended base directory. An MCP server that exposes a readFile tool and passes the path argument directly to fs.readFile() allows a prompt-injected LLM to read any file the server process can access — including /etc/passwd, SSH private keys, environment files, and database credentials.

import path from 'path'
import fs from 'fs'

const ALLOWED_BASE = '/app/workspace'  // the only directory the LLM can access

// WRONG: naive prefix check on raw input
async function readFile(filePath: string): Promise {
  if (!filePath.startsWith(ALLOWED_BASE)) {
    throw new Error('Access denied')
  }
  // Attack: filePath = '/app/workspace/../../../etc/passwd'
  // Starts with /app/workspace — passes the check!
  // fs.readFile resolves the ../ segments and reads /etc/passwd
  return fs.promises.readFile(filePath, 'utf8')
}

// CORRECT: resolve to real path first, then check prefix
async function readFile(filePath: string): Promise {
  // path.resolve: handles relative paths, normalizes ./ and ../
  const requested = path.resolve(ALLOWED_BASE, filePath)

  // fs.realpathSync: follows symlinks, resolves to the actual filesystem path
  // Throws if the path doesn't exist — catch and return access denied
  let realPath: string
  try {
    realPath = fs.realpathSync(requested)
  } catch {
    throw new Error('File not found or access denied')
  }

  // Now check the real, resolved path — not the input
  if (!realPath.startsWith(ALLOWED_BASE + path.sep)) {
    throw new Error('Access denied: path outside workspace')
  }

  return fs.promises.readFile(realPath, 'utf8')
}

Attack 2: Symlink escape

A symlink inside the allowed directory can point to a location outside it. If you check path prefixes before following symlinks, you'll accept a path like /app/workspace/link-to-secrets even if that symlink resolves to /etc/aws/credentials. The defense is identical to directory traversal — use fs.realpathSync() which follows symlinks, then check the prefix on the resolved path.

// Attacker creates: ln -s /etc/aws /app/workspace/aws-config
// Tool call: readFile({ path: 'aws-config/credentials' })

// WRONG: checks prefix before following symlink
const requested = path.join(ALLOWED_BASE, 'aws-config/credentials')
// requested = '/app/workspace/aws-config/credentials'
if (!requested.startsWith(ALLOWED_BASE)) throw new Error('denied')
// Passes check! But the symlink makes this /etc/aws/credentials

// CORRECT: realpathSync follows the symlink before the prefix check
const realPath = fs.realpathSync(requested)
// realPath = '/etc/aws/credentials' — the actual target after following the symlink
if (!realPath.startsWith(ALLOWED_BASE + path.sep)) {
  throw new Error('Access denied: symlink escape detected')
}
// Rejected correctly — even though the original path was inside the workspace

Attack 3: Zip slip in archive extraction

Zip slip is a path traversal attack that targets archive extraction. An attacker crafts a zip or tar file with entries whose path contains traversal sequences: ../../../../home/user/.ssh/authorized_keys. A naive extraction loop writes the entry to the constructed path without checking whether it falls inside the extraction directory — writing arbitrary files anywhere the server process has write access.

import AdmZip from 'adm-zip'
import path from 'path'
import fs from 'fs'

const EXTRACT_BASE = '/app/uploads/extracted'

// WRONG: extract without path validation
async function extractZip(zipBuffer: Buffer) {
  const zip = new AdmZip(zipBuffer)
  zip.extractAllTo(EXTRACT_BASE, true)
  // An entry named '../../.ssh/authorized_keys' is extracted to /app/.ssh/authorized_keys
}

// CORRECT: validate each entry before extraction
async function extractZip(zipBuffer: Buffer) {
  const zip = new AdmZip(zipBuffer)
  const entries = zip.getEntries()

  for (const entry of entries) {
    if (entry.isDirectory) continue

    // Resolve the destination path
    const destPath = path.resolve(EXTRACT_BASE, entry.entryName)

    // Check that resolved path is within extraction base
    if (!destPath.startsWith(EXTRACT_BASE + path.sep)) {
      throw new Error(`Zip slip detected: entry ${entry.entryName} would escape extraction directory`)
    }

    // Safe to write
    await fs.promises.mkdir(path.dirname(destPath), { recursive: true })
    await fs.promises.writeFile(destPath, entry.getData())
  }
}

Attack 4: Null byte injection

Null byte injection is a legacy attack that exploits C-based file system calls that treat the null character (\0) as a string terminator. A path like /app/workspace/file.txt\0.jpg would be read by the C runtime as /app/workspace/file.txt — the .jpg suffix is silently dropped. In modern Node.js, most file system calls validate against null bytes and throw ERR_INVALID_ARG_VALUE — but if you call into native extensions, legacy C libraries, or use string manipulation before the null reaches Node's fs module, the attack may still work.

// Explicit null byte validation — belt-and-suspenders for defense in depth
function validatePath(input: string): string {
  // Reject null bytes
  if (input.includes('\0')) {
    throw new Error('Invalid path: null byte detected')
  }

  // Normalize: resolve ./ and ../ sequences, standardize separators
  const normalized = path.normalize(input)

  // Reject paths that are still trying to traverse after normalization
  // (this catches encoded variants that path.normalize resolves)
  if (normalized.includes('..')) {
    throw new Error('Invalid path: directory traversal detected')
  }

  return normalized
}

// Full validation pipeline:
async function safeReadFile(rawPath: string): Promise {
  const sanitized = validatePath(rawPath)
  const requested = path.resolve(ALLOWED_BASE, sanitized)

  let realPath: string
  try {
    realPath = fs.realpathSync(requested)
  } catch {
    throw new Error('File not found or access denied')
  }

  if (!realPath.startsWith(ALLOWED_BASE + path.sep)) {
    throw new Error('Access denied')
  }

  return fs.promises.readFile(realPath, 'utf8')
}

What SkillAudit checks

See also

Check your file-reading tools for path traversal and symlink escape findings.

Run a free audit → How grading works →