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
- LLM-controlled path passed directly to fs.readFile/writeFile without real-path resolution — HIGH; directory traversal vector
- Path prefix check on unresolved (non-realpath) input — HIGH; symlink escape and traversal bypass
- Archive extraction without per-entry destination path validation — HIGH; zip slip write-anywhere vector
- Missing null byte rejection in path validation — WARN; defense-in-depth gap for legacy library calls
See also
- MCP server command injection — shell injection risks in tool handlers
- MCP server input validation — Zod schema validation patterns
- MCP server OWASP Top 10 — injection and access control in the full MCP threat model
- Public audit corpus — path traversal findings across scanned servers
Check your file-reading tools for path traversal and symlink escape findings.
Run a free audit → How grading works →