GHSA-PV2J-RGHR-V5R9
Vulnerability from github – Published: 2026-06-18 13:55 – Updated: 2026-06-18 13:55Summary
The execute_code tool's subprocess sandbox advertises a three-layer defense (AST validation, text-pattern blocklist, restricted __builtins__). In sandbox mode (the default) only two layers are active — the text-pattern blocklist is skipped — and both remaining layers are bypassed by combining two CPython semantics:
- Runtime string assembly. The AST validator (
src/praisonai-agents/praisonaiagents/tools/python_tools.py:75) enumerates blocked dunder names againstast.Attribute.attr,ast.Call.func.id, andast.Constantstring-substring. Names assembled at runtime (e.g."_"*2 + "class" + "_"*2) appear in the AST as multiple shortast.Constantnodes, none containing a blocked substring, so the static check passes. - C-level attribute access via format-spec.
str.format/str.format_mapresolve dotted field references through CPython's internalPyObject_GetAttr(do_string_format→get_field). This C path never consults the Python-levelgetattrbinding. The sandbox's_safe_getattrwrapper (python_tools.py:221) is installed only as thegetattrname insafe_builtins, so any C-level attribute access — including format-spec field resolution — sidesteps it.format/format_mapare also absent from_SANDBOX_BLOCKED_CALLS(python_tools.py:56).
Combined, this yields an arbitrary read primitive over every blocklisted attribute (__class__, __qualname__, __bases__, __base__, function __globals__, __dict__, …).
Affected code
| File | Lines | Symbol | Role |
|---|---|---|---|
src/praisonai-agents/praisonaiagents/tools/python_tools.py |
39–54 | _SANDBOX_BLOCKED_ATTRS |
The blocklist the bypass renders unreachable |
src/praisonai-agents/praisonaiagents/tools/python_tools.py |
56–60 | _SANDBOX_BLOCKED_CALLS |
Missing entries: format, format_map |
src/praisonai-agents/praisonaiagents/tools/python_tools.py |
75–102 | _validate_code_ast |
Static check, blind to runtime string assembly |
src/praisonai-agents/praisonaiagents/tools/python_tools.py |
221–226 | _safe_getattr |
Wraps Python-level getattr only; C-level access bypasses |
src/praisonai-agents/praisonaiagents/tools/python_tools.py |
352 | execute_code |
Entry point; gated by @require_approval(risk_level="critical") |
Reproducer
import os
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
from praisonaiagents.tools.python_tools import execute_code
payload = '''
und = "_" * 2 # "__" assembled at runtime
key1 = und + "class" + und # "__class__"
key2 = und + "qualname" + und # "__qualname__"
fmt_class = "{0." + key1 + "}"
fmt_qual2 = "{0." + key1 + "." + key2 + "}"
print("LEAK_CLASS=" + fmt_class.format(()))
print("LEAK_QUAL2=" + fmt_qual2.format(()))
'''
print(execute_code(payload, sandbox_mode="sandbox", timeout=15))
Observed: stdout = LEAK_CLASS=<class 'tuple'> / LEAK_QUAL2=tuple, success: true, no security error. Both __class__ (one hop) and __class__.__qualname__ (two hops) — all blocklisted — are read.
Trust boundary
The @require_approval(risk_level="critical") gate is bypassed when PRAISONAI_AUTO_APPROVE is set (verified: require_approval short-circuits on is_env_auto_approve()). That variable is set by the project's FULL_AUTO autonomy mode, the bots-CLI launchers, and the project's own issue-triage CI workflow — postures where the agent reaches execute_code with no human approval. The payload then arrives via any LLM-visible surface (user message, retrieved document, tool/web/MCP output) and the tool-call machinery passes it as the code argument.
Relationship to GHSA-4mr5-g6f9-cfrh
The code's own comment at python_tools.py:46 cites GHSA-4mr5-g6f9-cfrh, which added __self__ to the blocklist to stop C-builtins leaking builtins via func.__self__. This finding does not bypass that single entry — it bypasses the entire blocklist, because format-spec attribute resolution never consults the blocklist or _safe_getattr. "{0.__self__}".format(print) would leak __self__ regardless of the blocklist. Same defense surface, different mechanism; the GHSA-4mr5 fix does not mitigate this.
Scope (read primitive only)
This reports the read primitive. Turning the read into in-process execution requires a callable bridge; the obvious one (string.Formatter().get_field() returning the live object) is not directly reachable because import string is blocked at the AST layer (no ast.Import). Other bridges may exist; a full execution chain is not claimed here. If one is found, severity rises to ~8.8 (the subprocess has no seccomp/setrlimit/syscall filtering).
Suggested fix
- Add
format,format_mapto_SANDBOX_BLOCKED_CALLS(blocks the calls at the AST layer; cost: also blocks benignstr.format). - Or replace
strinsafe_builtinswith a subclass whoseformat/format_mapreject dotted fields resolving to leading-underscore attributes (preserves benign formatting). - Or drop sandbox-mode's in-process security claim and document that real isolation requires external sandboxing (gVisor/firejail/container/microVM) — which matches what the subprocess provides today.
The text-pattern blocklist present in the direct path (python_tools.py:487-502) is absent from the sandbox path; even if added, the runtime-assembly trick defeats it, so (1) or (2) is required.
Reporter: Kai Aizen / SnailSploit — kai@snailsploit.com — PGP on request. Coordinated disclosure; no public posting.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "praisonaiagents"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.6.59"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-693"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-18T13:55:29Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nThe `execute_code` tool\u0027s subprocess sandbox advertises a three-layer defense (AST validation, text-pattern blocklist, restricted `__builtins__`). In **sandbox mode** (the default) only two layers are active \u2014 the text-pattern blocklist is skipped \u2014 and both remaining layers are bypassed by combining two CPython semantics:\n\n1. **Runtime string assembly.** The AST validator (`src/praisonai-agents/praisonaiagents/tools/python_tools.py:75`) enumerates blocked dunder names against `ast.Attribute.attr`, `ast.Call.func.id`, and `ast.Constant` string-substring. Names assembled at runtime (e.g. `\"_\"*2 + \"class\" + \"_\"*2`) appear in the AST as multiple short `ast.Constant` nodes, none containing a blocked substring, so the static check passes.\n2. **C-level attribute access via format-spec.** `str.format` / `str.format_map` resolve dotted field references through CPython\u0027s internal `PyObject_GetAttr` (`do_string_format` \u2192 `get_field`). This C path never consults the Python-level `getattr` binding. The sandbox\u0027s `_safe_getattr` wrapper (`python_tools.py:221`) is installed only as the `getattr` name in `safe_builtins`, so any C-level attribute access \u2014 including format-spec field resolution \u2014 sidesteps it. `format`/`format_map` are also absent from `_SANDBOX_BLOCKED_CALLS` (`python_tools.py:56`).\n\nCombined, this yields an arbitrary read primitive over every blocklisted attribute (`__class__`, `__qualname__`, `__bases__`, `__base__`, function `__globals__`, `__dict__`, \u2026).\n\n## Affected code\n\n| File | Lines | Symbol | Role |\n|---|---|---|---|\n| `src/praisonai-agents/praisonaiagents/tools/python_tools.py` | 39\u201354 | `_SANDBOX_BLOCKED_ATTRS` | The blocklist the bypass renders unreachable |\n| `src/praisonai-agents/praisonaiagents/tools/python_tools.py` | 56\u201360 | `_SANDBOX_BLOCKED_CALLS` | Missing entries: `format`, `format_map` |\n| `src/praisonai-agents/praisonaiagents/tools/python_tools.py` | 75\u2013102 | `_validate_code_ast` | Static check, blind to runtime string assembly |\n| `src/praisonai-agents/praisonaiagents/tools/python_tools.py` | 221\u2013226 | `_safe_getattr` | Wraps Python-level `getattr` only; C-level access bypasses |\n| `src/praisonai-agents/praisonaiagents/tools/python_tools.py` | 352 | `execute_code` | Entry point; gated by `@require_approval(risk_level=\"critical\")` |\n\n## Reproducer\n\n```python\nimport os\nos.environ[\"PRAISONAI_AUTO_APPROVE\"] = \"true\"\nfrom praisonaiagents.tools.python_tools import execute_code\n\npayload = \u0027\u0027\u0027\nund = \"_\" * 2 # \"__\" assembled at runtime\nkey1 = und + \"class\" + und # \"__class__\"\nkey2 = und + \"qualname\" + und # \"__qualname__\"\nfmt_class = \"{0.\" + key1 + \"}\"\nfmt_qual2 = \"{0.\" + key1 + \".\" + key2 + \"}\"\nprint(\"LEAK_CLASS=\" + fmt_class.format(()))\nprint(\"LEAK_QUAL2=\" + fmt_qual2.format(()))\n\u0027\u0027\u0027\nprint(execute_code(payload, sandbox_mode=\"sandbox\", timeout=15))\n```\n\nObserved: `stdout` = `LEAK_CLASS=\u003cclass \u0027tuple\u0027\u003e` / `LEAK_QUAL2=tuple`, `success: true`, no security error. Both `__class__` (one hop) and `__class__.__qualname__` (two hops) \u2014 all blocklisted \u2014 are read.\n\n## Trust boundary\n\nThe `@require_approval(risk_level=\"critical\")` gate is bypassed when `PRAISONAI_AUTO_APPROVE` is set (verified: `require_approval` short-circuits on `is_env_auto_approve()`). That variable is set by the project\u0027s FULL_AUTO autonomy mode, the bots-CLI launchers, and the project\u0027s own issue-triage CI workflow \u2014 postures where the agent reaches `execute_code` with no human approval. The payload then arrives via any LLM-visible surface (user message, retrieved document, tool/web/MCP output) and the tool-call machinery passes it as the `code` argument.\n\n## Relationship to GHSA-4mr5-g6f9-cfrh\n\nThe code\u0027s own comment at `python_tools.py:46` cites GHSA-4mr5-g6f9-cfrh, which added `__self__` to the blocklist to stop C-builtins leaking `builtins` via `func.__self__`. This finding does not bypass that single entry \u2014 it bypasses the **entire** blocklist, because format-spec attribute resolution never consults the blocklist or `_safe_getattr`. `\"{0.__self__}\".format(print)` would leak `__self__` regardless of the blocklist. Same defense surface, different mechanism; the GHSA-4mr5 fix does not mitigate this.\n\n## Scope (read primitive only)\n\nThis reports the **read primitive**. Turning the read into in-process execution requires a callable bridge; the obvious one (`string.Formatter().get_field()` returning the live object) is not directly reachable because `import string` is blocked at the AST layer (no `ast.Import`). Other bridges may exist; a full execution chain is **not** claimed here. If one is found, severity rises to ~8.8 (the subprocess has no seccomp/`setrlimit`/syscall filtering).\n\n## Suggested fix\n\n1. Add `format`, `format_map` to `_SANDBOX_BLOCKED_CALLS` (blocks the calls at the AST layer; cost: also blocks benign `str.format`).\n2. Or replace `str` in `safe_builtins` with a subclass whose `format`/`format_map` reject dotted fields resolving to leading-underscore attributes (preserves benign formatting).\n3. Or drop sandbox-mode\u0027s in-process security claim and document that real isolation requires external sandboxing (gVisor/firejail/container/microVM) \u2014 which matches what the subprocess provides today.\n\nThe text-pattern blocklist present in the `direct` path (`python_tools.py:487-502`) is absent from the sandbox path; even if added, the runtime-assembly trick defeats it, so (1) or (2) is required.\n\nReporter: Kai Aizen / SnailSploit \u2014 kai@snailsploit.com \u2014 PGP on request. Coordinated disclosure; no public posting.",
"id": "GHSA-pv2j-rghr-v5r9",
"modified": "2026-06-18T13:55:29Z",
"published": "2026-06-18T13:55:29Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-pv2j-rghr-v5r9"
},
{
"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:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "PraisonAI: execute_code sandbox bypass: str.format C-level attribute access reads every blocklisted dunder"
}
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.