---
title: "GHSA-xmm6-8r3x-j567"
description: "Remote code execution via unsanitized sympy.sympify() on untrusted model output in the math verifier. Affected and fixed versions of qwed."
url: https://qwedai.com/security/advisories/ghsa-xmm6-8r3x-j567
---

# GHSA-xmm6-8r3x-j567

Remote code execution via unsanitized sympy.sympify() on untrusted model output in the math verifier

## Summary

The math verification path in the `qwed_sdk` package passes untrusted model output directly to `sympy.sympify()` without sanitization, resulting in arbitrary code execution. In `qwed_sdk/qwed_local.py`, the helper `_math_answers_match()` (line 306) calls `sympy.sympify(normalized_llm)` on the answer string returned by the language model. `sympify()` parses its input via `parse_expr()`, which evaluates the expression with the full SymPy namespace, so a crafted answer string reaches a Python `eval` sink and runs attacker-chosen commands inside the process. The sink is reachable from the public `QWEDLocal.verify()` and `QWEDLocal.verify_math()` API and from the `qwed verify` command line entry point. This is an independent, unpatched variant of the code-injection class previously disclosed as CVE-2026-55585 (GHSA-q27q-98j4-9pfv); the earlier remediation hardened a different parser in a different module and never touched this code path.

## Root Cause

The product treats language model output as untrusted input (its documented purpose is to verify model output before it is acted upon), yet the answer comparison helper feeds that untrusted string straight into a code-evaluating sink. `sympy.sympify()` is not a safe numeric parser: internally it calls `parse_expr()` which compiles and evaluates the string against the SymPy namespace, and that namespace exposes Python built-ins reachable through object-introspection gadgets. The only check before the sink is an exact-equality short-circuit that any non-numeric payload trivially fails, so no allow-list, character gate, or AST validation constrains what reaches the evaluator.

```text
qwed_sdk/qwed_local.py:297  _math_answers_match(llm_answer, verified_result)
qwed_sdk/qwed_local.py:299  normalized_llm = llm_answer.strip()
qwed_sdk/qwed_local.py:301  if normalized_llm == verified_text: return True   # bypassed by any non-numeric string
qwed_sdk/qwed_local.py:306  llm_expr = sympy.sympify(normalized_llm)          # unsanitized eval sink
```

## Impact

An attacker who can influence the model response obtains arbitrary Python and operating-system command execution in the process that runs the verification SDK, with the full read, write, and execute capability and denial-of-service capability that code execution implies. The realistic attacker positions are: a compromised or man-in-the-middle model endpoint, a malicious or self-hosted model, or prompt injection carried in the user-supplied query, which is interpolated into both model prompts. The attacker must shape two model turns: the answer turn carries the payload, and the expression turn returns a benign allow-listed expression so that the separate expression validator passes and control reaches the vulnerable comparison. A malicious or intercepted model controls both turns directly. Because the payload must transit the model rather than being sent straight to the host, the precondition of controlling or intercepting the model response is a genuine external condition rather than a default-reachable network path.

## Proof of Concept

`Dependencies: an installed checkout of the affected package (so that "import qwed_sdk" resolves) and its pinned dependency sympy 1.14.0. No network, server, authentication, or live model is required for the unit reproduction; the answer string stands in for untrusted model output.`

1.  Import the shipped helper from `qwed_sdk.qwed_local`.
2.  Discover the `catch_warnings` index in the current interpreter's subclass list (as any attacker probing a known build would).
3.  Call the helper with a crafted answer string and any numeric verified result; the answer string is not equal to the number, so control reaches the `sympify()` sink and the embedded command runs.

```python
import os
from qwed_sdk import qwed_local

subclasses = ().__class__.__base__.__subclasses__()
cw = next(i for i, c in enumerate(subclasses) if c.__name__ == "catch_warnings")
malicious_answer = (
    "().__class__.__base__.__subclasses__()[%d]()._module."
    "__builtins__.__getitem__('__import__')('os')."
    "system('touch /tmp/qwed_rce_marker')" % cw
)

qwed_local._math_answers_match(malicious_answer, 4)
print("executed:", os.path.exists("/tmp/qwed_rce_marker"))
```

```text
executed: True
```

