Skip to content
Guard0
Back to blog
·6 min read·Joel Estibeiro

The Sandbox That Wasn't: Escaping Semantic Kernel's AST Allowlist (CVE-2026-26030)

CVE-2026-26030 turns a mundane question to an AI assistant into remote code execution. Semantic Kernel built a sandbox around its filter evaluator — and it lost. Here is the escape, reproduced against a live agent, and what actually stops this class of bug.

#The Signal#AI Agents#Security#Research
The Sandbox That Wasn't: Escaping Semantic Kernel's AST Allowlist (CVE-2026-26030)

A user asks an internal AI assistant a mundane question: “pull up the platform team’s VPN runbook.” A few hundred milliseconds later the host is running an attacker’s command. No malware, no memory corruption, no CVE in the model itself. Just an agent doing exactly what it was built to do.

That is CVE-2026-26030, a remote code execution flaw in Microsoft’s Semantic Kernel (Python, before 1.39.4). It is the cleanest example we’ve seen of the defining problem in agent security: in an agentic system, there is no boundary between data and instructions. The thing the model writes from a user’s request is code, and here that code reaches a shell.

We reproduced it end to end against a real LLM agent. Here’s how it works, why the “sandbox” around it failed, and what we think you should actually do about this class of bug.

The setup: agents write filters

Modern agent frameworks lean heavily on tool calls backed by vector stores. A user asks something, the model decides to call a search tool, and the model often emits a filter expression to narrow the results. In Semantic Kernel’s in-memory vector store, that filter can be a string the framework turns into a Python lambda:

lambda x: x.team == 'platform'

That string is not a static query a developer wrote. It is assembled, at runtime, from the conversation — which means it is influenceable by anyone who can put text in front of the model: the user, a poisoned document the agent retrieves, a tool result, an email in the inbox the agent is summarizing. The moment untrusted text can shape that filter, the filter is an injection sink.

And it lands in code that does this:

code = compile(tree, filename="<filter>", mode="eval")
func = eval(code, {"__builtins__": {}}, {})  # nosec

A model-influenced string gets compile()d and eval()d. That’s the whole game.

The interesting part: the sandbox was there, and it lost

Semantic Kernel did not naively eval untrusted input. The maintainers clearly anticipated the risk and built a sandbox:

  1. eval runs with __builtins__ emptied. No __import__, no open, no eval. The obvious escapes are gone.
  2. Before compiling, the framework walks the expression’s AST and rejects any node type not on an allowlist (comparisons, boolean logic, literals, attribute access, subscripts, calls).
  3. Function calls are checked against a second allowlist of “safe” names (len, str, lower, startswith, etc.).

On paper that looks locked down. In practice it falls to two gaps that, together, hand you arbitrary code execution.

Gap 1: attribute access is completely unrestricted. ast.Attribute is allowlisted with no blocklist of dangerous names. So the classic Python sandbox-escape walk is permitted: from any object, __class____base__ (that’s object) → __subclasses__ reaches every class loaded in the process, including the import machinery. Emptying __builtins__ doesn’t help here — you’re not using builtins, you’re walking the live object graph.

Gap 2: the call check can be bypassed. The validator only identifies what is being called when the call target is a bare name (foo()) or an attribute (obj.foo()). If the target is anything else, the check is silently skipped:

if isinstance(node, ast.Call):
    func_name = None
    if isinstance(node.func, ast.Name):
        func_name = node.func.id
    elif isinstance(node.func, ast.Attribute):
        func_name = node.func.attr
    if func_name and func_name not in self.allowed_filter_functions:
        raise ...  # blocked

What target isn’t a Name or Attribute, but is allowlisted? A subscript. So something[0](...) is a call whose func is a Subscript node. func_name stays None, and the allowlist check never fires. That gives a universal primitive: wrap any callable in a list and index it back out before calling it.

[obj.method][0](args)   # calls anything; invisible to the name check

Chain the two gaps and you get a clean escape. Dunder-walk to the import system, load os, call system:

lambda x: [[[().__class__.__base__.__subclasses__][0]()[122].load_module][0]('os').system][0]('<command>')

Every call’s target is a subscript (Gap 2). Every traversal step is plain attribute access (Gap 1). No disallowed node types, no disallowed names. It sails through validation, compiles, and runs. (The 122 is the index of BuiltinImporter in object.__subclasses__(); it varies by build, so our PoC computes it at runtime.)

Diagram of the escape chain: ().__class__ (tuple) to .__base__ (object) to .__subclasses__() (every loaded class) to [122] (BuiltinImporter) to .load_module('os') to .system() shown in green as remote code execution, annotated with Gap 1 (unrestricted attribute access) and Gap 2 (subscript-call bypass)
Two “safe” primitives — attribute access and subscript-calls — compose into arbitrary code execution. Emptying __builtins__ never enters the picture.

Making it real: an agent, a prompt, a shell

A gadget in a unit test is one thing. We wanted to see it fire through an actual agent the way an attacker would hit it.

