Skip to content

[Security] 前端攻擊案例:差一點的 Figma XSS、Proton Mall XSS、Payment Request 漏洞、Bitrix24 XSS 與 Joomla! XSS

· 31 min

前言#

承接上篇 [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&lt;img src=x onerror=alert()&gt;</em></strong></p>"
}

可能的問題#

若攔截請求並修改,可送出沒編碼的結果給後端,因為後端收到結果後不會再處理,sanitization 是由前端處理。前端 sanitization 相關程式如下:

// 新建一個 div
let 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,看起來沒問題,兩次分別為:

  1. 以 CSS 做一次:允許標籤只有 20 種,且允許的都是安全標籤,沒有危險的 <iframe><style>。以下是允許的標籤。
['a', 'span', 'sub', 'sup', 'p', 'b', 'i', 'pre', 'code', 'em', 'strike', 'strong', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'hr', 'br']
  1. 以 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);
});
});
};

上述程式碼做了幾件事:

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 允許的內容如下:

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 規則如下:

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-636d90fa21db

new 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,導致攻擊者可用以下方式攻擊:

  1. 上傳 JavaScript 檔案,用上述方式將它轉為 blob
  2. 將產出的 blob URL 放入 <script> 以符合 script-src blob: 規則
  3. 繞過 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 的偷資料方法,步驟如下:

  1. img[src*=] 選擇器指定有包含這字元的字串,可得知 src 內有哪些字元,但不知道順序
  2. 如何知道順序? 一次使用三個字並窮舉所有三個字組合。如:img[src*="abc"] 可知字串內有 abc 這串字
    UUID 字元集是 0-9a-f-,可窮舉所有三個字的組合,窮舉如:001、002、003、…、fff
  3. 得知字串中所有連續三個字的字元組合,再拼接得到原始 blob URL
    如:UUID 有段是 8b723997-737a,可收到下列請求

可拼一起的字串符合 wxy + xyz 規則(後兩碼和前兩碼相同),依拼湊規則可得到原始結果。

此方式的缺點是可能不只一個組合,若要降低碰撞的機率,須提升一組的字元數(e.g. 每4個字組合),但會讓檔案大小更大。經研究,3 個字元組合的碰撞機率小、檔案大小可維持 1MB 內。

攻擊鏈步驟#

  1. 寄一個夾帶 JavaScript 程式碼附件的電子郵件給受害目標
  1. Server 得知 blob URL 後,再寄第二封信給受害目標
  1. 受害目標點開第二封信後,就會執行 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 背後原理#

  1. 呼叫 Payment Request API 時,瀏覽器會依據 URL 發請求
  2. 從收到的 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"]
}
  1. 從第二步的 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"
}
]
}
]
}
  1. 第三步的檔案中定義了一個 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);
});
});

金流服務提供商的角度

以金流服務提供商的角度,需要準備以下:

  1. 實作付款頁面 pay.html
  2. 提供一個 service worker 檔案 sw.js,其中呼叫 e.openWindow("pay.html") 顯示付款頁
  3. 提供 API https://pay.monica.tw/api 並在 response 回傳 https://pay.monica.tw/manifest.json,此 manifest.json 內再指定一個 https://pay.monica.tw/app_manifest.jsonapp_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。

問題#

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 執行任意程式碼。

整體攻擊流程#

  1. victim.com 網站可以上傳任意 .json 跟 .js,上傳後可以拿到類似 victim.com/mycode.json 的網址,但直接訪問會下載檔案不會在 victim.com 上執行,沒辦法執行到 XSS
  2. payment request API 漏洞,讓我們可以上傳 victim.com/manifest.json 然後裡面標示惡意 victim.com/sw.js(victim.com/sw.js 也是我們上傳的檔案)
  3. 找到任意網頁可執行 JavaScript,在該網頁 call payment request API 請求 victim.com/manifest.json,然後觸發 victim.com 註冊執行惡意 victim.com/sw.js,達成 victim.com 的 XSS

修復方式#

為何這樣可以修復問題?當 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] = 1getParser 最後會執行 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 屬性決定設置 innerTextinnerHTML

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;
}

攻擊方式#

  1. 在 render function 將 tag 汙染為 script
  2. 在 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 的處理方式如下:

  1. 取出 < 之前的文字 hello,和 > 之後的文字 titile</h1>123
  2. 繼續處理 titile</h1>123,取出 < 之前的文字 titile,和 > 之後的文字 123
  3. 得到 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 該如何儲存。因此有不同的編碼(儲存)方式例如:

那程式語言如何處理長度不固定的字元編碼?如何知道此字元被編碼為幾個 bytes?

它根據不同編碼長度的固定規則來判斷,1 個 byte 有自己固定的規則、2 個 bytes 有自己固定的規則…,規則例如:

例如「你」被 UTF-8 編碼的 3 個 bytes 為 0xE4 0xBD 0xA00xE4 換算二進位為 1110 0100,符合「共 3 個 bytes」規則,因此判定「你」共 3 個 bytes。又例如11100100 01100001 01100001 是不合法 UTF-8 格式,因為後兩者不是 10 開頭。

問題#

此案例的問題是mb_strposmb_substr 對不合法 UTF-8 字串的處理不一致。

mb_substr 對不合法格式的處理方式是它只看開頭判斷它是幾個 bytes 的字,後面不管,例如以下範例:

第一個 byte \xE4 換成二進位是 11100100,代表共 3 個 bytes,而最後一位 0x61 不符合 10zzzzzz 格式,代表不是合法格式。但mb_substr 只看第一個 byte 判斷,因此認定是 3 個 bytes。

mb_strpos 對不合法格式的處理方式則是碰到不合法字元就停止,不會納入計算,示意圖如下:

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

這個多取得的 < 會讓 sanitization 失效,可插入任意標籤、內容,達成 XSS。

修復方式#

更多詳細說明可參考 Joomla: PHP Bug Introduces Multiple XSS Vulnerabilities

結語#

《Beyond XSS:探索網頁前端資安宇宙》介紹前端資安的各面向議題,內容包含:

另外,如果對前端資安很感興趣,huli 大在這篇有列出可進一步參考的資源~

最後是自己的小小總結!這是《Beyond XSS:探索網頁前端資安宇宙》筆記系列的最後一篇囉🥳,太感人…,前端資安真的是自己很陌生的領域,透過這本書認識很多新東西,真的要說最有收穫的應該是跨域安全、origin 與 site 的差異那邊,去查找 spec 文件真的頭痛但也很有趣~不過因為自己對資安這塊真的不太熟,沒辦法像 React 讀書筆記系列還可以去引用更多其他資源、或是做更多舉例與示意圖,這系列就比較以書中敘述為主,小可惜QQ 最後感謝 Lois 舉辦的讀書會督促我把書看完!還有讀書會的小夥伴們,有大家一起讀書真的比較有動力~


Reference:#

如有任何問題歡迎聯絡、不吝指教✍️