My MCP Security Scanner Missed 2026's Worst MCP RCE: Here Is the One-Rule Fix
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:
┌─────────────────────────────────────────────┐
│ 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:
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:
# 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:
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:
# 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)
$ 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 matchesw,a,xmodes (with optionalb) as an additional signal, and duplicate matches againstPY_OPENare harmless becausesinksis a tuple checked in order withnext().- 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
- 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.
- 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.
- Reuse your detection pipeline (
INTERPandTRAVERSALhere) instead of writing a parallel rule file. This keeps the surface area small and makes it easier to keep both sink families in sync. - 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. Discussion happens there.
Frequently asked questions
- 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.
About the author
Security Engineer & Technical Writer
Security engineer and technical writer based in Batam, Indonesia. I review and harden MCP servers and AI agents, build security automation, and write up what holds and what fails. I also train on DNS security as a PANDI .id Academy trainer.
Related articles
-
10 min read
I Traced 29 CVEs in One MCP Server to 4 Root Causes
29 mcp-atlassian CVEs traced to 4 root causes: DNS rebinding, credential fallback, tool allowlist and file_path. The safe version floor is 0.23.1, not 0.22.0.
- MCP Security
- Security Engineering
-
10 min read
MCP Servers Had a Rough 48 Hours: 4 Unauthenticated CVEs
Four MCP servers published unauthenticated CVEs in about 35 hours, plus LiteLLM's KEV-listed bypass. Fixed versions and a checklist to audit your MCP servers.
- MCP Security
- Security Engineering
-
4 min read
My Scanner Passed Until I Built a Harness That Lied to It on Purpose
I built a harness of synthetic attack fixtures to test my own scanner, mcpscan: 6 of 7 fired, and the miss, a leaked key in a JSON config, taught me the most.
- MCP Security
- Security Engineering
All writing | RSS | Markdown