{"slug": "breaking-the-aws-mcp-python-sandbox", "title": "Breaking the AWS MCP Python Sandbox", "summary": "A security researcher bypassed the Python restrictions in AWS's managed MCP Server, reaching system command execution inside the AWS Lambda sandbox that runs the `aws___run_script` tool, which AWS made generally available on May 6, 2026. The researcher, working with Claude Opus 4.6, built a Python CLI MCP client using `mcp-proxy-for-aws` and a limited-permission test IAM user to probe the tool, then defeated both of its protection layers: an AST-based static validator and a runtime layer that swaps builtins for restricted proxies such as `_safe_type()` and limits `open()`. The bypass matters because the tool executes agent-supplied Python inside AWS's environment using the user's IAM credentials.", "body_md": "## Preface[#](#preface)\n\nAWS announced the general availability of the [AWS MCP Server](https://aws.amazon.com/blogs/aws/the-aws-mcp-server-is-now-generally-available/) on May 6, 2026. It is a managed remote Model Context Protocol (MCP) server.\n\nAn MCP server exposes certain tools/functions and lets your AI agent call them. It extends your AI agent’s capabilities. The AWS MCP Server makes this possible for coding assistants such as Claude Code, Kiro and Cursor to retrieve current AWS documentation, make AWS API calls using the IAM credentials supplied by the user, and provide guidance through skills. Those tools can be used for tasks such as retrieving account information or creating an EC2 instance.\n\nAWS’s MCP server provides a tool called `aws___run_script`. This tool accepts a short Python script as an argument and runs it in a server-side sandbox. Its purpose is to let an agent combine multiple AWS API calls, filter the responses and process the result in one request programatically, instead of making several separate calls.\n\nThe idea of user-supplied Python code executing inside an AWS managed sandboxed environment is interesting!\n\n`aws___run_script` runs the supplied code in an AWS Lambda sandbox. The sandbox restricts imports, builtins, attribute access and file operations.\n\nIn this blog, I explain how I bypassed those Python restrictions and reached system command execution inside the Lambda environment.\n\nI performed this research collaboratively with Claude Opus 4.6.\n\n## Discovery[#](#discovery)\n\nNormally connecting the MCP server to Cursor or any other agent adds friction for security testing because your payloads have to pass through the AI agent which then invokes the tools. While it is fine for a while, but later it becomes a pain when your payloads starts become overly chaotic. So I quickly created a Python CLI as an MCP client that could let me enumerate the server’s tools and invoke them. The client used [`mcp-proxy-for-aws`](https://github.com/aws/mcp-proxy-for-aws), which signed requests with my local AWS credentials. I created a test IAM user in my AWS account with limited permissions for this assessment.\n\nThis made it possible to send controlled inputs to each tool and examine the returned results. Although the Python code ran inside the Lambda, dangerous Python code was immediately blocked due to the custom sandbox implemented, to prevent code execution inside the Lambda runtime. I wanted to find out what actually prevented it from reaching the process, filesystem or environment beneath the Python sandbox.\n\n## Understanding the Sandbox[#](#understanding-the-sandbox)\n\nThe submitted code passed through two layers of protection.\n\nFirstly, a static validator inspected the code’s Abstract Syntax Tree (AST). An AST is a structured representation of source code. ASTs are used widely in compilers, code interpreters, linters, etc. An AST is a data structure containing the structural logic of the code such as imports, calls, attribute reads and assignments. The validator in the sandbox used AST to parse the Python script passed into `aws___run_script` to reject certain malicious looking operations before the code gets executed.\n\nSecondly, a runtime layer replaced builtins with restricted implementations or proxy objects. In this context, a proxy is a replacement object that acts like the original object but limits how it can be used. For example, instead of the real `type()`, the script gets `_safe_type()` which returns restricted proxy classes that block access to sensitive attributes. The sandbox also limited `open()` to control file access.\n\nFor example, reading sensitive attributes through `obj.__class__` was blocked. The `getattr()` builtin was also blocked, as was importing `operator` to call `operator.attrgetter()`.\n\n`getattr()` is simply another way to read an attribute. In normal Python, two lines produce the same output:\n\n``` python\nfrom types import SimpleNamespace\n\nperson = SimpleNamespace(name=\"John Doe\", age=900)\n\nprint(person.name)  # John Doe\nprint(getattr(person, \"name\")) # John Doe\n```\n\nThey both read `name` from `person`. When the attribute name is determined at runtime, `getattr()` is useful, as the attribute name can be passed as a string.\n\nThe sandbox covered both of these common forms of attribute access. It did not account for structural pattern matching.\n\n## The Flaw: Attribute Reads Hidden in a Pattern[#](#the-flaw-attribute-reads-hidden-in-a-pattern)\n\nPython 3.10 introduced structural pattern matching through `match/case`. It checks whether a value fits a pattern and can bind parts of that value to names.\n\nA class pattern first checks an object’s type, then reads attributes from it. Here is an example:\n\n``` python\nfrom collections import namedtuple\n\nCoordinate = namedtuple(\"Coordinate\", [\"x\", \"y\"])\nlocation = Coordinate(10, 20)\n\nmatch location:\n    case Coordinate(horizontal, vertical):\n        print(horizontal, vertical)\n```\n\nThis would print `10 20`.\n\nPython decides which attributes go into `horizontal` and `vertical` by looking at `__match_args__` on the class.\n\n```\nprint(Coordinate.__match_args__)  # ('x', 'y')\n```\n\nNames wrapped in double underscores are often called dunder names (dunder comes from double underscore). They are special names used by Python’s object model.\n\nFor the `Coordinate` class, `__match_args__` contains:\n\n```\n(\"x\", \"y\")\n```\n\nSo the first position in the pattern reads `x` and the second reads `y`. This tuple can be changed:\n\n```\nCoordinate.__match_args__ = (\"y\", \"x\")\n```\n\nWith that change, the exact same pattern prints `20 10`. The tuple controls which attributes Python reads for the positional entries in the pattern.\n\n[PEP 634](https://peps.python.org/pep-0634/#class-patterns) specifies this class-pattern behaviour. CPython performs these attribute lookups internally, so they happen even if the `getattr()` builtin exposed to user code has been removed.\n\nThat matters for an AST validator. The validator on the sandbox only looks at the structure of the submitted code, and it was built to reject nodes that read sensitive attributes, such as `obj.__class__` (an attribute access) or `getattr(obj, \"__class__\")` (a call to a blocked builtin).\n\nA class pattern produces neither. 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. It is stored in `__match_args__`, and Python only looks it up while evaluating the pattern at runtime.\n\nHere is the same `Coordinate` example with `__match_args__` pointed at a different attribute:\n\n```\nCoordinate.__match_args__ = (\"__class__\",)\n\nmatch location:\n    case Coordinate(c):\n        print(c)\n```\n\nThis prints `<class '__main__.Coordinate'>`. The pattern `Coordinate(c)` has a single positional entry, so Python reads the first name in `__match_args__`, which is now `__class__`, from `location` and binds the result to `c`. The code above never directly specified `location.__class__`, but that value came up in `c`.\n\nTwo conditions had to hold in the sandbox for this to be useful:\n\n- Assigning to `__match_args__` was permitted. The validator blocked reading dunder attributes but it checked writes only against a short denylist of dunder names, and`__match_args__` was not on it.\n- Class patterns still performed their implicit reads, because the check lives inside CPython and not in the validator or the replaced builtins.\n\nTogether with those two, we can read attributes that the normal syntax could not reach. `__class__` is just an example here. This same trick works for any attribute name, including the ones the sandbox was intended to block.\n\nThere was one more hurdle. The validator also rejected dunder names written as plain strings. So `(\"__class__\",)` was blocked. I got around this by building the string at runtime using `chr()`, where the validator could not see it:\n\n```\n\"__class__\"                        # blocked by the validator\nchr(95)*2 + \"class\" + chr(95)*2    # allowed\n```\n\n`chr(95)` is the underscore `_` character. I used this for every dunder name in the chain such as `__reduce_ex__`, `__globals__` and `__import__`.\n\n## Building the Escape Chain[#](#building-the-escape-chain)\n\nI began with `collections.namedtuple()`, which was available in the sandbox. Calling it creates a tuple subclass with named fields, like `Coordinate` in the earlier example. The resulting class’s `__match_args__` can be reassigned.\n\nThis was useful because the sandbox removed `__build_class__` from the exposed builtins, preventing ordinary class definitions. However, `namedtuple()` creates its class internally using `type(...)`, so this allowed standard-library function still gave me a way to obtain a class and control its `__match_args__`.\n\n### Recovering a Real Function[#](#recovering-a-real-function)\n\nThere were probably many other candidates, but after testing several, I ended up with `__reduce_ex__`.\n\n`__reduce_ex__` belongs to Python’s serialization support. When `pickle` needs to reconstruct an object later, it can ask the object for a recipe: a callable, arguments and any required state.\n\nAfter changing the namedtuple class’s `__match_args__` to `(\"__reduce_ex__\",)`, a class pattern retrieved the instance’s `__reduce_ex__` method. Calling it with protocol `4` returned a recipe whose first element was `copyreg.__newobj__`.\n\n`copyreg.__newobj__` was a real Python function, rather than a proxy function in the sandbox.\n\nThis is important because a function defined in Python exposes `__globals__` which is a dictionary of global names in the module where it was defined. The globals for `copyreg.__newobj__` included `__builtins__`, the original Python builtins dictionary.\n\n`__builtins__` contains `__import__`, which we can use to load different modules. If I could recover that original import function, I could import `os` and `subprocess`.\n\nThe next challenge was obtaining the real function’s `__globals__` with another class pattern. The namedtuple pattern couldn’t be reused here, because a class pattern checks its subject with `isinstance()`, and `copyreg.__newobj__` is a function, not a namedtuple. I needed a class pattern for functions.\n\n### The Proxy That Completed the Chain[#](#the-proxy-that-completed-the-chain)\n\nNormally, `type()` returns an object’s actual type. For a Python function, that is the `function` type (`types.FunctionType`), which is immutable, so Python rejects attempts to add attributes to it.\n\nFor example, assigning `__match_args__` to the real function type produces:\n\n```\nTypeError: cannot set '__match_args__' attribute of immutable type 'function'\n```\n\nSince the sandbox stopped me from defining my own class, this looked like a dead end.\n\nThe sandbox replaced `type()` with `_safe_type()`, which was meant to return restricted proxy classes that block dangerous attributes. I called it on a throwaway function, `type(lambda: None)`. Inside the sandbox, the same assignment that fails in normal Python went through without an error.\n\nI only understood why later, once the finished escape let me read the sandbox’s logic. For any object, `_safe_type()` tries to return a restricted copy of its real type by subclassing it. Python does not allow subclassing the function type, so for functions that attempt fails. The fallback then builds a brand-new proxy class from scratch.\n\nThat new class blocks a few dangerous attribute reads, but it is an ordinary class created while the program runs, so its attributes can be changed.\n\n**The sandbox returned a mutable replacement for an immutable type.**\n\nSo I set the proxy’s `__match_args__` to `(\"__globals__\",)`, using the same technique explained before.\n\nA class pattern only works on objects of the right type. When the proxy was asked whether `copyreg.__newobj__` was a function, it said yes. So the pattern matched.\n\nPython then read `__globals__` from the real function. The proxy did not restrict this access because the inspected function was an original “non-proxied” object.\n\nIts `__globals__` gave me the real Python `__builtins__` dictionary. By calling its `__import__` function, I could then import `os` and `subprocess`.\n\nThe AST validator missed the implicit reads from the class patterns. The runtime proxy gave the missing piece for the chain: a mutable class that still passed `isinstance()` for real functions, which let me set `__match_args__` on it.\n\nThe full chain is as follows:\n\n``` php\nnamedtuple -> __match_args__ -> __reduce_ex__ -> copyreg.__newobj__ -> type() proxy -> __match_args__ -> __globals__ -> __builtins__ -> __import__ -> subprocess -> id\n```\n\n## Confirming Command Execution[#](#confirming-command-execution)\n\nOnce I could import `subprocess`, I used my Python MCP client CLI to execute `id` in the Lambda environment. It returned:\n\n```\nuid=993(sbx_user1051) gid=990 groups=990\n```\n\nThis confirmed command execution as the Lambda user beyond what the Python sandboxed environment was meant to allow.\n\nRunning `env` also exposed the Lambda execution role’s credentials in these variables:\n\n```\nAWS_ACCESS_KEY_ID\nAWS_SECRET_ACCESS_KEY\nAWS_SESSION_TOKEN\n```\n\nThe credentials belonged to the role attached to the Lambda sandbox, not the IAM user I used to connect to the MCP server.\n\nThe escape also allowed filesystem reads, including reads of the sandbox’s own Python source. The root filesystem was read-only and `/tmp` was writable as in a normal Lambda function.\n\n## The Impact[#](#the-impact)\n\nThe Python sandbox escape gave me command execution and access to the process environment inside my session’s Lambda container:\n\n- System command execution as the Lambda user.\n- Disclosure of the Lambda execution role credentials.\n- Read access to the files available to that user, including the sandbox implementation.\n\nThe recovered sandbox Lambda role credentials received explicit deny errors for every AWS API call except for `sts:GetCallerIdentity` which is always available and [does not require any permissions](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html). This limited what could be done.\n\nThe credentials belong to an IAM role in the AWS-owned account and not a customer account, which I confirmed via the `sts:GetCallerIdentity` API response. In my testing, I found no access to other sessions or customer data and the escape was confined to the Lambda container created for my MCP client’s session.\n\n## Closing Thoughts[#](#closing-thoughts)\n\nThe sandbox had two layers of protection. The AST validator blocked the usual ways of reading sensitive attributes, but Python’s `match/case` feature can read attributes without using any of them. The runtime layer replaced standard Python functions and objects with its own versions, which restricted certain capabilities and attributes. There was a gap in this implementation which finally completed the exploit chain.\n\nIt is not easy to get sandboxes right. Python often has several ways to achieve the same thing. So, blocking the most obvious ones can still leave a less obvious one open. Also, replacing a built-in with a “safer” version can backfire if the replacement behaves differently from the original. You have to get right in all the ways possible. An attacker just needs to find one path to exploit.\n\nIn this case, AWS’s other controls (locked-down credentials, no internet access and a separate container per session in my testing) limited the damage.\n\nThanks for reading, and I’ll see you around on the next one :)\n\n## Vulnerability Disclosure Timeline[#](#vulnerability-disclosure-timeline)\n\n- **July 26, 2026** — Reported to Amazon AWS VDP via HackerOne\n- **July 30, 2026** — Report validated by HackerOne and forwarded to AWS team\n- **September 15, 2026** — Amazon AWS team acknowledged the report and confirmed a fix was in progress\n- **October 23, 2026** — Vulnerability report marked as resolved\n- **October 24, 2026** — Public disclosure", "url": "https://wpnews.pro/news/breaking-the-aws-mcp-python-sandbox", "canonical_source": "https://xplo1t-sec.github.io/posts/breaking-the-aws-mcp-python-sandbox/", "published_at": "2026-10-27 03:09:37+00:00", "updated_at": "2026-10-27 03:41:38.529748+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "agent-protocols", "ai-tools"], "entities": ["AWS", "AWS MCP Server", "aws___run_script", "AWS Lambda", "Claude Opus 4.6", "Cursor", "Kiro", "mcp-proxy-for-aws"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/breaking-the-aws-mcp-python-sandbox", "markdown": "https://wpnews.pro/news/breaking-the-aws-mcp-python-sandbox.md", "text": "https://wpnews.pro/news/breaking-the-aws-mcp-python-sandbox.txt", "jsonld": "https://wpnews.pro/news/breaking-the-aws-mcp-python-sandbox.jsonld"}}