MCP servers v2.4.0 — Symlink TOCTOU path confinement — What does it mean?
The news. On November 11, 2026, the Model Context Protocol project released servers v2.4.0, fixing six vulnerabilities across the reference filesystem, Git and fetch servers. Two of them are related races in the filesystem server: the read tools, including
read_text_file, and the source side ofmove_fileeach checked a path and then used it by name, so a directory swapped for a symlink in the gap could reach outside the allowed directories. Both are fixed in@modelcontextprotocol/server-filesystem2.3.0. Read the release →
Picture the hotel desk again. The clerk reads your slip, sees room 4B, and confirms that floor 4 is a guest floor. Then you walk off alone and follow the hallway signs. The check was about the number on the slip; the walk is about whatever the signs say now. If someone turns one sign between the desk and the corridor, you arrive at the manager's office holding a slip that was perfectly valid.
The filesystem server worked the same way. validatePath resolved the requested path with realpath, confirmed the result sat under an allowed directory, and returned a string. The read tool then passed that string to fs.readFile, and the kernel resolved every component of it again. A process with write access inside the allowed tree can rename a directory there and put a symlink to outside/ in its place. If the swap lands between the two resolutions, the read follows the new link. This is the classic shape of a tool as attack surface: the agent never misbehaved, the boundary did.
The obvious patch is to check twice, and the advisory rules it out: a second path-based realpath check leaves another window, because the racer can swap back in time. The only fix that closes the gap is to stop handing the file system a name to resolve later.
Version 2.3.0 gives the guest a key instead of directions. The server enters the checked directory, confirms it is standing in the directory it validated, opens only the last name relative to it, and then reads through the descriptor. Node.js has no openat(), so the server uses chdir(), which moves the process's working directory, as the anchor: the working directory holds the directory's inode, so renaming the path afterwards cannot move it. The open goes through the same helper copy_file already used: the entry is checked with lstat (which describes a link itself instead of following it), opened with O_NOFOLLOW, and verified to be the same file. A descriptor stays bound to the file it opened, so the read is anchored however long it takes. The writes had been anchored this way since the 2.0.1 fix for an earlier advisory; reads were the half that was left.
move_file needed one more step, because rename() takes two paths and only one of them can be anchored per call. The server first renames the source to a fresh random name inside its own checked directory, moves only that name across, and checks that it arrived by identity. A directory swapped on the unanchored side now leads to a name nothing holds, so the rename fails instead of pulling an outside file into the allowed tree.
| Approach | What the check binds to | Race window | Source |
|---|---|---|---|
| Check the path, then read by name (2.1.0 reads) | A path string | Open: every component resolves again at read time | GHSA-p9cv-8g75-4xcg |
| Check the path twice | A path string, twice | Still open: the racer swaps back before the read | GHSA-p9cv-8g75-4xcg |
Anchor at the checked directory, open the last name with O_NOFOLLOW, read the descriptor (2.3.0, Linux and macOS) | The directory's inode, then the opened file | Closed | v2.4.0 release |
| Same anchor on Windows | A working-directory string, plus an lstat identity check | Narrowed, not closed | v2.4.0 release |
Put numbers on how fast this goes wrong. The reporter ran the published 2.1.0 server as one Linux user and an attacker client plus a swapping process as another, with a random secret in a 0700 directory the attacker could not read directly. In one run, 13 of 200 read calls returned the secret, about 6.5%, while the same test without the swapping process leaked 0 of 500. Across the four two-user runs, leaks ranged from 3 to 25 of 200. A single call is unreliable, but an attacker does not need one: (illustrative, treating each call as independent at 6.5%) the chance that 100 calls all miss is 0.935100 ≈ 0.1%, so a loop of 100 reads returns the file with roughly 99.9% probability. The advisory rates the read bug medium severity, with a suggested CVSS 4.4 on the standard 0 to 10 severity scale, because exploitation needs a cooperating local process with write access inside an allowed directory; a shared workspace or multi-agent setup can meet that condition.
The fix also shows how a guard should behave when it cannot be sure. Where the server cannot make the check bind to the file, it refuses rather than guesses: a FIFO, socket or device is now rejected instead of read, and the release notes say plainly that on Windows, where the working directory is only a string, the anchor narrows the race without closing it. That is the fail-safe side of a guardrail: state the residual risk, and close the default. It is also a reminder that capability scoping is only as strong as the code that enforces it; "restricted to allowed directories" was a correct policy enforced at the wrong moment.
Goes deeper in: AI Agents → Tool Use & Function Calling → Tools as Attack Surface