PraisonAI MCP `tools/call` path-traversal => RCE via Python `.pth` injection
Summary
PraisonAI's MCP (Model Context Protocol) server (praisonai mcp serve) registers four file-handling tools by default — praisonai.rules.create, praisonai.rules.show, praisonai.rules.delete, and praisonai.workflow.show. Each accepts a path or filename string from MCP tools/call arguments and joins it onto ~/.praison/rules/ (or, for workflow.show, accepts an absolute path) with no containment check. The JSON-RPC dispatcher passes params["arguments"] blind to each handler via **kwargs without validating against the advertised input schema.
By setting rule_name="../../" an attacker walks out of the rules directory and writes any file the running user can write. Dropping a Python .pth file into the user site-packages directory escalates this primitive to arbitrary code execution in any subsequent Python process the user spawns — the next praisonai CLI invocation, an IDE script run, the user's python REPL, or any background Python service. The same primitive is reachable from:
- An MCP-connected LLM (Claude Desktop, Cursor, Continue.dev, Claude Code) whose context is poisoned by attacker-controlled web content / documents / emails — no operator click required beyond ordinary "ask the LLM to summarise this page" usage.
praisonai mcp serve --transport http-streamwith no--api-key(default), reachable from any local process / DNS-rebound browser tab / container neighbour sharing loopback.- Stdio MCP from any prompt-injection vector that reaches the connected LLM.
No operator misconfiguration is required. No env var, flag, or config switch disables the vulnerable handlers.
Details
1. The dispatcher accepts unvalidated kwargs
src/praisonai/praisonai/mcp_server/server.py:281-298:
async def _handle_tools_call(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Handle tools/call request."""
tool_name = params.get("name")
arguments = params.get("arguments", {})
if not tool_name:
raise ValueError("Tool name required")
tool = self._tool_registry.get(tool_name)
if tool is None:
raise ValueError(f"Tool not found: {tool_name}")
# Execute tool
try:
if asyncio.iscoroutinefunction(tool.handler):
result = await tool.handler(**arguments) # ← no schema enforcement
else:
result = tool.handler(**arguments)
tool.input_schema is built reflectively from the handler signature in registry.py:320-376 and surfaced in tools/list responses — but it is never enforced before dispatch. Whatever JSON shape the MCP client (or an LLM under prompt injection) sends becomes a **kwargs call.
2. The four registered handlers have no containment
src/praisonai/praisonai/mcp_server/adapters/cli_tools.py:
# line 116-128 — rules.create — primary write primitive
@register_tool("praisonai.rules.create")
def rules_create(rule_name: str, content: str) -> str:
"""Create a new rule."""
try:
import os
rules_dir = os.path.expanduser("~/.praison/rules")
os.makedirs(rules_dir, exist_ok=True)
rule_path = os.path.join(rules_dir, rule_name) # ← no realpath/containment
with open(rule_path, 'w') as f:
f.write(content)
return f"Rule created: {rule_name}"
except Exception as e:
return f"Error: {e}"
# line 102-114 — rules.show — read primitive (f-string interpolation, same vuln class)
@register_tool("praisonai.rules.show")
def rules_show(rule_name: str) -> str:
"""Show a specific rule."""
try:
import os
rule_path = os.path.expanduser(f"~/.praison/rules/{rule_name}") # ← `..` works
if not os.path.exists(rule_path):
return f"Rule not found: {rule_name}"
with open(rule_path, 'r') as f:
content = f.read()
return content
except Exception as e:
return f"Error: {e}"
# line 130-141 — rules.delete — delete primitive
@register_tool("praisonai.rules.delete")
def rules_delete(rule_name: str) -> str:
"""Delete a rule."""
try:
import os
rule_path = os.path.expanduser(f"~/.praison/rules/{rule_name}") # ← same pattern
if not os.path.exists(rule_path):
return f"Rule not found: {rule_name}"
os.remove(rule_path)
return f"Rule deleted: {rule_name}"
except Exception as e:
return f"Error: {e}"
# line 63-73 — workflow.show — absolute-path read primitive (no traversal needed)
@register_tool("praisonai.workflow.show")
def workflow_show(file_path: str) -> str:
"""Show workflow configuration."""
try:
with open(file_path, 'r') as f: # ← absolute path, no validation
content = f.read()
return content
except FileNotFoundError:
return f"File not found: {file_path}"
except Exception as e:
return f"Error: {e}"
os.path.join(rules_dir, "../../somewhere") and os.path.expanduser(f"~/.praison/rules/../../somewhere") both resolve .. segments at open() time, so the on-disk effect escapes the rules directory. workflow.show does not need traversal at all — it open()s an absolute path the LLM supplied.
3. Default registration ships these unconditionally
src/praisonai/praisonai/mcp_server/cli.py:216-219 (cmd_serve):
from .adapters import register_all
register_all()
src/praisonai/praisonai/mcp_server/adapters/__init__.py:33-39:
def _register_all():
register_all_tools()
register_extended_capability_tools()
register_cli_tools() # ← rules.create / rules.show / rules.delete / workflow.show
register_mcp_resources()
register_mcp_prompts()
There is no flag, env var, or config switch that disables the file primitives. praisonai mcp serve registers them on every startup.
4. HTTP-stream transport defaults to no authentication
src/praisonai/praisonai/mcp_server/cli.py:184:
parser.add_argument("--api-key", default=None)
The auth check at mcp_server/transports/http_stream.py:191-198 is wrapped in if self.api_key: — None skips the entire block. Default config: praisonai mcp serve --transport http-stream binds 127.0.0.1:8080/mcp unauthenticated.
5. Code-execution escalation via Python .pth
CPython's Lib/site.py (addsitedir / addpackage) imports lines starting with import from every .pth file present in site.getsitepackages() and site.getusersitepackages() at every interpreter startup. The user site-packages directory is always writable without elevation. A single .pth file containing import os; os.system("...") turns the path-traversal write primitive into RCE on the next Python interpreter the user starts — including the user's own python REPL, the next praisonai CLI command, IDE script launchers, and any background Python service.
Suggested fix
-
Containment in every cli_tools handler. Replace bare
os.path.join/ f-string interpolation with explicit prefix validation:import re from pathlib import Path if not re.fullmatch(r"[A-Za-z0-9._-]+", rule_name): return "Error: invalid rule name" rules_dir = Path(os.path.expanduser("~/.praison/rules")).resolve() rule_path = (rules_dir / rule_name).resolve() if not str(rule_path).startswith(str(rules_dir) + os.sep): return "Error: rule_name escapes rules directory"Apply identically to
praisonai.rules.create,rules.show,rules.delete,workflow.validate. Forworkflow.show, restrictfile_pathto a designated workflow directory and reject absolute paths or any value containing... -
Schema enforcement in the dispatcher. Validate
params["arguments"]againsttool.input_schema(a JSON-Schema validator such asjsonschema) beforetool.handler(**arguments). Reject unknown properties, type mismatches, missing required fields. Return JSON-RPC-32602 Invalid params. -
Reduce the default tool surface. Move
rules.*andworkflow.showbehind an explicit--enable-fs-toolsopt-in. Theregister_allhelper should only register read-only safe tools by default. -
Require auth on non-loopback HTTP-stream binds.
praisonai mcp serve --transport http-streamshould refuse to start withhost != 127.0.0.1if--api-keyis unset (mirror the gateway'sassert_external_bind_safefromsrc/praisonai/praisonai/gateway/auth.py:23-54).
PoC
Tested against the PraisonAI repository at HEAD as of 2026-05-02. Verified on Python 3.14 / Windows 11 with both packages installed in editable mode. Each invocation of the RCE chain produced a fresh PID for the spawned Python process — confirmed across four successive runs (PIDs 8172, 23412, 10016, 17912) — proving the payload genuinely runs in a new interpreter, not residual state.
Reproduction prerequisites
- Python ≥ 3.10 (3.14 used during verification).
- A clean clone of the PraisonAI repository:
git clone https://github.com/MervinPraison/PraisonAI.git cd PraisonAI - Install both packages in editable mode:
pip install -e src/praisonai-agents -e src/praisonai - For PoC #3 (HTTP-stream variant):
pip install uvicorn starlette(already pulled in bypraisonai[api]). - All other PoCs run against the package source alone — no network server required.
PoC 1 — In-process file primitives via MCP tools/call
Confirms arbitrary file READ, path-traversal WRITE, and path-traversal READ-BACK without spinning up a network server. Equivalent to electerm's parser dry-run; runs against the package source alone.
cat > /tmp/poc01_primitives.py 'owned-by-poc'
[A4] sending undeclared kwarg to confirm dispatcher accepts it
[A4] response (TypeError raised by handler, NOT by dispatcher): Error: register_cli_tools..workflow_show() got an unexpected keyword argument 'undeclared_kwarg'
PoC 2 — RCE escalation via Python .pth
Drops a Python .pth payload into the user site-packages directory using the path-traversal write from PoC #1, then spawns an unrelated python -c "pass" to demonstrate that the payload runs in a fresh interpreter.
cat > /tmp/poc02_rce.py