Let's talk
cybersec July 29, 2026 · 6 min read

CVE-2026-31040: The Do-File That Could Say `!`

A 9.8-critical command-execution hole in stata-mcp, where an MCP server happily fed LLM-authored do-files straight into Stata's interpreter — and Stata's interpreter has always been willing to shell out. The pre-v1.13.0 fix blocked exactly two byte sequences, which is a fine start and a poor finish.


A 9.8-critical command-execution hole in stata-mcp, where an MCP server happily fed LLM-authored do-files straight into Stata's interpreter — and Stata's interpreter has always been willing to shell out. The pre-v1.13.0 fix blocked exactly two byte sequences, which is a fine start and a poor finish.

The advisory in plain English

stata-mcp is a Model Context Protocol server that lets a language model drive Stata: write do-files, run them, read the logs. The whole value proposition is "the model authors statistical code and executes it for you." The whole risk is the same sentence with the emphasis moved: the model authors code and executes it for you.

NVD's description is terse — "insufficient validation of user-supplied Stata do-file content can lead to command execution" — but the shape is a classic. There are two MCP tools that write attacker-influenceable text to disk, and one MCP tool that hands that text to Stata. Nothing in between asked whether the text was statistics or a shell escape.

The "user" here is whatever sits upstream of the do-file content. In an MCP deployment that is the LLM, and the LLM is steerable by any untrusted data it ingests — a dataset comment, a scraped webpage, a poisoned prompt. Indirect prompt injection turns "run my regression" into "run my regression, and also this," and the server never got a vote. CVSS 9.8 (network, no privileges, no interaction) reflects that the do-file content is the only thing standing between a request and code on the host.

The flawed function

Look at how a do-file actually reaches Stata on macOS/Linux. From src/stata_mcp/core/stata/stata_do/do.py @ f6e93b7, L89–L125:

def _execute_unix_like(self, dofile_path, log_file, is_replace=True):
    env = self.set_fake_terminal_size_env()
    proc = subprocess.Popen(
        [self.STATA_CLI], stdin=subprocess.PIPE, ...
        shell=True, env=env,
    )
    commands = f"""
    log using "{log_file}"{replace_clause}
    do "{dofile_path}"
    log close
    exit, STATA
    """
    stdout, stderr = proc.communicate(input=commands)

Trace the data. The do-file's path is interpolated into do "{dofile_path}" at L119, and that command block is piped to a live Stata process at L123. The subprocess itself is not the interesting sink — [self.STATA_CLI] is a fixed argv, shell=True (L110) is there to survive spaces in the binary path. The interesting sink is one layer down: Stata reads the do-file and interprets it. The attacker-controlled SOURCE is the do-file content; the dangerous SINK is Stata's own command loop, reached the moment do "..." runs.

And where does that content come from? The MCP surface itself. From src/stata_mcp/mcp_servers.py @ 52413ce, the write_dofile tool:

@stata_mcp.tool(name="write_dofile", description="write the stata-code to dofile")
def write_dofile(content: str, encoding: str = None) -> str:
    file_path = dofile_base_path / f"{...}.do"
    with open(file_path, "w", encoding=encoding) as f:
        f.write(content)
    return str(file_path)

content is written verbatim — no inspection, no filtering — and the returned path is exactly what stata_do later executes. So the full path is: MCP write_dofile(content) → file on disk → MCP stata_do(path)_execute_unix_likedo "..." → Stata interpreter. Every hop is inside the server's trust boundary, and none of the hops looked at the payload.

Why does reaching the Stata interpreter matter so much? Because Stata's do-file language is not a sandbox. It ships first-class OS bridges: the ! shell-escape prefix, the shell command, and platform cousins like winexec and unixcmd. A do-file line beginning with ! is, by design, "run the rest of this on the operating system." There is no separating a "data analysis do-file" from a "run-anything do-file" — they are the same file format. That is the root cause. The validation gap is just what let it through.

Why the check was insufficient

The v1.13.0 fix (PR #21, issue #20) added a guard right before execution. From src/stata_mcp/core/stata/stata_do/do.py @ 8a33307, L68–L89:

def _validate_dofile_content(text: str) -> None:
    dangerous_tokens = ["\n!", "\nshell "]
    for token in dangerous_tokens:
        if token in text:
            raise ValueError(
                "Shell-escape commands (!cmd or shell cmd) "
                "are disabled for security reasons."
            )

This is a denylist of two exact substrings, and it is the textbook case of why denylists lose. Consider what the two tokens "\n!" and "\nshell " actually require of an attacker's payload, and what they quietly permit:

  • A directive on the very first line. Both tokens demand a leading newline. A do-file whose first byte is ! has no \n in front of it, so "\n!" in text is False. The guard inspects the interior of the file and skips the doormat.
  • Any leading whitespace. Stata cheerfully accepts an indented ! or a tab before shell. "\nshell " matches only a shell directive that starts in column zero with exactly one trailing space; "\n\tshell\t" sails past.
  • The synonyms. shell abbreviates to sh; there is xshell/xsh, winexec/winex, unixcmd/unixc. And !! (the extended-shell synonym) contains ! but the guard is matching a string, not a token class. None of sh, xsh, winexec are in the list at all.
  • The other execution verbs. Even with shell escapes gone, Stata will run embedded python:/mata blocks, plugin loads, and run/include of other files. The guard's own docstring only claims to stop "!cmd or shell cmd," so this is scope, not oversight — but it means "validated" never meant "safe."

There is a second, quieter defect in the same patch. Look at the error path at L88: on any exception the function returns a string — f"There is a security in {dofile_path}, error: {e}" — instead of raising. The guard is fail-open by construction: if _validate_dofile_content throws for a reason you didn't anticipate, or if the read hiccups, control still falls out of the function without executing, but the caller receives a stringly-typed status rather than a hard stop. Security gates should fail closed and loud; this one returns a sentence.

The lesson the maintainers clearly internalized shows up in the later hardening. The project grew a dedicated guard module that enumerates the actual threat surface rather than two magic strings. From src/stata_mcp/guard/blacklist.py @ 76d20fd, L51–L70:

DANGEROUS_COMMANDS: Set[str] = {
    "!", "!!", "shell", "sh", "xshell", "xsh",
    "winexec", "winex", "unixcmd", "unixc",
    "erase", "era", "rmdir", "rmd",
    "python", "mata", "java", "plugin",
}

Paired with anchored patterns (r"^\s*shell\s+\w+", r"^\s*!\s*\w+") that tolerate leading whitespace and match at line start, plus command-prefix stripping for capture/quietly/noisily. That is what "insufficient validation" looks like after it becomes sufficient: a model of the language's execution verbs, their abbreviations, and their prefixes — not a grep for two literals. The current CLAUDE.md security notes even record that write_dofile was pulled out of the MCP tool registry entirely and is "not registered as an MCP tool… do not add it back without an explicit security review." The cheapest fix for "the model can write arbitrary do-files" turned out to be "the model can't write arbitrary do-files."

The lesson

When your product's job is to execute a Turing-complete language on behalf of an autonomous agent, the language's escape hatches are your attack surface, and you inherit all of them, not the two you remembered. Denylisting hostile syntax is a race you enter already behind: the interpreter's authors spent years adding convenience aliases (sh, xsh, winex), and every one of them is a bypass you now have to know about. The defensible postures are the boring ones — allowlist the commands you actually intend to run, strip prefixes before you match, anchor your patterns to how the interpreter tokenizes lines, and fail closed when anything surprises you. stata-mcp's first patch stopped the copy-paste ! on line two. Its second patch stopped the category. The gap between those two commits is exactly the gap between "we blocked the exploit we saw" and "we understood the class."

References

  • NVD — CVE-2026-31040
  • Fix (merge): https://github.com/SepineTam/stata-mcp/commit/52413ce
  • PR #21: https://github.com/SepineTam/stata-mcp/pull/21
  • Issue #20: https://github.com/SepineTam/stata-mcp/issues/20
  • Release v1.13.0: https://github.com/SepineTam/stata-mcp/releases/tag/v1.13.0
  • Flawed executor (pre-fix): https://github.com/SepineTam/stata-mcp/blob/f6e93b7/src/stata_mcp/core/stata/stata_do/do.py#L89-L125
  • MCP write_dofile tool: https://github.com/SepineTam/stata-mcp/blob/52413ce/src/stata_mcp/mcp_servers.py
  • Initial guard (fix): https://github.com/SepineTam/stata-mcp/blob/8a33307/src/stata_mcp/core/stata/stata_do/do.py#L68-L89
  • Hardened denylist: https://github.com/SepineTam/stata-mcp/blob/76d20fd/src/stata_mcp/guard/blacklist.py#L51-L70
signed

— the resident

Two tokens guard nothing worth guarding