DEV Community

Cover image for MCP Inspector's OAuth flow could run PowerShell on Windows: inside CVE-2026-119025, and why npm update misses the fix
Piekwerk
Piekwerk

Posted on

MCP Inspector's OAuth flow could run PowerShell on Windows: inside CVE-2026-119025, and why npm update misses the fix

The MCP Inspector is the official debugging tool for the Model Context Protocol, and it is the tool AWS, Cloudflare and Azure docs point at when you need to test a remote MCP server before trusting it. On November 5, Pillar Security published the uncomfortable punchline: on Windows and WSL, the Inspector itself let a remote MCP server run code on your machine during the OAuth handshake. No tool call, no approval prompt, no model involved. The advisory (GHSA-q978-78c7-phqw, CVSS 3.1 8.8) went up October 21 and the CVE record (CVE-2026-119025, CVSS 4.0 7.7) landed October 29, but the Inspector-specific walkthrough is days old, and it is worth understanding if you ever test MCP servers on a Windows machine.

The tool you vet servers with was the attack surface

The Inspector's whole purpose is to be pointed at servers whose behavior you do not yet trust. That is what it is for. Pillar puts it at about 768,000 weekly npm downloads, and the exposed population is exactly the people deliberately connecting to unknown servers, on the operating system where this bug bites. The irony is not subtle. You run the Inspector to avoid giving an untrusted server access to your machine, and the act of running it did the opposite.

The chain, step by step

The bug needs five moves, and none of them involve the model:

  1. You point the Inspector at a remote MCP server. It answers 401 with a WWW-Authenticate challenge and serves OAuth discovery documents.
  2. The authorization_endpoint inside those documents is a free-form string chosen entirely by that server.
  3. The Inspector opens that URL automatically through npm's open package. Auto-open is on by default in an interactive terminal, and the same call existed in three places: the CLI, the TUI and the web client.
  4. On Windows and WSL, open 10.x places the URL inside a PowerShell double-quoted string, base64-encodes the result and runs it with -EncodedCommand. Nothing escapes the URL.
  5. PowerShell evaluates $(..) subexpressions inside double quotes. The payload is an ordinary, valid https URL:
https://evil.test/$([IO.File]::WriteAllText('C:\Users\Public\pwned.txt','RCE'))
Enter fullscreen mode Exit fullscreen mode

URL serialization percent-encodes spaces and double quotes but preserves $ ( ) [ ] : , + &, so the payload needs no encoded character at all. The code runs while you are still connecting, before a single tool is listed.

Why the usual defenses do not fire

Three details make this worse than it first looks.

It is a valid https URL. A scheme check, the defense several projects adopted for this exact class of bug, passes it cleanly. What actually stops this payload is open 11, which escapes the argument and places it in a single-quoted PowerShell string, where $(..) is literal text. Scheme validation is defense in depth here, not the fix.

The model plays no part. Nothing is approved and no tool is invoked, so permission rules in your agent config never see it. I write about agent guardrails a lot, and this is the case they cannot cover: the injection happens one layer below the agent, in the connect path.

The server did not have to be malicious when you added it. The payload arrives in a discovery response at request time, so a typosquatted package name, an expired domain or a later compromise all produce the same result.

Honest limits: the researcher demonstrated the sink string executing in PowerShell, while the Windows and WSL reachability is established by code review of the win32 branch. No in-the-wild exploitation has been reported.

Who is affected, exactly

  • Windows and WSL, for this chain. On macOS and Linux, open calls xdg-open or open through spawn with an argument vector, no shell, so this particular path does not exist there.
  • Component ranges from the CVE record: open >= 7.2.0 and < 11.0.0, @modelcontextprotocol/inspector >= 2.0.0 and < 2.12.0, and opencode-ai >= 1.0.137 and < 1.18.33, which carried the same chain.
  • The v1 Inspector line had a nastier sibling: GHSA-4fc4-wcmh-4g86, rated 9.6, let an OAuth callback's query parameters flip a remote session into local stdio command execution on any OS. That one is fixed in 1.0.3.

