
前言#
承接上篇 [Security] 前端攻擊在 Web3 的應用、XSLeaks、XS-Search 與 Cache probing,此篇主要敘述的是《Beyond XSS:探索網頁前端資安宇宙》 6–1~6–5 章節的筆記,也是這系列最後一篇囉🎉,若有錯誤歡迎大家回覆告訴我~
實際前端攻擊案例通常會串連各種漏洞、逐層繞過限制再攻擊目標,不限於單一漏洞或主題,此篇會介紹幾個實際案例。
差一點的 Figma XSS#
Figma 是一個設計協作工具,前端開發者應該不陌生,設計師可將設計稿放上去,工程師可在上面查看每個地方的屬性、色碼或元素間距。
2023 年賞金獵人 Sudhanshu Rajbhar(簡稱 sudi)開始尋找 Figma 漏洞,開啟一系列 Figma 探索之旅…。
背景#
使用者可透過 Figma 提供的連結對外公開自己的設計稿,此設計稿可設定公開敘述,支援部分 HTML 標籤,若在前端插入不支援的標籤,送到後端是編碼過的結果。
{ "name": "Published to Community hub", "description": "<p><strong><em>shirley<img src=x onerror=alert()></em></strong></p>"}可能的問題#
若攔截請求並修改,可送出沒編碼的結果給後端,因為後端收到結果後不會再處理,sanitization 是由前端處理。前端 sanitization 相關程式如下:
// 新建一個 divlet p = document.createElement('div');
// e 是使用者的輸入,也就是 description// 先把 e 的內容放入 div 中p.innerHTML = e;
// 先用 CSS 選出所有不在名單中的元素let f = IFs.map(y=> `:not(${y})`).join("") , g = p.querySelectorAll(f);
// 把每一個不在名單中的元素移除 for(let y of g) (h = y.parentNode) == null || h.removeChild(y);
// 最後呼叫 DOMPurify 做 sanitization r.current.innerHTML = HAm.default.sanitize(p.innerHTML)Figma 做了兩次 sanitization,看起來沒問題,兩次分別為:
- 以 CSS 做一次:允許標籤只有 20 種,且允許的都是安全標籤,沒有危險的
<iframe>、<style>。以下是允許的標籤。
['a', 'span', 'sub', 'sup', 'p', 'b', 'i', 'pre', 'code', 'em', 'strike', 'strong', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'hr', 'br']- 以 DOMPurify 再做一次
然而有個瀏覽器特別的地方會讓這段 sanitization 可能有問題。以下程式碼,照理來說沒事,實際運行卻被執行。
let div = document.createElement("div");div.innerHTML = "<img src=x onerror=alert()>";原本想說,沒有真的將元素插入畫面、沒有執行 document.body.appendChild(div) 因此不會執行到 onerror,但實際運行時,會跳出 alert。原因是即使沒有將元素插入 document,瀏覽器還是會試圖載入圖片、執行 event handler。
這個瀏覽器特別行為代表,不管後續操作是否將元素放到畫面上,只要有一瞬間將使用者輸入放入 innerHTML,就可觸發 XSS。因此 Figma 即使做了兩次 sanitization,但在 innerHTML 那段程式就可能有問題。
攻擊的限制#
Figma 有嚴格 CSP 如下:
script-src 'unsafe-eval' 'nonce-PVEIuETDGJR+8hIA6PqgIQ==' 'strict-dynamic' ;CSP 沒有允許 unsafe-inline,event handler 無法成功繞過 CSP,因此就算可以在 innerHTML 放入惡意程式碼,CSP 也會阻止你執行,XSS 攻擊不會成功。
而這種「被 CSP 擋掉的 XSS」一般不會被公司視為危害,但有的公司會認為這是潛在漏洞而給獎金,此案例回報後有取得 1000 美金賞金。更多詳細說明可看 Interesting case of a DOM XSS in www.figma.com。
繞過層層防禦的 Proton Mall XSS#
Proton Mail 是一個標榜隱私和安全的 mail 服務,2022 年 6 月資安公司 Sonar 找到 Proton Mail 網頁版 XSS 漏洞,攻擊影響力是此攻擊可讀到目標所有信件。
背景#
網頁 render 信件時,為了呈現其中的圖片、超連結等,會以 HTML 形式 render 出來,每封信件其實背後都是 HTML。render 信件 HTML 前,需經過 sanitization 過濾不安全標籤,避免攻擊者在信件塞 <img src=x onerror=alert(1)> 這類 XSS payload。
Proton Mail 也有用 DOMPurify 做 sanitization,不過在 DOMPurify sanitization 後,又額外做了操作。
const LIST_PROTON_TAG = ['svg'];// [...]const sanitizeElements = (document: Element) => { LIST_PROTON_TAG.forEach((tagName) => { const svgs = document.querySelectorAll(tagName); svgs.forEach((element) => { const newElement = element.ownerDocument.createElement(`proton-${tagName}`); // [...] element.parentElement?.replaceChild(newElement, element); }); });};上述程式碼做了幾件事:
- 選出所有 svg,接著新增 proton-svg 元素
- 將 svg 的東西移到 proton-svg 內(proton-svg 是 proton 自製元件)
Proton Mail 過濾了內容,只替換外層元素,看似安全,但可能會有些問題。
替換外層元素可能會有問題#
網頁有些標籤的解析規則和一般 HTML 不同,例如:MathML 和 SVG 解析和一般 HTML 不同。這點在 [Security] 最新 XSS 防禦 Trusted Types & Sanitizer API,以及 XSS 防禦繞過手法這篇裡的「瀏覽器貼心服務」有提過。這裡再簡單說明一次。
原本<style> 內的東西會被瀏覽器解析為文字,以下程式碼,<style> 內的 <a> 會被解析為文字,而非標籤。
<div> <style> <a id="a"></a> </style></div>若 <style> 被 svg 包住,則 <style> 內東西會被解析為 HTML 標籤,以下程式碼,若 <style> 被 svg 包住,則 <style> 內東西會被解析為 HTML 標籤。
<svg> <style> <a id="a"></a> </style></svg><style> 標籤內的解析差異會有什麼問題?
舉例來說,以下會被解析為 <style> 內有個 <a> 標籤,且 <a> 的 id 是 </style><h1>hello</h1>。
<svg> <style> <a id="</style><h1>hello</h1>"></a> </style></svg>但如果將外層 <svg> 換成 <div>,<style> 內會被解讀為文字。
<div> <style> <a id=" </style> <h1>hello</h1>"></a> </style></div>遇到 </style> 會提前閉合 <style> 標籤,提前閉合後,後面的 <h1> 變成新元素。由這範例可知,<style> 外層是 HTML 還是 SVG,會影響瀏覽器的解讀方式。
回到 Proton Mail,Proton Mail 將外層 svg 換成自定義 tag,會讓原本看似安全的 HTML 變不安全,此處理方式可繞過檢查、插入任意 tag。
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.11/purify.min.js"></script><body> <div id=app></div></body><script> const input = ` <svg> <style> <a id="</style><img src=x onerror=alert(1)></a> </style> </svg> ` // 先用 DOMPurify 過濾 const doc = DOMPurify.sanitize(input, { FORBID_TAGS: ['input', 'form'], WHOLE_DOCUMENT: true, RETURN_DOM: true }) console.log(doc)
// 把外層元素替換掉 const element = doc.querySelector('svg') const newElement = doc.ownerDocument.createElement('div') while (element.firstChild) { newElement.appendChild(element.firstChild) } element.parentElement?.replaceChild(newElement, element)
// 把弄好的結果放到畫面上 app.innerHTML = doc.innerHTML</script>Proton Mail 第二道防線:iframe sandbox#
Proton Mail 處理好的 HTML 會被放在 iframe 內,而 iframe 有 sandbox 屬性,iframe sandbox 允許的內容如下:
allow-same-originallow-popupsallow-popups-to-escape-sandbox
iframe sandbox 沒有 allow-script,無法執行 JavaScript,因此即使前面我們插入任意 tag,也無法執行。
但 iframe sandbox 可被繞過,allow-popups-to-escape-sandbox 允許新開視窗,新跳的視窗就不受 sandbox 限制。另外,Safari 上的 sandbox 有加 allow-script 屬性,allow-same-origin + allow-script 等於 sandbox 沒限制。
補充一點,為何只有在 Safari 加 allow-script 屬性?因為 Proton Mail 用到的 React Portals 在 Safari 上需要 allow-script 屬性才能運作。
Proton Mail 第三道防線:CSP#
Proton Mail CSP 規則如下:
default-src 'self'style-src 'unsafe-inline'img-src httpsscript-src blob:
CSP 的 script-src 會列出執行 JavaScript 須符合的規則,而 blob: 是利用 createObjectURL 產生出的 URL 才會有的協定,範例程式碼如下:
const blob = new Blob(['<h1>hello</h1>'], { type: "text/html",});const url = URL.createObjectURL(blob);console.log(url);// blob:https://medium.com/b5561157-613b-4579-9841-636d90fa21dbnew Blob 會產生新 blob 物件,接著利用 URL.createObjectURL 建立 blob URL,建立的 blob URL 會和在哪個 origin 產生有關,如果是在 https://medium.com 產生 blob URL,網址前半段就是此 domain,而產生後的 URL 可視為一般網址,可放在任何可放 URL 的地方,例如:<img src>、<script src>、<a href>、瀏覽器網址列。
Proton Mail 用到 blob 的原因是為了處理電子郵件中的圖片 render。每個附件都有個 ID,當 img src 格式為 cid:ID 時,Proton Mail 將對應附件轉換為 blob URL,以生成的 blob URL 替換 img 的 src。舉例來說,有張圖片 test.png ID 是 ad3c25,在信件中放 <img src="cid:ad3c25"> 後,此圖片會被轉為 blob, 變成 <img src="blob:https://mail.proton.me/8c2a19fa-8dcd-44d1-807c-1c65abef0251"> 之類的東西。
Proton Mail 處理 blob 的方式和 CSP 檢查的關聯是什麼?其問題在於 Proton Mail 將附件轉為 blob 時,沒檢查 content type,導致攻擊者可用以下方式攻擊:
- 上傳 JavaScript 檔案,用上述方式將它轉為 blob
- 將產出的 blob URL 放入
<script>以符合script-src blob:規則 - 繞過 CSP,成功執行 JavaScript
取得 blob URL#
要如何取得 blob URL 來執行攻擊?取得 blob URL 後才能放入 <script> 觸發執行 JavaScript。blob URL 格式是 blob + origin + UUID,已知開頭為 blob:https://mail.proton.me/,但不知 UUID。UUID 會放在 img 標籤屬性內,在不執行 JavaScript 情況下,要如何偷到 HTML 屬性內容?
可用之前提過的 CSS injection,以 CSS 選擇器加上圖片載入向外發請求,竊取 img src 屬性內的 blob URL。不過 CSS injection 有個前提,須利用 @import 持續載入新 CSS,逐字元偷資料,但 Proton Mail CSP 的 style-src 'unsafe-inline' 不允許載入新的、動態內容,只能有一個靜態 CSS,代表最多只能偷一個字。
那有沒有不用 import 新 CSS 也能偷資料的方法?import 新 CSS 的偷資料方法是以 img[src^=] 選擇器指定字串開頭,但是有另一種不用 import 新 CSS 的偷資料方法,步驟如下:
- 以
img[src*=]選擇器指定有包含這字元的字串,可得知 src 內有哪些字元,但不知道順序 - 如何知道順序? 一次使用三個字並窮舉所有三個字組合。如:
img[src*="abc"]可知字串內有abc這串字
UUID 字元集是0-9a-f-,可窮舉所有三個字的組合,窮舉如:001、002、003、…、fff - 得知字串中所有連續三個字的字元組合,再拼接得到原始 blob URL
如:UUID 有段是8b723997-737a,可收到下列請求
- 8b7
- b72
- 723
- 239
- 399
- 997
- 97-
- 7–7
- -73
- 737
- 37a
可拼一起的字串符合 wxy + xyz 規則(後兩碼和前兩碼相同),依拼湊規則可得到原始結果。
此方式的缺點是可能不只一個組合,若要降低碰撞的機率,須提升一組的字元數(e.g. 每4個字組合),但會讓檔案大小更大。經研究,3 個字元組合的碰撞機率小、檔案大小可維持 1MB 內。
攻擊鏈步驟#
- 寄一個夾帶 JavaScript 程式碼附件的電子郵件給受害目標
- 信件內容為
<style>標籤和img[src*="aaa"]選擇器 - CSS 洩漏出 blob URL 給 server
- Server 得知 blob URL 後,再寄第二封信給受害目標
- 內容是
<script src="blobUrl">,且利用 sanitization bypass 讓此 script 可被執行
- 受害目標點開第二封信後,就會執行 script 載入 blobUrl,也就是執行第一封信的 JavaScript,達成 XSS
另外,書中還有提到非 Safari 瀏覽器繞過 iframe sandbox 的方式,這裡就不細講,有興趣可去看書中說明~
攻擊影響力#
XSS 在信件網站上執行,可直接偷畫面上其他元素,例如可偷其他有機密資訊的 email 內容。
更多詳細說明可見 Code Vulnerabilities Put Proton Mails at Risk。
隱藏在 Payment 功能中的 Chrome 漏洞#
過去處理金流服務的方式是開發者需串接不同金流服務的 API,例如要串接綠界支付,就要串接綠界 API、要串接 PayPal 就要串接 PayPal 的 API。考量資安,網頁前端通常拿不到使用者信用卡號,像是 Stripe 會在頁面放入自己的 iframe,網頁無法存取 Stripe 的 iframe 資訊,只能等 Stripe 主動將處理過的資訊傳回。
而 Payment Request API 是瀏覽器的新 feature,透過標準做法處理第三方支付,使用方法就是輸入商品資訊和第三方 URL,就可直接顯示第三方支付頁,不須串接各金流服務的 API。程式碼範例如下:
<!DOCTYPE html><html lang="ja"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Payment Request Demo</title></head><body> <h1>Payment Request Demo</h1> <button onclick="pay()">支付</button>
<script> async function pay() { // 檢查是否支援 PaymentRequest if (!window.PaymentRequest) { alert('瀏覽器不支援'); return; }
// 輸入第三方的網址 const supportedInstruments = [{ supportedMethods: 'https://bobbucks.dev/pay' }];
// 商品資訊 const details = { displayItems: [ { label: '商品 1', amount: { currency: 'TWD', value: '200' } } ], total: { label: '合計', amount: { currency: 'TWD', value: '200' } } };
// 發起 PaymentRequest const request = new PaymentRequest(supportedInstruments, details);
try { // 顯示第三方支付頁面 const paymentResponse = await request.show(); await paymentResponse.complete('success'); alert('支付成功'); } catch (err) { alert('支付失敗'); console.error(err); } } </script></body></html>點擊支付按鈕後,會跳出第三方支付頁面,使用者就可在彈窗進行支付。
Payment Request API 背後原理#
- 呼叫 Payment Request API 時,瀏覽器會依據 URL 發請求
- 從收到的 response Link header 找 manifest 檔
manifest 檔案 URL 為https://bobbucks.dev/pay/payment-manifest.json,檔案內容如下:
{ "default_applications": ["https://bobbucks.dev/pay/manifest.json"], "supported_origins": ["https://bobbucks.dev", "https://webauthn.org", "https://webauthn.org:8443", "https://webauthn.org:8000", "https://webauthn05.noknoktest.com:8443", "https://gogerald.github.io", "https://upay.noknoktest.com", "https://rsolomakhin.github.io"]}- 從第二步的 manifest 檔案中找到另一個
https://bobbucks.dev/pay/manifest.json檔案,內容如下:
{ "name": "Pay with BobBucks", "short_name": "BobBucks", "icons": [ { "src": "tree.png", "sizes": "48x48", "type": "image/png" } ], "serviceworker": { "src": "sw-bobbucks.js", "use_cache": false }, "prefer_related_applications": true, "related_applications": [ { "platform": "play", "id": "dev.bobbucks", "min_version": "1", "fingerprints": [ { "type": "sha256_cert", "value": "4A:4C:7D:C9:98:C4:28:24:9B:21:A5:90:7D:32:A7:5A:ED:E1:64:81:7F:B1:4B:2A:59:22:0D:95:1F:B8:6D:E3" } ] } ]}- 第三步的檔案中定義了一個 service worker(
https://bobbucks.dev/pay/sw-bobbucks.js),Chrome 會載入此 service worker 處理支付邏輯,以下為 service worker 部分程式碼:
self.addEventListener('canmakepayment', function(e) { e.respondWith(true);});
self.addEventListener('paymentrequest', function(e) { payment_request_event = e;
payment_request_resolver = new PromiseResolver(); e.respondWith(payment_request_resolver.promise);
var url = "https://bobbucks.dev/pay"; // The methodData here represents what the merchant supports. We could have a // payment selection screen, but for this simple demo if we see alipay in the list // we send the user through the alipay flow. if (e.methodData[0].supportedMethods[0].indexOf('alipay') != -1) url += "/alipay.html";
e.openWindow(url) .then(window_client => { if(window_client == null) payment_request_resolver.reject('Failed to open window'); }) .catch(function(err) { payment_request_resolver.reject(err); });});金流服務提供商的角度
以金流服務提供商的角度,需要準備以下:
- 實作付款頁面
pay.html - 提供一個 service worker 檔案
sw.js,其中呼叫e.openWindow("pay.html")顯示付款頁 - 提供 API
https://pay.monica.tw/api並在 response 回傳https://pay.monica.tw/manifest.json,此 manifest.json 內再指定一個https://pay.monica.tw/app_manifest.json,app_manifest.json內指定再載入sw.js
整體流程示意圖如下:

另也可省略從 /manifest.json 找 /app_manifest.json 流程,API response 直接回傳 manifest 本身。

Payment Request API 的問題#
背景#
網站提供上傳與下載檔案功能,原本不會有問題。舉例來說, files.example.com 下載檔案時會有 response header Content-Disposition: attachment,此 header 是用來告知瀏覽器此檔案應該被下載,因此訪問 files.example.com/huli123/index.html 時會觸發檔案下載,而非載入 HTML,不會有 XSS 問題。
Payment Request 會從 response 獲得內容,最後找到 sw.js 並執行此 service worker,為了 Payment Request 可上傳以下三個檔案,瀏覽器最後會安裝上傳的 service worker。
https://files.example.com/monica123/manifest.jsonhttps://files.example.com/monica123/app_manifest.jsonhttps://files.example.com/monica123/sw.js
問題#
service worker 等於網頁和瀏覽器中間的 proxy,可攔截請求回傳自定義 response,範例如下:
self.addEventListener("fetch", (event) => { const blob = new Blob( ["<script>alert('xss')</script>"], { type: "text/html" } );});event.responseWith(new Response(blob));當 https://files.example.com/monica123/sw.js 被載入並執行,就可在 files.example.com 註冊 service worker,能執行任意程式碼,代表獲得 files.example.com 的 XSS。換句話說,利用 Payment Request API 繞過下載檔案無法執行 HTML 的限制,可用 service worker 執行任意程式碼。
整體攻擊流程#
victim.com網站可以上傳任意 .json 跟 .js,上傳後可以拿到類似victim.com/mycode.json的網址,但直接訪問會下載檔案不會在victim.com上執行,沒辦法執行到 XSS- payment request API 漏洞,讓我們可以上傳
victim.com/manifest.json然後裡面標示惡意victim.com/sw.js(victim.com/sw.js也是我們上傳的檔案) - 找到任意網頁可執行 JavaScript,在該網頁 call payment request API 請求
victim.com/manifest.json,然後觸發victim.com註冊執行惡意victim.com/sw.js,達成victim.com的 XSS
修復方式#
- 取消「manifest 內容可直接去 response 找」功能
- 強制第一步 manifest 需透過 response header 傳遞,避免載入使用者自己上傳的檔案
為何這樣可以修復問題?當 request API 不能直接看 response 的 json 檔案,而是改為要看 response header 時,因為通常不會有網站讓你控制 response header,所以就沒辦法偽造 manifest。
對更多資訊有興趣可參考 CVE-2023–5480: Chrome new XSS Vector。
從 Protptype Pollution 到 Bitrix24 XSS#
Bitrix24 是一個提供 CRM、專案管理、團隊協作系統的工具,2023 年 STAR Laba 發現了 Bitrix24 Protptype Pollution 引發的 XSS 漏洞。
背景#
Bitrix24 解析 query string 的函式程式碼如下,傳入 URL 的 query string,會回傳解析好的物件。
function parseQuery(input) { if (!Type.isString(input)) { return {}; }
const url = input.trim().replace(/^[?#&]/, '');
if (!url) { return {}; }
return url.split('&').reduce((acc, param) => { const [key, value] = param.replace(/\+/g, ' ').split('='); const keyFormat = getKeyFormat(key); const formatter = getParser(keyFormat); formatter(key, value, acc); return acc; }, {});}getKeyFormat 會依內容決定格式,e.g. a[b] 屬於 index;a[] 屬於 bracket;都不是則 default。
function getKeyFormat(key) { if (/^\w+$/.test(key)) { return 'index'; } if (/^\w+\[\w+\]$/.test(key)) { return 'bracket'; } return 'default';}接著依據格式決定如何解析,若輸入 a[b] = 1,則 a 是 key,b 是 result[1],最後會執行 accumulator["a"]["b"] = 1。
function getParser(format) { switch (format) { case 'index': return (sourceKey, value, accumulator) => { // 取出 [] 中的部分 const result = /\[(\w*)\]$/.exec(sourceKey);
// 把 [] 前面的部分拿出來 const key = sourceKey.replace(/\[\w*\]$/, '');
if (Type.isNil(result)) { accumulator[key] = value; return; }
if (Type.isUndefined(accumulator[key])) { accumulator[key] = {}; }
// 設置物件 accumulator[key][result[1]] = value; };
case 'bracket': // parse bracket style keys
default: // parse default style keys }}問題:getParser 的 prototype pollution 漏洞#
若輸入 __proto__[test] = 1,getParser 最後會執行 accumulator["__proto__"]["test"] = 1,accumulator 是物件,物件的 __proto__ 是 Object.prototype,等於執行 Object.prototype.test = 1,汙染原型鏈。
污染原型鏈後,接著尋找可利用原型鏈來攻擊的地方。以下是前端渲染頁面的 render function。
BX.render = function(item){ var element = null;
if (isBlock(item) || isTag(item)) { var tag = 'tag' in item ? item.tag : 'div'; // [6] var className = item.block; var attrs = 'attrs' in item ? item.attrs : {}; var events = 'events' in item ? item.events : {}; var props = {};
// Load props, atts and events element = BX.create(tag, {props: props, attrs: attrs, events: events, children: children, html: text}); }
// ...
return element;};此程式碼依參數 item 建立元素,若 item.tag 不存在,tag 使用 div;若存在則使用 item.tag。而攻擊者可用 prototype pollution 汙染 Object.prototype.tag 以建造任意 tag。
BX.create 會再呼叫另一個函式 Dom.adjust,其中會依 data 的 text 和 html 屬性決定設置 innerText 或 innerHTML。
function adjust(target, data = {}) { if (!target.nodeType) { return null; }
let element = target;
if (target.nodeType === Node.DOCUMENT_NODE) { element = target.body; }
if (Type.isPlainObject(data)) {
// Initialize element attrs, event handlers, styles
if ('text' in data && !Type.isNil(data.text)) { element.innerText = data.text; // [7] return element; }
if ('html' in data && !Type.isNil(data.html)) { element.innerHTML = data.html; } }
return element;}攻擊方式#
- 在 render function 將 tag 汙染為 script
- 在 adjust function 將 text 汙染為 JavaScript 程式碼,以此 render 任意內容的
<script>標籤,達成 XSS
XSS 攻擊影響力#
若被 XSS 的受害者有 admin 權限,可直接呼叫 php_command_line.php API,直接在機器執行 PHP 程式碼。
更多詳細說明可參考 (CVE-2023–1717) Bitrix24 Cross-Site Scripting (XSS) via Client-side Prototype Pollution。
PHP 底層 bug 引發的 Joomla! XSS#
Joomla! 是一個 CMS 內容管理系統,功能類似 WordPress,可在後台上傳文章、修改頁面、設定樣式,實際應用如:部落格、公司形象網站、購物網站。
2023 年資安公司 Sonar 回報了一個 Joomla! XSS 漏洞,不過嚴格來講是底層機制的問題。
背景#
Joomla! 的 cleanTags 函式會處理使用者輸入,並清除所有標籤,清除方式是找出在 < 之前的文字,和 > 之後的文字。舉例 hello<h1>titile</h1>123 的處理方式如下:
- 取出
<之前的文字hello,和>之後的文字titile</h1>123 - 繼續處理
titile</h1>123,取出<之前的文字titile,和>之後的文字123 - 得到
hellotitle123
部分的實作程式碼會像這樣:
<?php $input = "hello<h1>"; $end = mb_strpos($input, '<'); $output = mb_substr($input, 0, $end); echo $output; // hello?>mb_strpos是PHP 提供的函式,可找出字串的位置;mb_substr也是PHP 提供的函式,會取出字串的其中一部份。舉例來說,< 在字串的 index 是 5,$end 就是 5,mb_substr 會取出 index 0 到 5 之間的字串,$output 就是 hello。
PHP 還有另一個內建函式 substr,那為何是 mb_substr 而非 substr?mb_substr 的mb 代表 multi-byte,可取得超過一個 byte 的單一字元;而substr則是會以 byte 為單位取字。以下面這段來說,用 mb_substr 取出第一個字,可成功取得「你」;用 substr 取出第一個字,會輸出看似亂碼的內容,因為「你」字元佔據 3 個 bytes,而 substr 只取第一個 byte。因此通常會選有 mb_ 開頭的字串處理函式。
<?php $input = "你好"; echo mb_substr($input, 0, 1); echo substr($input, 0, 1);?>不過如果尋找字串和取字串的函式統一用或不用 byte,就沒問題,以下程式碼中,mb_strpos 找到 index 為 1 的 好,mb_substr 根據 index 取對應值;而 strpos 找到 index 為 3 的 好,substr 根據 index 取對應值,都沒問題👌。
<?php $input = "你好"; $end = mb_strpos($input, '好'); $output = mb_substr($input, 0, $end); echo $end; // 1 echo $output; // 你
$end = strpos($input, '好'); $output = substr($input, 0, $end); echo $end; // 3 echo $output; // 你?>字元編碼方式補充
在繼續講此案例的漏洞(問題)之前,先補充一下字元編碼小知識。
Unicode 是目前廣泛使用的字元集,Unicode 每個文字都有自己獨一無二的代號 code point,例如「你」的 code point 是 U+4F6。但是 Unicode 沒有定義該如何儲存 code point,也就是說沒定義 U+4F6 該如何儲存。因此有不同的編碼(儲存)方式例如:
- UTF-32:每個字元的 code point 固定用 4 個 bytes 儲存
- UTF-8:每個字元的 code point 會編碼為 1 ~ 4 個 bytes(長度不固定)
那程式語言如何處理長度不固定的字元編碼?如何知道此字元被編碼為幾個 bytes?
它根據不同編碼長度的固定規則來判斷,1 個 byte 有自己固定的規則、2 個 bytes 有自己固定的規則…,規則例如:
- 第一個 byte 二進位開頭是
110,代表共 2 個 bytes - 第一個 byte 二進位開頭是
1110,代表共 3 個 bytes 1110xxxx 10yyyyyy 10zzzzzz二進位的格式,代表共 3 個 bytes
例如「你」被 UTF-8 編碼的 3 個 bytes 為 0xE4 0xBD 0xA0,0xE4 換算二進位為 1110 0100,符合「共 3 個 bytes」規則,因此判定「你」共 3 個 bytes。又例如11100100 01100001 01100001 是不合法 UTF-8 格式,因為後兩者不是 10 開頭。
問題#
此案例的問題是mb_strpos 和 mb_substr 對不合法 UTF-8 字串的處理不一致。
mb_substr 對不合法格式的處理方式是它只看開頭判斷它是幾個 bytes 的字,後面不管,例如以下範例:

第一個 byte \xE4 換成二進位是 11100100,代表共 3 個 bytes,而最後一位 0x61 不符合 10zzzzzz 格式,代表不是合法格式。但mb_substr 只看第一個 byte 判斷,因此認定是 3 個 bytes。
mb_strpos 對不合法格式的處理方式則是碰到不合法字元就停止,不會納入計算,示意圖如下:

而這兩者解析不一致會導致取得子字串時,結果與預期不同,以下面範例來說,對 mb_strpos,< 是第 6 個字;但對 mb_substr,e 是第 6 個字。因此取前 6 個字時,會得到 \xE4\xBDabcd<,多拿到 <,而非預期的 \xE4\xBDabcd。

這個多取得的 < 會讓 sanitization 失效,可插入任意標籤、內容,達成 XSS。
修復方式#
- Joolma! 將
mb_strpos和mb_substr換成strpos和substr,strpos和substr都是每個 byte 處理,不管 UTF-8 是否合法 - PHP 本身也修復此漏洞,讓
mb_substr和mb_strpos行為一致
更多詳細說明可參考 Joomla: PHP Bug Introduces Multiple XSS Vulnerabilities。
結語#
《Beyond XSS:探索網頁前端資安宇宙》介紹前端資安的各面向議題,內容包含:
- XSS 與防禦手法:介紹各種 XSS 攻擊類型及防禦策略,如 sanitization、CSP、Trusted Types 和 Sanitizer API
- 不執行 JavaScript 的攻擊方式:探討不執行 JavaScript 的攻擊方式,包括 prototype pollution、DOM clobbering 與 CSS injection,強調前端資安不僅限於 JavaScript
- 跨域與瀏覽器安全議題:說明 origin 與 site 的差異、CORS 配置錯誤的風險、CSRF 攻擊及 same-site cookie,強調層層防禦的重要性
- 其他資安議題:探討點擊劫持、MIME type 偵測漏洞、供應鏈攻擊、Web3 資安挑戰,並介紹 XSLeaks 的攻擊思維
- 實際案例探討:了解實際攻擊案例,看看真實世界中的資安漏洞,同時引發對攻擊手法的興趣
另外,如果對前端資安很感興趣,huli 大在這篇有列出可進一步參考的資源~
最後是自己的小小總結!這是《Beyond XSS:探索網頁前端資安宇宙》筆記系列的最後一篇囉🥳,太感人…,前端資安真的是自己很陌生的領域,透過這本書認識很多新東西,真的要說最有收穫的應該是跨域安全、origin 與 site 的差異那邊,去查找 spec 文件真的頭痛但也很有趣~不過因為自己對資安這塊真的不太熟,沒辦法像 React 讀書筆記系列還可以去引用更多其他資源、或是做更多舉例與示意圖,這系列就比較以書中敘述為主,小可惜QQ 最後感謝 Lois 舉辦的讀書會督促我把書看完!還有讀書會的小夥伴們,有大家一起讀書真的比較有動力~
Reference:#
如有任何問題歡迎聯絡、不吝指教✍️