The Validator Watched the Syntax and Missed the Semantics: CVE-2026-115845 and the match/case Escape From the AWS MCP Python Sandbox

AWS published security bulletin 2026-149-AWS on 23 October 2026, assigning CVE-2026-115845 to an incomplete list of disallowed inputs in the Python code validator of the run_script tool in the AWS MCP Server — the managed remote MCP server, generally available since 6 May 2026, that lets coding assistants such as Claude Code, Kiro and Cursor retrieve AWS documentation, call AWS APIs with user-supplied credentials, and combine several API calls into one scripted request. An authenticated remote user could execute arbitrary code within their own isolated session environment via a crafted script. NVD published the record the same day, carrying CWE-184, a CVSS 3.1 score of 6.4 Medium and a CVSS 4.0 score of 5.3, with the exclusively-hosted-service tag. The issue is fixed service-side; no customer action is required. The reporter, credited in the bulletin as Manash Saikia, published the full escape chain the next day, 24 October, on the xplo1t research blog. We read the bulletin, the NVD record, and the complete writeup first-hand.

Why match/case is invisible to an AST denylist

The sandbox had two layers. First, a static validator parsed the submitted script into an abstract syntax tree and rejected operations that looked dangerous: sensitive attribute reads such as obj.__class__, calls to blocked builtins such as getattr(), importing operator.attrgetter, and even dunder names written as plain strings. Second, a runtime layer replaced builtins with restricted implementations or proxies — type() became _safe_type(), open() was limited, __build_class__ was removed so no new classes could be defined. The researcher probed it through a purpose-built Python MCP client speaking to the server with mcp-proxy-for-aws and a limited test IAM user, deliberately bypassing the AI-agent friction that makes payload iteration painful.

The flaw is that Python 3.10 structural pattern matching reads attributes without using any of the syntax the validator watches. A class pattern such as case Coordinate(c): asks CPython to look up which attributes fill the positional slots by reading __match_args__ on the class — internally, at match-evaluation time. In the AST it is a single ast.MatchClass node containing the class name and the names to bind; the attribute name never appears in the code at the point where the read happens. And the write side was open too: the validator checked assignments against a short denylist of dunder names, and __match_args__ was not on it, so pointing a class pattern at __class__ or any other blocked attribute was a permitted assignment. String literals naming dunders were rejected, but chr(95) * 2 + "class" + chr(95) * 2 builds the same string at runtime where the validator cannot see it.

The chain, link by link

From that primitive the researcher built a complete escape. collections.namedtuple() was available in the sandbox, and because it creates its class internally via type(), it yielded a mutable class whose __match_args__ could be reassigned despite the missing __build_class__. Setting it to __reduce_ex__ and matching an instance retrieved the serialization hook; calling it with protocol 4 returned a recipe whose first element was copyreg.__newobj__ — a real Python function rather than a sandbox proxy, and therefore carrying a real __globals__ dictionary.

The next link came from a gap in the runtime layer itself. Calling the sandboxed type() — actually _safe_type() — on a throwaway lambda should return a restricted proxy, and it did; but where real Python refuses to subclass the immutable function type, the fallback built a brand-new proxy class from scratch, and that class was an ordinary mutable class that still passed isinstance() checks for real functions. Setting its __match_args__ to __globals__ and matching copyreg.__newobj__ against it read the real globals, exposing the genuine __builtins__ dictionary and its __import__ — and from there, os and subprocess. Running id returned uid=993(sbx_user1051) gid=990: command execution as the Lambda user, outside everything the Python sandbox was meant to allow.

Contained by the platform, not by the sandbox

The honest second half of the writeup is what the escape could not reach. The Lambda execution-role credentials visible in the process environment belonged to an AWS-owned account role, and every AWS API call attempted with them hit explicit deny except sts:GetCallerIdentity. There was no internet access, the root filesystem was read-only with only /tmp writable, and each MCP session ran in its own container — the researcher found no path to other sessions or customer data. AWS's CVSS 4.0 vector says the same thing in scoring language: scope changed, but confidentiality and integrity impact low, availability none. The sandbox failed; the surrounding platform held.

That containment is why this is a 6.4 and not a 9.8 — and why it still deserves a briefing. The vulnerable pattern is not AWS-specific: an AST denylist plus safer-looking reimplementations of builtins is the standard recipe for agent-execution sandboxes, and this chain breaks it with a language feature from 2021. We have covered the same design failing differently across this MCP stack: argument injection that walked past workspace confinement in the AWS security-agent MCP server (CVE-2026-97662), audience-blind token acceptance in the AWS API MCP Server (CVE-2026-115413), and a run_script-named endpoint executing posted Python in LMCache (CVE-2026-107204). The lesson generalises further than any one product: Python has several ways to do everything, so blocking the obvious ones leaves the unobvious ones, and a replacement builtin that behaves differently from the original is itself an attack surface.

What to do

  • Nothing to patch — but verify that statement covers you. AWS resolved this service-side on 23 October, so there is no version to upgrade. If you inventory agent infrastructure against CVE feeds, note the record is still Awaiting Analysis in NVD with no CPE entries; scanners keyed on CPE matching may never fire on it.
  • Audit what your agents route through run_script. The tool exists to let one script combine AWS API calls and filter results. Any prompt-injectable content that reaches those scripts — branch names, issue text, retrieved documentation — was, for the life of this bug, one match/case away from Lambda command execution under your session identity.
  • If you build sandboxes, deny by default and test the language, not the list. The validator failed on an allow-what-you-see basis: it enumerated dangerous spellings instead of permitting only known-safe operations. Structural pattern matching, decorators, exception matching, format specs, and comprehension scopes all execute attribute and name lookups that never appear as attribute syntax. Fuzz the sandbox with the language reference open, not the denylist.
  • Test proxy parity, not just proxy presence. The decisive bug was a replacement type() that succeeded where the original raises TypeError — a safer-looking builtin that was strictly more permissive than what it replaced. Every shim in a sandbox needs a differential test against the original for both success and failure cases.
  • Keep the platform containment this incident relied on. Per-session isolation, least-privilege execution roles, no network egress from the sandbox, and read-only filesystems are what turned a sandbox escape into a contained finding. Those controls are the actual security boundary; the Python-layer filtering is defence in depth. Design and monitor accordingly.

Our verification was read-only and remote: we read the full AWS bulletin, the NVD JSON record, and the complete researcher writeup including its disclosure timeline (reported 26 July 2026 via HackerOne, validated 30 July, fix acknowledged 15 September, resolved 23 October, disclosed 24 October). We did not run the MCP server, executed no Python against any sandbox, and made no request to any AWS service.

Sources: