GitHub Advisoryadvisory · PyPI
GHSA-pv2j-rghr-v5r9 PraisonAI: execute_code sandbox bypass: str.format C-level attribute access reads every blocklisted dunder
MEDIUM
Official page ↗
- Aliases
- CVE-2026-57120
- Ecosystem
- PyPI
- Packages
- praisonaiagents
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
- Weaknesses
- CWE-693
- Reviewed
- 2026-06-18
Affected packages
• PyPI praisonaiagents: < 1.6.59 → fixed in 1.6.59
Description
Summary
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:
1. 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.
2. C-level attribute access via format-spec. `str.format` / `str.format_map` resolve dotted field references through CPython's internal `PyObject_GetAttr` (`do_string_format` → `get_field`). This C path never consults the Python-level `getattr` binding. The sandbox's `_safe_getattr` wrapper (`python_tools.py:221`) is installed only as the `getattr` name in `safe_builtins`, so any C-level attribute access — including format-spec field resolution — sidesteps it. `format`/`format_map` are 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
```python
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=` / `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
1. Add `format`, `format_map` to `_SANDBOX_BLOCKED_CALLS` (blocks the calls at the AST layer; cost: also blocks benign `str.format`).
2. Or replace `str` in `safe_builtins` with a subclass whose `format`/`format_map` reject dotted fields resolving to leading-underscore attributes (preserves benign formatting).
3. 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 — [email protected] — PGP on request. Coordinated disclosure; no public posting.
Related
Alias · CVE (1)
- CVE-2026-57120 PraisonAI is a multi-agent teams system. vulnerability
Weakness · CWE (1)
- CWE-693 Protection Mechanism Failure weakness