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

> My mcpscan path traversal rule MCP007 only covered file reads and missed four 2026 MCP CVEs, including a CVSS 9.1 RCE. Here is the write-sink fix.

- URL: https://www.kielltampubolon.id/writing/my-mcp-security-scanner-missed-2026s-worst-mcp-rce-here-is-the-one-rule-fix/
- Author: Kiell Tampubolon
- Published: 2026-09-05
- Last updated: 2026-09-05
- Language: en
- Tags: MCP Security, Security Engineering

## Key takeaways

- Rule MCP007 was scoped to file reads, so it would have missed all four 2026 path traversal CVEs, which happened on the write side.
- The worst was CVE-2026-27825 in mcp-atlassian, scored CVSS 9.1 as an unauthenticated RCE, chained with SSRF CVE-2026-27826.
- The fix adds write-sink regexes such as write_text, fs.writeFile, shutil.copy and os.rename, reusing the existing INTERP and TRAVERSAL detection pipeline.
- Write-sink hits are escalated straight to HIGH even without a literal ../, because an attacker-controlled destination with attacker-controlled content is a worse primitive.
- The regex still will not catch a path built three functions away from the sink, so a clean scan should not be blindly trusted.

## 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:

```plaintext
┌─────────────────────────────────────────────┐
│ 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                           │
└─────────────────────────────────────────────┘
```

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

```python
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*\(",
)
```

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:**

```python
# 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*\(",
)
```

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

```python
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
```

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:

```python
# 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)
```

```bash
$ 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.
```

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.

*Originally published on [DEV](https://dev.to/kielltampubolon/my-mcp-security-scanner-missed-2026s-worst-mcp-rce-here-is-the-one-rule-fix-1g1i). Discussion happens there.*

## FAQ

### What was CVE-2026-27825 in mcp-atlassian?

It is a CVSS 9.1 unauthenticated RCE in the confluence_download_attachment tool. It was chained with SSRF CVE-2026-27826 to overwrite ~/.ssh/authorized_keys or drop a cron entry.

### Why did the mcpscan MCP007 rule miss these MCP path traversal CVEs?

Its docstring and sink regexes were scoped to file reads, while the four real 2026 exploits happened on the write side. Write_text, write_bytes, fs.writeFile, shutil.copy and os.rename were not matched at all.

### How do I detect path traversal in MCP server write operations?

Add write-sink patterns alongside the read ones, check whether the path is built dynamically, and confirm the resolved path stays within an allowed base directory before writing. The article's patch is about 15 lines.
