Vulnerability PYSEC-2026-3807

Medium Risk
MEDIUM RISK
CVSS Score: 5.0
Score Range: 4.0–6.9
Medium severity vulnerabilities (CVSS 4.0–6.9). Important issues that meaningfully reduce security confidence.
1 month ago
September 10, 2026 at 09:44 AM UTC
asteval has a Sandbox Escape via BaseException Subclasses
0.9 - 1.0.8
0.9 - 1.0.8

Summary

asteval has a Sandbox Escape via BaseException Subclasses

Details

Summary

An attacker who can supply expressions to asteval.Interpreter.eval() can raise SystemExit, KeyboardInterrupt, GeneratorExit, or BaseException from inside the sandbox. These exceptions are subclasses of BaseException but not Exception, so they bypass the except Exception: safety net in both run() and eval(). The exception propagates verbatim to the calling application, terminating the process or disrupting signal and cleanup handlers.

This is distinct from prior vulnerabilities CVE-2025-24359 (format string injection) and GHSA-vp47-9734-prjw (AST mutation TOCTOU), both fixed in 1.0.6. This vector is present in all versions including 1.0.6 and current HEAD.

Root Cause

Python's exception hierarchy has two distinct branches under BaseException:

BaseException
├── SystemExit          ← NOT caught by except Exception:
├── KeyboardInterrupt   ← NOT caught by except Exception:
├── GeneratorExit       ← NOT caught by except Exception:
└── Exception           ← caught normally
    ├── RuntimeError
    ├── ValueError
    └── ...

FROM_PY exposes all four non-Exception classes to sandbox users. When a user writes raise SystemExit("msg"), the on_raise() handler calls:

self.raise_exception(None, exc=out.__class__, msg=msg, expr='')

which executes raise SystemExit(msg). This propagates through both except Exception: guards unchecked and surfaces in the calling application.

Impact

Variant Impact
SystemExit Process terminates; exit code and message attacker-controlled
KeyboardInterrupt Disrupts finally blocks, signal handlers, and KeyboardInterrupt-aware loops
GeneratorExit Disrupts generator cleanup in calling code
BaseException Generic escape, same propagation

Any application that:

  • Accepts user-supplied expressions via asteval
  • Relies on except Exception: at the top level (standard practice)
  • Does not wrap aeval.eval() in except BaseException: (non-standard, unexpected requirement)

...is vulnerable to attacker-triggered process termination (DoS).

CVSS breakdown: Network-reachable (AV:N), no special conditions (AC:L), no credentials (PR:N), no interaction (UI:N), scope unchanged (S:U), no confidentiality/integrity impact (C:N/I:N), high availability impact — process termination (A:H).

Recommended Fix

Option A — Remove dangerous classes from FROM_PY (minimal, preferred):

# asteval/astutils.py

FROM_PY = ('ArithmeticError', 'AssertionError', 'AttributeError',
           # Remove: 'BaseException',
           'BufferError', 'BytesWarning',
           'DeprecationWarning', 'EOFError', 'EnvironmentError',
           'Exception', 'False', 'FloatingPointError',
           # Remove: 'GeneratorExit',
           'IOError', 'ImportError', 'ImportWarning', 'IndentationError',
           'IndexError', 'KeyError',
           # Remove: 'KeyboardInterrupt',
           'LookupError',
           'MemoryError', 'NameError', 'None',
           'NotImplementedError', 'OSError', 'OverflowError',
           'ReferenceError', 'RuntimeError', 'RuntimeWarning',
           'StopIteration', 'SyntaxError', 'SyntaxWarning', 'SystemError',
           # Remove: 'SystemExit',
           'True', 'TypeError', ...)

Option B — Block non-Exception raises in on_raise():

# asteval/asteval.py

def on_raise(self, node):
    excnode = node.exc
    msgnode = node.cause
    out = self.run(excnode)
    # Prevent BaseException subclasses from escaping the sandbox
    if not issubclass(out.__class__, Exception):
        self.raise_exception(node, exc=RuntimeError,
                             msg=f"raising {out.__class__.__name__!r} is not permitted")
        return
    msg = ' '.join(str(a) for a in out.args)
    msg2 = self.run(msgnode)
    if msg2 not in (None, 'None'):
        msg = f"{msg}: {msg2}"
    self.raise_exception(None, exc=out.__class__, msg=msg, expr='')

Note: Option B also fixes a secondary bug on the same line — ' '.join(out.args) crashes with TypeError when args contain non-strings (e.g., raise SystemExit(0) with integer code). The fix uses str(a) for a in out.args.

Option C — Catch BaseException in run() and eval() (broadest, requires care):

except BaseException as exc:
    if isinstance(exc, (SystemExit, KeyboardInterrupt, GeneratorExit)):
        # Re-raise as RuntimeError to contain within sandbox
        self.raise_exception(node, exc=RuntimeError,
                             msg=f"{type(exc).__name__} raised in sandbox")
    elif with_raise and self.expr is not None:
        self.raise_exception(node, expr=self.expr)

Option A is the simplest and least likely to introduce regressions. Option B additionally addresses the str.join crash on integer args.

Researcher

Independent security researcher. No bug bounty program exists for this project. CVE assignment requested via GitHub Security Advisory submission.

Impacted packages

Timeline

Published
1 month ago
September 10, 2026 at 09:44 AM UTC
Fixed (1.0.9)
4 months ago
June 11, 2026 at 06:40 PM UTC
Last Modified
1 month ago
September 10, 2026 at 12:15 PM UTC