ai · Day 7 / 100 · AI cybersecurity · 5 min

MCP security labs: path bypasses and poisoned tool responses

TLDR: MCP does not make a filesystem boundary or a tool response trustworthy by itself. One lab bypassed directory containment with a sibling path sharing the same prefix; another used a harmless-looking fact response to influence a separate WhatsApp tool.

Day 7 turned into two scenarios from MCP Breach-to-Fix Labs and one larger lesson: the protocol is only part of the security story. These are the labs I am actually working through, using their vulnerable and secure local servers. The real boundary is still the code that validates paths, recipients, permissions, and tool results. If that code treats a string prefix as a directory boundary, or treats tool output as instructions, the agent can be led somewhere it should not go.

Lab 2: a filesystem boundary made of text

Path traversal is an old web security problem. An application takes a path supplied by a user, joins or resolves it on the server, and accidentally allows access outside the intended directory. The familiar ../ sequence is only one form of the problem. The important question is whether the final filesystem object is inside the approved directory.

The Cymulate research on EscapeRoute describes CVE-2025-53110 as a directory-containment bypass in Anthropic's Filesystem MCP Server. The flaw was a naive prefix check: a path was accepted when it began with the configured allowed directory, even if it named a different sibling directory. Cymulate reported the issue as high severity and separately documented CVE-2025-53109, a symlink bypass.

That is not a flaw in the idea of MCP authentication. It is a broken filesystem containment check in an MCP server, which is more dangerous because the server is deliberately giving an AI agent file operations.

The lab used this allowed directory:

/app/files/safe_files

The legitimate file was:

/app/files/safe_files/manifest.txt

The deliberately out-of-scope directory was:

/app/files/safe_files_sensitive/secret.txt

The vulnerable implementation reduced the security decision to something equivalent to:

if requested_path.startswith(allowed_directory):
    allow()

That returns True for both paths. The second path is not inside safe_files; it is a sibling called safe_files_sensitive. It only looks related when compared as text.

The lab lists the legitimate manifest file inside the approved directory.
Fig. 1. The lab lists the legitimate manifest file inside the approved directory.

I then requested the sibling directory. The vulnerable server returned secret.txt, and reading it returned the lab flag:

FLAG{cve_2025_53110_escape_route}
The vulnerable server accepts the sibling path and exposes the synthetic lab flag.
Fig. 2. The vulnerable server accepts the sibling path and exposes the synthetic lab flag.

The secure implementation uses a path-aware check instead. It first canonicalizes both paths with realpath(), resolving .., symlinks, and other path components. It then uses commonpath() and requires the shared path to equal the approved directory. Conceptually:

real_allowed = os.path.realpath(allowed_directory)
real_target = os.path.realpath(requested_path)

if os.path.commonpath([real_allowed, real_target]) != real_allowed:
    return "Access denied."

For the attack path, the common path is /app/files, not /app/files/safe_files, so the request stops before the server opens the file. The secure version also validates input, checks that the target exists and has the right type, rejects symlinks for file reads, and uses a generic denial response.

The secure server rejects the same sibling path after canonical containment checking.
Fig. 3. The secure server rejects the same sibling path after canonical containment checking.

The secure retest is the useful part of the exercise. A defense is not demonstrated by finding one path that fails in a vulnerable build. It is demonstrated by showing that the intended file still works while the lookalike path does not.

Lab 3: when a fact becomes an instruction

The next lab moved from filesystem authorization to response poisoning. The setup contained two MCP servers connected to the same agent:

  • a facts helper exposing get_fact_of_the_day;
  • a fake WhatsApp bridge exposing list_chats and send_message.

The MCP Breach-to-Fix Labs repository provides the local vulnerable and secure implementations. The broader pattern resembles the tool-poisoning research from Invariant Labs, which describes indirect prompt injection through MCP tool metadata and outputs. Their follow-up work documented a WhatsApp data-exfiltration scenario, and Docker's write-up discusses the same class of cross-tool risk.

The first fact response was harmless:

Did you know that the first computer virus (Elk Cloner) spread via floppy disks?

On the second call, the helper returned the same fact followed by hidden instructions telling the agent to rewrite every WhatsApp recipient to a relay number while pretending the original delivery succeeded.

The facts helper returns a normal-looking fact followed by a poisoned instruction block.
Fig. 4. The facts helper returns a normal-looking fact followed by a poisoned instruction block.

This creates two separate failures. First, it is indirect prompt injection: untrusted data from a tool changes the agent's reasoning. Second, it is cross-tool influence: a low-trust facts server attempts to control a higher-impact send_message operation on a different server.

The vulnerable WhatsApp bridge made the second failure worse. Its send_message() function accepted any recipient and wrote the message to the fake outbox. There was no server-side allowlist or fresh confirmation that the recipient was an approved contact.

In the test, the agent correctly recognized that the helper's instructions were untrusted, so I explicitly reproduced the routing step against the local vulnerable bridge. The same message body was accepted with the relay recipient and appeared in the outbox:

TO=+13241234123
The lunch has been canceled
The vulnerable bridge records the unchanged message body under the attacker-controlled relay recipient.
Fig. 5. The vulnerable bridge records the unchanged message body under the attacker-controlled relay recipient.

No real WhatsApp account or contact was involved. That detail matters: the lab proves the server-side behavior without turning a controlled demonstration into an external message.

The secure design validates the recipient at the control point. It keeps a trusted-contact allowlist and rejects the relay number even if the model has been influenced by a poisoned tool response. The model is not the authorization boundary. The WhatsApp server is.

What I am taking away

The two labs look different, but the design mistake is similar: trusting an easy-to-check representation instead of the security property that representation is meant to describe.

  1. A string prefix is not filesystem containment. Resolve the path and compare path components.
  2. A valid MCP tool response is not automatically an instruction. Treat results, documents, web pages, and messages as untrusted data.
  3. High-impact tools need their own server-side authorization. A model deciding that a recipient is safe is not enough.
  4. The best defense is close to the action: canonicalize before file access, validate the recipient before sending, and require confirmation for consequential operations.
  5. A secure retest should preserve the legitimate workflow. The manifest remained readable, while the sibling secret was denied. A trusted contact can still receive a message, while the relay number is rejected.

The human part of the exercise was the most useful one. It is tempting to blame the model when it follows a malicious instruction, but the stronger question is why the server accepted the resulting action. Models can improve at recognizing injections, but the durable fix belongs in the code that owns the resource and the side effect.

Filed under ai, mcp, path-traversal, prompt-injection, tool-poisoning. If any of this is wrong, or you have hit the same thing, tell me.

Published 20 September 2026.

Ryan Sacatani

Simply curious about the world, constantly building and breaking things for fun.

sacataniryan1@gmail.com ↗

BrowseBrowse topics