Vulnerability Detail Report
Vulnerability Overview
- ZDID: ZD-2026-00912
- Vendor: 積木遊戲工坊
- Title: 積木遊戲工坊 網站儲存型跨站腳本 Stored XSS
- Introduction: 攻擊者無須登入即可透過分享作品功能,把 JavaScript 惡意程式碼儲存在作品的角色造型(`costume`)欄位中;當任何使用者開啟該分享連結 `https://ai-scratch.zeabur.app/play/<id>` 時,惡意程式碼會在網站來源(origin)下自動執行,且全程無需點擊互動。
處理狀態
目前狀態
-
新提交
-
已審核
-
已通報
-
未回報修補狀況
-
未複測
-
公開
處理歷程
- 2026/07/10 21:41:22 : 新提交 (由 Ianon 更新此狀態)
- 2026/07/10 21:47:18 : 新提交 (由 Ianon 更新此狀態)
- 2026/07/12 03:05:52 : 審核完成 (由 HITCON ZeroDay 服務團隊 更新此狀態)
- 2026/07/23 16:44:39 : 審核完成 (由 HITCON ZeroDay 服務團隊 更新此狀態)
- 2026/07/23 16:44:39 : 通報未回應 (由 HITCON ZeroDay 服務團隊 更新此狀態)
- 2026/07/23 16:44:39 : 通報未回應 (由 HITCON ZeroDay 服務團隊 更新此狀態)
- 2026/09/09 03:00:03 : 公開 (由 HITCON ZeroDay 平台自動更新)
詳細資料
- ZDID:ZD-2026-00912
- 通報者:Ianon (Ianon)
- 風險:中
- 類型:基於 DOM 的 XSS (DOM-based Cross-Site Scripting)
參考資料
漏洞說明: OWASP - Cross-site Scripting (XSS)
https://www.owasp.org/index.php/Cross-site_Scripting_(XSS)
防護原則:
https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet
XSS 防禦繞過方式:
https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet
相關網址
https://ai-scratch.zeabur.app/play/<id> (觸發執行的分享連結)
https://ai-scratch.zeabur.app/#p=<壓縮資料> (純前端 hash 變體,無需後端儲存)
敘述
網站前端在 js/app.js 的 renderSpriteList()(第 154–157 行)繪製角色卡片時,會把角色造型欄位 costume 的值未經跳脫(escape)直接寫入 innerHTML:
const faceContent = s.costume?.startsWith('img:')
? `<img src="/api/images/${s.costume.slice(4)}.png" alt="${escapeHtml(s.name)}" ...>`
: s.costume; // ← 原始字串,未跳脫
card.innerHTML = `<div class="face">${faceContent}</div>` +
`<div class="name">${escapeHtml(s.name)}</div>...`;
角色名稱 name 有經過 escapeHtml 處理,但 costume 沒有。當 costume 不是以 img: 開頭時,其原始內容會被當成 HTML 直接插入頁面。
作品資料來自未經身分驗證的分享功能:使用者開啟 /play/<id> 時,前端(js/app.js 第 66–84 行)會呼叫 Storage.loadFromServer(id) 取回作品並立即渲染;#p= hash 變體則完全在前端還原。兩條路徑都會把攻擊者可控的 sprites[].costume 送進上述 sink,在頁面載入時自動執行,無需受害者任何互動。網站回應標頭中沒有 Content-Security-Policy,因此沒有任何機制可阻擋 payload 執行。
重現步驟
1. 植入惡意作品(無需登入)
- 對
POST /api/projects送出含惡意costume的作品。 - 伺服器回傳分享 id,且 payload 原封不動被儲存(以
GET /api/projects/<id>驗證)。POST /api/projects HTTP/2 Host: ai-scratch.zeabur.app Content-Type: application/json
{"name":"poc","sprites":[{"id":"s1","name":"poc",
"costume":"<img src=x onerror=alert(document.domain)>",
"x":0,"y":0,"dir":90,"size":100,"visible":true,"workspace":null}]}
回應:{"id":"-ls_gM6"}
(實測回應與儲存內容見證據截圖三,payload 原封不動存回。)
**2. 誘使受害者開啟分享連結**
- 將 `https://ai-scratch.zeabur.app/play/-ls_gM6` 傳給受害者。
- 預期結果:頁面一載入即執行注入的 `onerror`,跳出 `alert` 對話框顯示
`ai-scratch.zeabur.app`,證明惡意 JavaScript 已在網站來源(origin)下執行。
- 實測(2026-07-10):於一般瀏覽器開啟即彈出 `ai-scratch.zeabur.app` 的 alert
(證據截圖一);DevTools 可見注入的 `<img src="x" onerror="alert(document.domain)">`
節點位於 `div.sprite-card > div.face` 內(證據截圖二),確認成功執行。修補建議
1. **不要把 `costume` 未經處理放進 `innerHTML`**:
將 `costume` 視為「白名單內的 emoji」或「`img:<id>` 參照」兩種合法型態;其餘一律拒絕或跳脫。
最佳做法是改用 DOM API(`document.createElement`、`textContent`、以驗證過的 id 設定 `img.src`)
建立角色卡片,避免以字串串接產生 HTML:
```js
const face = document.createElement('div');
face.className = 'face';
if (s.costume?.startsWith('img:') && /^[A-Za-z0-9_-]+$/.test(s.costume.slice(4))) {
const img = document.createElement('img');
img.src = `/api/images/${s.costume.slice(4)}.png`;
img.alt = s.name;
face.appendChild(img);
} else {
face.textContent = s.costume ?? ''; // emoji 以純文字呈現,不會變成標記
}
```
2. **後端輸入驗證**:`POST /api/projects` 應對 `sprites[].costume` 做正規化/驗證
(限定為允許的 emoji 集合或 `img:<合法 id>`),使分享快照無法夾帶 HTML 標記。