My MCP Security Scanner Missed 2026's Worst MCP RCE: Here Is the One-Rule Fix

dev.to

The hook

A few months back I shipped mcpscan, a static analyzer that scans MCP (Model Context Protocol) servers for the vulnerability classes that keep showing up in this ecosystem: command injection, SSRF, and path traversal. Rule MCP007 was supposed to be the path traversal catch-all.

This week I sat down with my own research notes and ran a simple gut-check: would MCP007 have caught the four real path-traversal CVEs disclosed against MCP servers this year?

It would have missed every single one. Including the worst one.

Real-world context

Here is what actually shipped as CVEs in 2026, all in MCP servers, all sharing the same root cause:

CVE Server Sink Impact
CVE-2026-40576 excel-mcp-server file write Path traversal
CVE-2026-84201 appium-mcp-server write_file Path traversal
CVE-2026-44336 PraisonAI MCP Python .pth write RCE via site-packages injection
CVE-2026-27825 mcp-atlassian confluence_download_attachment CVSS 9.1, unauthenticated RCE (chained with SSRF CVE-2026-27826 to overwrite ~/.ssh/authorized_keys or drop a cron entry)

Four different maintainers, four different tools, the exact same blind spot: a file path built from caller-controlled input, written without a directory-boundary check. The bug in mcp-atlassian is the nastiest: no auth needed, no restart needed, straight to a shell.

So I opened my own rule file and read the docstring out loud:

MCP007: path traversal in file-reading tools.

There it is. My rule was scoped to reads from day one, and every real-world exploit this year happened on the write side. A scanner whose entire job is catching this bug class was structurally blind to the half of it that is actually landing CVSS 9+ scores.

Architecture: how MCP007 actually works

The rules in mcpscan are simple on purpose: line-scan regex matching without an AST, so they run fast across any language mcpscan supports. Each rule has three regex layers:

┌─────────────────────────────────────────────┐
│ 1. SINK: does this line call a              │
│    file-open/read function?                 │
├─────────────────────────────────────────────┤
│ 2. INTERP: is the path argument built       │
│    dynamically (f-string, +,                │
│    .format, os.path.join w/ var)?           │
├─────────────────────────────────────────────┤
│ 3. TRAVERSAL: does a literal ../ token      │
│    sit in the line? -> HIGH                 │
│    else -> MEDIUM                           │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The original sink regex, verbatim from mcpscan/rules/path_traversal.py:

PY_OPEN = re.compile(
    r"\bopen\s*\(|\.read_text\s*\(|\.read_bytes\s*\(|send_file\s*\(|FileResponse\s*\(",
)
JS_OPEN = re.compile(
    r"\bfs\.(?:readFile|readFileSync|createReadStream)\s*\(|\breadFile(?:Sync)?\s*\(",
)
Enter fullscreen mode Exit fullscreen mode

