A security flaw has been discovered in a piece of software called Flux159 mcp-game-asset-gen, which is used to convert images into 3D game assets. The problem is surprisingly simple: the software doesn't properly check where files are being saved, which means an attacker could trick it into writing files anywhere on a computer instead of just in the folder it's supposed to use.
Think of it like a filing clerk who's supposed to put documents in a specific cabinet, but doesn't check the folder label. If you hand them a misdirected label, they might file something in your private records instead.
Here's why this matters: if someone exploits this flaw, they could potentially inject malicious code onto a computer, steal sensitive files, or corrupt important data. Game developers and anyone running this software as part of their workflow are most at risk, especially small studios or individuals who might not have robust security protections in place.
The good news is that security researchers haven't found anyone actively exploiting this yet, so it's not an emergency. But the window to patch is important.
If you use this software, here's what to do: First, check if there's an updated version available from the developers and install it immediately. Second, only download and use this software from the official source, not from random websites. Third, if you can't update right away, isolate the computer running this software from your main network — treat it like a quarantine zone until it's patched. Finally, keep an eye on any files the software creates and verify they're in the right places. Most importantly, don't ignore software updates, even for tools that seem minor.
Want the full technical analysis? Click "Technical" above.
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)
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.