Vulnerability PYSEC-2026-3807
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()inexcept 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.
Related Vulnerabilities
Other vulnerabilities affecting the same packages