Notice: open( is in there, but Python's open() is also the write sink (open(path, "w")). The regex does not care about mode, so in theory some writes already slip through. But write_text, write_bytes, fs.writeFile, shutil.copy, and os.rename were not matched at all. That is the actual gap that let the shape of CVE-2026-27825 through.

Step-by-step: the fix

Since INTERP and TRAVERSAL already do what is needed (detecting a dynamically-built path and escalating severity when a literal ../ shows up), the fix is additive: a second sink pattern, reusing the same detection pipeline.

1. Add write-sink regexes next to the read ones:

# Write sinks (the CVE-2026-27825 mcp-atlassian shape: attacker path +
# attacker content, written with zero boundary check).
PY_WRITE = re.compile(
    r"\.write_text\s*\(|\.write_bytes\s*\(|\bopen\s*\([^)]*['\"]\s*[wax]b?['\"]|"
    r"shutil\.(?:copy2?|copyfile|move)\s*\(|os\.(?:rename|replace)\s*\(",
)
JS_WRITE = re.compile(
    r"\bfs\.(?:writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream|"
    r"copyFile|copyFileSync|renameSync)\s*\(",
)
Enter fullscreen mode Exit fullscreen mode

2. Check both sink families per line, not just one:

def check(self, files: List[FileInfo]) -> List[Finding]:
    out: List[Finding] = []
    for f in by_kind(files, "source"):
        is_py = f.ext == ".py"
        sinks = (PY_OPEN, PY_WRITE) if is_py else (JS_OPEN, JS_WRITE)
        for i, line in enumerate(f.lines, start=1):
            s = line.strip()
            if s.startswith("#") or s.startswith("//"):
                continue
            hit = next((sink for sink in sinks if sink.search(line)), None)
            if hit is None or not INTERP.search(line):
                continue
            is_write = hit in (PY_WRITE, JS_WRITE)
            sev = Severity.HIGH if (TRAVERSAL.search(line) or is_write) else Severity.MEDIUM
            out.append(self.finding(
                f, i, line,
                title="File path built from untrusted input" +
                      (" (write sink)" if is_write else ""),
                detail="Resolve the path and confirm it stays within an allowed base "
                       "directory (e.g. os.path.realpath + prefix check) before " +
                       ("writing to" if is_write else "opening") + " it.",
                severity=sev,
            ))
    return out
Enter fullscreen mode Exit fullscreen mode

I bumped write-sink hits straight to HIGH even without a literal ../. An attacker-controlled destination path is a worse primitive than an attacker-controlled source path, because the payload usually rides along in the same request (as seen in confluence_download_attachment).

3. Prove it against the real shape. Drop this into a fixture and run the scanner:

# fixtures/confluence_style_write.py
def confluence_download_attachment(filename: str, content: bytes):
    dest = "/data/attachments/" + filename   # <- attacker controls filename
    with open(dest, "wb") as fh:
        fh.write(content)
Enter fullscreen mode Exit fullscreen mode
$ mcpscan scan fixtures/confluence_style_write.py
MCP007  HIGH  fixtures/confluence_style_write.py:5
  File path built from untrusted input (write sink)
  dest = "/data/attachments/" + filename
  -> Resolve the path and confirm it stays within an allowed base directory
    (e.g. os.path.realpath + prefix check) before writing to it.
Enter fullscreen mode Exit fullscreen mode

Before the patch, that fixture produced zero findings. That is the whole bug, in one diff.

Gotchas and edge cases

  • open() mode ambiguity. open(path, "r+") is technically read and write. I did not try to parse complex mode strings. The regex also matches w, a, x modes (with optional b) as an additional signal, and duplicate matches against PY_OPEN are harmless because sinks is a tuple checked in order with next().
  • False positives on legitimate atomic writes. Code that runs tmp = path + ".tmp"; open(tmp, "w") will now flag, correctly. That is still an unvalidated destination path even if it is only a tempfile suffix. Do not suppress this; validate the base path instead.
  • Severity inflation. Escalating all write-sink hits to HIGH (not just ones with a literal ../) will produce more HIGH findings than before. That is intentional given the CVE data. If you fork this, expect your triage backlog to grow, which is the point.
  • Regex limitations. It still will not catch a path built three functions away from the sink. That is a real limitation of the entire rule, not something this patch fixes. It is worth noting in your own documentation so users do not place blind trust in a clean scan.

Actionable takeaways

  1. If you maintain a security scanner, periodically re-run it against your own list of recent real CVEs. Rules drift stale silently: nothing breaks, it just quietly stops catching the vulnerabilities that matter.
  2. When a rule docstring says "read" and the threat landscape has moved to "write", that is a signal, not a footnote. Read your own comments critically.
  3. Reuse your detection pipeline (INTERP and TRAVERSAL here) instead of writing a parallel rule file. This keeps the surface area small and makes it easier to keep both sink families in sync.
  4. Write-sink path traversal deserves a harder default severity than read-sink. An attacker-controlled destination with attacker-controlled content is a strictly worse primitive than an attacker-controlled source.

The patch above is about 15 lines. The gap it closes took four independent CVEs and one CVSS 9.1 unauthenticated RCE chain to surface. Check your own blind spots before someone else's CVE does it for you.

Source: dev.to

arrow_back Back to News