An end-to-end reproduction that drives the public `QWEDLocal.verify_math("What is 2 + 2?")` (with only the network model call stubbed) shows the same result: the exploit answer turn creates the marker and returns `verified=False`, while a benign answer of `4` creates no marker and returns `verified=True`, confirming the answer value, not the harness, is the trigger.

## Attack Chain

1.  **Exposure:** The `qwed_sdk` module ships in the distributed wheel and exposes the public `QWEDLocal.verify()` and `QWEDLocal.verify_math()` methods, plus the `qwed verify` command line entry point.
2.  **Control:** The attacker influences the model response. The answer turn value flows unmodified into the comparison helper; the query is also interpolated into both prompts, enabling prompt-injection influence.
3.  **Path:** `verify()` calls `verify_math()`, which issues two model turns. The expression turn is validated by a separate AST allow-list, so the attacker returns a benign valid expression there; that validation passes and control reaches the answer comparison at the call site of `_math_answers_match()`.
4.  **Guard:** The only check on the answer string is the exact-equality short-circuit, which any non-numeric payload fails, so no validation constrains the value that reaches `sympy.sympify()`.
5.  **Primitive:** `sympy.sympify(normalized_llm)` parses via `parse_expr()` and evaluates the string against the SymPy namespace, reaching a Python `eval` with access to built-ins through introspection gadgets.
6.  **Result:** Arbitrary Python and operating-system command execution in the verification process.

## Bypass Evidence

The prior remediation for CVE-2026-55585 (GHSA-q27q-98j4-9pfv) hardened a `safe_parse_expr` routine in `src/qwed_new/core/safe_parser.py` (NFKC normalization, an ASCII character allow-list, and an AST node allow-list). That routine is not imported or invoked anywhere in `qwed_sdk/qwed_local.py`; the answer comparison path calls raw `sympy.sympify()` instead. Running the same introspection-gadget payload against both routines demonstrates the divergence: `safe_parse_expr()` rejects it with a parser error ("Expression contains disallowed characters", enforced by the character gate), while `sympy.sympify()` executes it and returns normally after the embedded command runs. The exact-equality short-circuit preceding the sink is bypassed by any non-numeric string, and the AST allow-list applied elsewhere in `verify_math()` validates the expression turn, a different input, not the answer turn. The repository's release boundary check (`scripts/check_boundary.py`) does not cover this path either: it scans only the `src/` directory and its forbidden-call list does not include `sympify`, so the sink is doubly unguarded. The helper has a single caller, confirmed by searching the source, so no alternate guarded path masks the issue.

## Affected Versions

-   `Ecosystem: pip (PyPI)`
-   `Package: qwed`
-   `Confirmed affected range: >= 6.0.0, <= 7.2.0`
-   `Latest release checked: 7.2.0 (PyPI; corroborated by the released git tag v7.2.0)`
-   `Fix status: not fixed`

The vulnerable `sympy.sympify(normalized_llm)` sink is present in `qwed_sdk/qwed_local.py` at every released tag inspected, confirmed at v6.0.0 and at v7.2.0 (the latest release). No released version fixes this path: the remediation commit released in 7.2.0 touches only `src/qwed_new/core/safe_parser.py`, not this module. The lower bound is set conservatively to the earliest tag available in the repository (6.0.0); documentation references an earlier mitigation that suggests older release lines existed, so the true lower bound may extend earlier, but no earlier artifact was available to confirm the sink there. The affected package coordinate should be reconfirmed against the exact published wheel before final publication.

## Suggested Fix

Enforce that untrusted model output is never evaluated as an expression at this comparison point. The answer string should be parsed only as a number, or routed through the existing hardened `safe_parse_expr` routine (character allow-list plus AST allow-list) rather than raw `sympy.sympify()`, at `qwed_sdk/qwed_local.py` line 306. The remediation must also cover the answer turn specifically, not only the expression turn that is already validated, and should reject non-numeric answers outright where a numeric comparison is intended. As defense in depth, extend the release boundary check (`scripts/check_boundary.py`) to scan the `qwed_sdk/` package and to treat `sympify` (and other SymPy string-parsing entry points) as forbidden calls outside the approved safe wrapper. As an interim mitigation, deployments can pin their model endpoint to a trusted origin and validate that answer responses are strictly numeric before verification runs.

Reported by zx (GitHub: @manus-pi).
