Skip to content

fix(security): sandbox sympy expression parsing against arbitrary code execution (#42) - #51

Merged
franciszver merged 5 commits into
mainfrom
fix/p-sec2-parse-expr
Jul 24, 2026
Merged

fix(security): sandbox sympy expression parsing against arbitrary code execution (#42)#51
franciszver merged 5 commits into
mainfrom
fix/p-sec2-parse-expr

Conversation

@franciszver

Copy link
Copy Markdown
Owner

Closes #42

Vulnerability

sympy.parse_expr/sympify compile+eval their string input; with unrestricted globals a string like __import__("os").system(...) runs arbitrary code. Reachable via _canonical_key (model-generated choice text) and the eval graders (fixture/model text); MathGenerator.validate_answer is designed for student input (not handler-wired today, fixed preemptively). Confirmed with a live os.system PoC.

Fix — src/services/practice/safe_expr.py (new), one helper used everywhere

  • Primary control: allowlist namespace. _SAFE_GLOBALS contains only safe math constructors/functions/constants (Symbol, Integer, Rational, Float, sqrt, trig, log, exp, factorial, gcd, …) with __builtins__ stripped. Crucially it does NOT include sympify/S/parse_expr/lambdify — anything that re-parses/compiles a string — so those degrade to inert auto-symbols instead of executing.
  • Defense-in-depth: a minimal deny-list (__, backtick).
  • global_dict/local_dict force-set so a caller can't reintroduce the bypass.
  • All string-parse sites (_canonical_key, _parse_answer, 3 grader sites) rewired; simplify() on live sympy objects left untouched; each site's existing fallback preserved.

Review journey (this is why it took the rounds it did)

  • Round 1 (allowlist-less, import * + deny-list): a fresh review executed a real RCE via sympify("_"+"_"+"imp"+"ort"+…) — the namespace itself contained a re-parsing callable, and a concatenated payload evaded the static deny-list. Green tests missed it.
  • Round 2: rebuilt as the allowlist above. A second adversarial review tried all vectors (esp. the string-accepting Float/Rational/Integer constructors) and could not break it.
  • Round 3: pinned the string-constructor threat model as regression tests.

Verification

  • Full suite: 494 passed, 1 skipped. run_eval: PASS. black/isort/flake8 clean.
  • RCE independently re-verified closed (os.system + subprocess mocked, assert_not_called across all call sites for every payload class); legit math parses identically.

🤖 Generated with Claude Code

https://claude.ai/code/session_016v4C1daTwNakL6QDbmy3MJ

franciszver and others added 5 commits July 23, 2026 23:50
Assisted-by: Claude Code (sonnet subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Assisted-by: Claude Code (sonnet subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Assisted-by: Claude Code (sonnet subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#42)

Assisted-by: Claude Code (sonnet subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on (#42)

Add regression tests ensuring sympy's whitelisted string-accepting constructors
(Float, Rational, Integer, S) cannot execute arbitrary code when fed malicious
string payloads. Tests cover:
- Float/Rational/Integer with lambda/os.system/import payloads
- Payloads with and without __ deny-list substrings
- Mocking os.system and subprocess.Popen to verify no execution
- All entry points: safe_parse_expr, _canonical_key, validate_answer, grader

Test count: +5 parametrized cases across 4 test functions (56 total safety tests).

Assisted-by: Claude Code (haiku subagent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@franciszver
franciszver merged commit 0675d9d into main Jul 24, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P-sec.2 — sympy parse_expr executes arbitrary code on untrusted expression strings

1 participant