QWED
v7.2.2 GitHub 60

Type to search pages, engines and advisories.

    Looking for API details?Search the docs

    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.

    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.
    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"))
    
    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).