Topic: mcp server toctou security
MCP server TOCTOU security — time-of-check-to-time-of-use race conditions on files and resources
A time-of-check-to-time-of-use (TOCTOU) race condition occurs when a program checks a property of a resource, then uses the resource, but the property changes between the check and the use. In MCP servers that expose file access tools, the classic form is: validate the path with fs.access(), then open the file with fs.readFile(). Between those two calls, an attacker who controls the filesystem can replace the file with a symlink pointing to a sensitive target outside the allowed directory. The access check passes on the original path. The read follows the symlink to the attacker's target.
Why TOCTOU appears in MCP file tools
MCP servers that give an LLM access to the local filesystem — file readers, code editors, project browsers — almost universally implement some form of path validation: a check that the requested path is within the allowed working directory and not a reference to sensitive files like /etc/passwd or ~/.ssh/id_rsa. The standard Node.js approach is to resolve the path to an absolute form and check that it starts with the allowed prefix:
// Node.js — VULNERABLE TOCTOU
async function readFileTool(args) {
const resolved = path.resolve(WORK_DIR, args.path);
// Check: is the resolved path inside the allowed directory?
if (!resolved.startsWith(WORK_DIR + '/')) {
throw new Error('Path traversal not allowed');
}
// Use: read the file
// RACE WINDOW: between these two operations, args.path may have been
// replaced with a symlink pointing outside WORK_DIR
return await fs.readFile(resolved, 'utf8');
}
The check validates the path as a string. It does not check whether the path component on disk is a symlink, nor does it validate the destination of a symlink. Between path.resolve() (which is a pure string operation, not a syscall) and fs.readFile() (which opens the file and follows symlinks), the filesystem is open. An attacker who can create files in the working directory — possible if another tool in the same MCP server provides write access — can replace the validated path with a symlink to any file readable by the MCP server process.
In Node.js this race is probabilistically exploitable: the async yield between the check and the read creates a window where the event loop can process other operations. A concurrent attacker tool call that creates a symlink at the validated path fires in that window. Against a server processing many concurrent requests, the race window opens reliably.
The symlink-swap attack in detail
A concrete three-step exploitation sequence:
1. The attacker (via the LLM's tool calls) creates a legitimate file at a path within the working directory: WORK_DIR/data/report.txt with benign content. Confirm it passes validation.
2. The attacker issues two concurrent tool calls: a read request for data/report.txt, and immediately a write/symlink creation for the same path. The write tool creates a symlink at data/report.txt pointing to /etc/shadow (or any sensitive file). The write fires in the event loop gap between the read's path check and the read's open call.
3. The read tool opens the now-symlinked path, follows the symlink, reads /etc/shadow, and returns its contents as the tool result. The LLM receives the sensitive file contents in its context.
This attack requires the MCP server to have both a read tool and a write tool operating on the same directory. Many file-management MCP servers have exactly this combination. The two-concurrent-call approach is reliable because the agent orchestrator can issue multiple tool calls in the same response turn.
Safe patterns: O_NOFOLLOW, fstat-after-open, and realpath validation
The safe approach eliminates the race by combining the check and the use into a single atomic or check-after-open operation. In Node.js, use fs.open() with the O_NOFOLLOW flag constant (available via fs.constants.O_NOFOLLOW) to prevent the open syscall from following a symlink at the final path component:
// Node.js — SAFE using O_NOFOLLOW
const { O_RDONLY, O_NOFOLLOW } = fs.constants;
async function readFileTool(args) {
const resolved = path.resolve(WORK_DIR, args.path);
// Validate the string path as a defense-in-depth check
if (!resolved.startsWith(WORK_DIR + '/')) {
throw new Error('Path traversal not allowed');
}
// Open with O_NOFOLLOW — fails with ELOOP if resolved is a symlink
// This check and open are a single syscall; no race window
let fd;
try {
fd = await fs.open(resolved, O_RDONLY | O_NOFOLLOW);
} catch (err) {
if (err.code === 'ELOOP') throw new Error('Symlink access denied');
throw err;
}
// Now validate the open file using fstat — no path involved
const stat = await fd.stat();
if (!stat.isFile()) {
await fd.close();
throw new Error('Not a regular file');
}
try {
return await fd.readFile({ encoding: 'utf8' });
} finally {
await fd.close();
}
}
The O_NOFOLLOW flag causes the open to fail with ELOOP if the final component of the path is a symlink. This closes the race window: by the time the file descriptor is valid, we know the opened path was not a symlink at open time. The subsequent fstat validates the open file object rather than the path string — no further path resolution, no further race window.
Note that O_NOFOLLOW prevents following symlinks at the final path component only. Intermediate components (directories) may still be symlinked. For fully symlink-safe resolution, use fs.realpath() on the validated path and re-check that the resolved real path is within the allowed directory before opening with O_NOFOLLOW:
// Extra hardening: realpath + O_NOFOLLOW
const real = await fs.realpath(resolved);
if (!real.startsWith(WORK_DIR + '/')) {
throw new Error('Symlink escape detected');
}
fd = await fs.open(real, O_RDONLY | O_NOFOLLOW);
TOCTOU in permission and quota checks
TOCTOU also appears in resource accounting outside file access. An MCP server that checks a per-user quota before executing a tool:
// VULNERABLE — concurrent calls both pass the quota check
async function handlePaidOperation(userId, args) {
const usage = await db.getUsage(userId);
if (usage >= QUOTA) throw new Error('Quota exceeded');
// Race window — another concurrent call may have incremented usage
await db.incrementUsage(userId);
return await runOperation(args);
}
The safe pattern uses a single atomic check-and-increment in the database: either a SELECT ... FOR UPDATE / UPDATE ... WHERE usage < quota RETURNING * pattern, or an optimistic lock with a re-check after the increment that rolls back on violation.
SkillAudit detection
SkillAudit's Security axis identifies TOCTOU patterns in MCP file tool code by detecting fs.access() or fs.stat() calls that are followed by fs.readFile(), fs.writeFile(), or fs.open() on the same path variable within the same async function body — without an intervening O_NOFOLLOW flag or realpath check. The LLM probe stress test issues concurrent read and symlink-creation tool calls against the same target path and checks whether the server correctly rejects or safely handles the race. Servers that expose both a read tool and a write tool on the same directory receive elevated TOCTOU risk scoring on the Permissions axis. Run a free audit at skillaudit.dev to check your file-access MCP tools for TOCTOU vulnerabilities.
Related: state manipulation security, use-after-free, permissions checklist.