So we built OpsBot, a small internal-knowledge-base assistant: a real LLM (Llama 3.3 70B) driving Semantic Kernel, exposing a search_runbooks(team) tool backed by the vulnerable in-memory store. The tool interpolates the model’s argument straight into the filter:

filter_str = f"lambda x: x.team == '{team}'"

Then we sent the agent a prompt-injection payload — the kind that could arrive inside any document, ticket, or message an agent ingests — instructing it to call the tool with a team value that breaks out of the quotes:

platform' or <gadget> or '1'=='1
Screenshot of the OpsBot demo: an attacker system message instructs the agent to call search_runbooks with a payload string; the agent calls the tool verbatim, a PWNED_by_CVE-2026-26030 ransom note opens, and an RCE-fired banner confirms os.system executed on semantic-kernel 1.39.3 while the agent replies normally
The live agent (OpsBot): the model passes the payload to the tool verbatim, os.system fires on the host, and the agent cheerfully summarizes the runbook as if nothing happened.

The model called the tool with the payload verbatim. The filter string became valid, malicious Python. It passed the allowlist. It executed. In our lab the command is harmless — it opens a “ransom note” in TextEdit — but it is genuine arbitrary command execution by the host process. The agent, meanwhile, summarized the (perfectly normal) runbook results and asked if there was anything else it could help with.

One detail worth dwelling on: the model was the last line of defense, and it didn’t hesitate. A more safety-tuned model might have balked at copying an obviously hostile string. An open, instruction-following model passed it straight through. You cannot treat “the model probably won’t cooperate” as a control.

We also ran it headless, side by side, against the vulnerable and patched releases:

Terminal screenshot of the headless proof-of-concept: on semantic-kernel 1.39.3 the attacker-supplied filter executes os.system and creates /tmp/pwned_by_filter (RCE confirmed); on the patched 1.39.4 the same payload is rejected with a VectorStoreOperationException stating access to attribute __subclasses__ is not allowed
Same payload, two releases. On 1.39.3 the filter reaches os.system; on 1.39.4 it is rejected before eval.

The full reproduction — isolated virtualenvs, the headless PoC, and the live agent demo — is on GitHub. The payload is harmless and everything runs locally; reproduce it ethically.

The fix, and why it’s only half the lesson

Semantic Kernel 1.39.4 closes the hole by adding a dangerous-attribute blocklist, so the dunder walk is rejected before eval:

Access to attribute '__subclasses__' is not allowed in filter expressions.
This attribute could be used to escape the filter sandbox.

But patch the symptom and the deeper pattern remains. This is one of a wave of 2026 disclosures where an agent framework turned model-influenced text into execution. Microsoft’s own research catalogued many of these across the ecosystem, from Semantic Kernel’s .NET SDK to the broad cluster of Model Context Protocol command-injection CVEs. The common shape is always the same: somewhere, a string the model shaped is eval’d, compile’d, or handed to a shell. A blocklist of bad attributes is a patch. It is not a position you can hold.

What we’d actually do about it

Guard0’s take, strongest control first:

  1. Don’t evaluate model output. At all. A filter does not need to be arbitrary Python. Expose a small structured filter API (field, operator, value) and let the model fill in parameters, never code. If the model can only choose team == <value>, there is no gadget to build.
  2. If you must interpret an expression, use a closed grammar — not a node allowlist over open attribute access. This CVE is the cautionary tale: an allowlist that permits attribute access and subscript-calls is not a sandbox, because the dangerous capability is reachable by composition of “safe” pieces. Parse to a tiny, purpose-built AST you fully control; reject everything else by construction.
  3. Assume bypass, and contain the blast radius. The agent process should run with the least privilege that lets it do its job: no ambient cloud credentials, network egress locked to what it actually needs, filesystem scoped, ideally a seccomp/sandbox profile. Code execution should be a bad day, not game over. In our demo the “victim” was a desktop app — picture the same agent on a server with an instance role.
  4. Treat all retrieved and tool-sourced content as hostile input. Prompt injection isn’t only what the user types. It’s the document, the webpage, the ticket, the email the agent reads. The injection in our demo could ride in on any of them.

The uncomfortable truth of agent security is that the model is, by design, an interpreter that blurs data and instructions. CVE-2026-26030 didn’t break that boundary — there was no boundary to break. The job now is to build the boundaries ourselves, in the code around the model, and to stop pretending an allowlist is one.


References

  1. CVE-2026-26030 — National Vulnerability Database record
  2. Microsoft Semantic Kernel security advisories and the 1.39.4 release
  3. semantic-kernel on PyPI (1.39.3 vulnerable, 1.39.4 patched)
  4. Microsoft, “Prompts become shells: RCE vulnerabilities in AI agent frameworks”
  5. Reproduction lab (isolated PoC + live agent demo)
G0
Joel Estibeiro
A Security Researcher figuring out the agentic world

Get Started

Developers

Try g0 on your codebase

Learn more about g0 →
Self-Serve

Start free on Cloud

Dashboards, AI triage, compliance tracking. Free for up to 5 projects.

Start free →
Enterprise

Accountability at scale

SSO, RBAC, CI/CD gates, self-hosted deployment, SOC2 compliance.