GHSA-pv2j-rghr-v5r9 PraisonAI: execute_code sandbox bypass: str.format C-level attribute access reads every blocklisted dunder
SummaryThe `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 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.**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")` |ReproducerObserved: `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 boundaryThe `@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-cfrhThe 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 fixAdd `format`, `format_map` to `_SANDBOX_BLOCKED_CALLS` (blocks the calls at the AST layer; cost: also blocks benign `str.format`).Or replace `str` in `safe_builtins` with a subclass whose `format`/`format_map` reject 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.
CVSS v3.1 score: 6.5