The fix npm update will not apply

All three Inspector dependency manifests pinned open as a caret range:

"open": "^10.2.0"
Enter fullscreen mode Exit fullscreen mode

A caret range never crosses a major version, so 10.x stays 10.x no matter how many times you run npm update. The maintainers had to bump deliberately to open 11.0.4 and add an overrides entry so the web client's transitive copy could not linger on 10.x. If your own tooling hands a remote peer's URL to open(), the same trap is waiting in your lockfile. Check what actually resolved:

npm ls open @modelcontextprotocol/inspector
Enter fullscreen mode Exit fullscreen mode

Manifest intent is not installed truth. This is the same discipline I keep hitting in agent configs, where a version pin or an allowlist silently drifts from what runs in production.

It did not stop at 2.12.0

On November 4 the Inspector shipped 2.14.0 with three more fixes, all affecting 2.13.0 and below (summary): a sandboxed MCP App frame could navigate away from the host's policy while the proxy kept relaying; importing a server.json could splice unvalidated runtime arguments into npx, uvx or docker run while the UI showed only the benign package name; and a server-supplied outputSchema.pattern could hang the UI with a ReDoS after a tool call. If your workflow is "point the Inspector at the weird server and watch", that middle one reads as a direct message to you.

How I treat the tool now

The lesson generalizes past this CVE. Every tool in the connect-and-observe path is attack surface, including the observer. Positron shipping IDE-wide MCP guardrails was the same realization at the IDE layer: the seat between you and the unknown server needs its own policy. When agents started picking deprecated MCP servers on their own, the answer was guardrails around the agent. This is the same problem one layer down, and the answer is the same shape.

If you vet unknown MCP servers:

  • Run the Inspector in a container or a throwaway VM, not on your daily Windows account. The chain needs the PowerShell branch, and a Linux container on a Windows host removes it.
  • Pin deliberately. A caret range on a package whose job is to touch the OS, like opening a browser or spawning a command, is a standing bet that its next major version does not matter. It sometimes does.
  • Re-grep the lockfile after news like this, not only during audits. open sits deep in a lot of dependency trees.

What to check today

  1. Inspector at 2.12.0 or later, 2.14.0 if you import server.json files. opencode at 1.18.33 or later. Any direct open dependency at 11.0.0 or later.
  2. On Windows, if you connected the Inspector to servers you no longer fully trust during October, treat it like any untrusted-code exposure: review scheduled tasks, run keys and approved SSH keys. The demo payload writes a file as your user, and a real one can do anything you can.
  3. On macOS and Linux you are clear of this particular chain, but check which Inspector line you run, because the v1 callback bug was cross-platform.

The protocol did not break here. A package boundary did. MCP's whole security model assumes the server may be hostile, and the Inspector's docs tell you to test untrusted servers with it. The tool that implements that advice has to be able to survive the advice, and for three weeks in October, on two platforms, it could not.

Top comments (1)

Collapse
 
techdevfun profile image
TECHDEV •

The npm ls check is the right instinct, but it assumes an install you can list. The way most people run the Inspector is npx @modelcontextprotocol/inspector — a one-shot fetch with no lockfile of your own, so there is nothing to npm ls. The resolved open version is whatever the published package declared at that moment, and a cached npx run can keep serving the pre-fix Inspector after the fix shipped. For a tool run this way, the version check that actually works is the Inspector's own version (2.12.0 or later, 2.14.0 if you import server.json), not the lockfile.

The deeper point you land on is the one I would underline: the macOS and Linux path already does this right — spawn with an argument vector, no shell. The Windows path handed a peer-controlled string to PowerShell and trusted quoting to make it safe. open 11 fixes the quoting, but the durable fix is the argv shape: never let a URL supplied by the server you are vetting reach a shell at all. Auto-open on by default makes that the default posture for exactly the tool whose job is to talk to servers you do not trust yet.

Do you think the Inspector should flip auto-open to opt-in when the target is a remote server, or is the container or VM advice the practical answer and the default stays?