最近開了一個讀者回饋表單,無論是對文章的感想或是對部落格的感想,有什麼想回饋的都可以填表單跟我說:表單連結

防止 XSS 可能比想像中困難

前言

如果你不知道什麼是 XSS(Cross-site Scripting),簡單來說就是駭客可以在你的網站上面執行 JavaScript 的程式碼。既然可以執行,那就有可能可以把使用者的 token 偷走,假造使用者的身份登入,就算偷不走 token,也可以竄改頁面內容,或是把使用者導到釣魚網站等等。

要防止 XSS,就必須阻止駭客在網站上面執行程式碼,而防禦的方式有很多,例如說可以透過 CSP(Content-Security-Policy)這個 HTTP response header 防止 inline script 的執行或是限制可以載入 script 的 domain,也可以用 Trusted Types 防止一些潛在的攻擊以及指定規則,或是使用一些過濾 XSS 的 library,例如說 DOMPurify 以及 js-xss

但是用了這些就能沒事了嗎?是也不是。

如果使用正確那當然沒有問題,但若是有用可是設定錯誤的話,還是有可能存在 XSS 的漏洞。

前陣子我剛從公司內轉到一個做資安的團隊 Cymetrics,在對一些網站做研究的時候發現了一個現成的案例,因此這篇就以這個現成的案例來說明怎樣叫做錯誤的設定,而這個設定又會帶來什麼樣的影響。

錯誤的設定,意料之外的結果

Matters News 是一個去中心化的寫作社群平台,而且所有的程式碼都有開源

像是這種部落格平台,我最喜歡看的是他們怎麼處理內容的過濾,秉持著好奇跟研究的心態,可以來看看他們在文章跟評論的部分是怎麼做的。

Server 過濾的程式碼在這邊:matters-server/src/common/utils/xss.ts

import xss from 'xss'

const CUSTOM_WHITE_LISTS = {
  a: [...(xss.whiteList.a || []), 'class'],
  figure: [],
  figcaption: [],
  source: ['src', 'type'],
  iframe: ['src', 'frameborder', 'allowfullscreen', 'sandbox'],
}

const onIgnoreTagAttr = (tag: string, name: string, value: string) => {
  /**
   * Allow attributes of whitelist tags start with "data-" or "class"
   *
   * @see https://github.com/leizongmin/js-xss#allow-attributes-of-whitelist-tags-start-with-data-
   */
  if (name.substr(0, 5) === 'data-' || name.substr(0, 5) === 'class') {
    // escape its value using built-in escapeAttrValue function
    return name + '="' + xss.escapeAttrValue(value) + '"'
  }
}

const ignoreTagProcessor = (
  tag: string,
  html: string,
  options: { [key: string]: any }
) => {
  if (tag === 'input' || tag === 'textarea') {
    return ''
  }
}

const xssOptions = {
  whiteList: { ...xss.whiteList, ...CUSTOM_WHITE_LISTS },
  onIgnoreTagAttr,
  onIgnoreTag: ignoreTagProcessor,
}
const customXSS = new xss.FilterXSS(xssOptions)

export const sanitize = (string: string) => customXSS.process(string)

這邊比較值得注意的是這一段:

const CUSTOM_WHITE_LISTS = {
  a: [...(xss.whiteList.a || []), 'class'],
  figure: [],
  figcaption: [],
  source: ['src', 'type'],
  iframe: ['src', 'frameborder', 'allowfullscreen', 'sandbox'],
}

這一段就是允許被使用的 tag 跟屬性,而屬性的內容也會被過濾。例如說雖然允許 iframe 跟 src 屬性,但是 <iframe src="javascript:alert(1)"> 是行不通的,因為這種 javascript: 開頭的 src 會被過濾掉。

只看 server side 的沒有用,還需要看 client side 那邊是怎麼 render 的。

對於文章的顯示是這樣的:src/views/ArticleDetail/Content/index.tsx

<>
  <div
    className={classNames({ 'u-content': true, translating })}
    dangerouslySetInnerHTML={{
      __html: optimizeEmbed(translation || article.content),
    }}
    onClick={captureClicks}
    ref={contentContainer}
  />

  <style jsx>{styles}</style>
</>

Matters 的前端使用的是 React,在 React 裡面所 render 的東西預設都已經 escape 過了,所以基本上不會有 XSS 的洞。但有時候我們不想要它過濾,例如說文章內容,我們可能會需要一些 tag 可以 render 成 HTML,這時候就可以用 dangerouslySetInnerHTML,傳入這個的東西會直接以 innerHTML 的方式 render 出來,不會被過濾。

所以一般來說都會採用 js-xss + dangerouslySetInnerHTML 這樣的做法,確保 render 的內容儘管是 HTML,但不會被 XSS。

這邊在傳入 dangerouslySetInnerHTML 之前先過了一個叫做 optimizeEmbed 的函式,可以繼續往下追,看到 src/common/utils/text.ts

export const optimizeEmbed = (content: string) => {
  return content
    .replace(/\<iframe /g, '<iframe loading="lazy"')
    .replace(
      /<img\s[^>]*?src\s*=\s*['\"]([^'\"]*?)['\"][^>]*?>/g,
      (match, src, offset) => {
        return /* html */ `
      <picture>
        <source
          type="image/webp"
          media="(min-width: 768px)"
          srcSet=${toSizedImageURL({ url: src, size: '1080w', ext: 'webp' })}
          onerror="this.srcset='${src}'"
        />
        <source
          media="(min-width: 768px)"
          srcSet=${toSizedImageURL({ url: src, size: '1080w' })}
          onerror="this.srcset='${src}'"
        />
        <source
          type="image/webp"
          srcSet=${toSizedImageURL({ url: src, size: '540w', ext: 'webp' })}
        />
        <img
          src=${src}
          srcSet=${toSizedImageURL({ url: src, size: '540w' })}
          loading="lazy"
        />
      </picture>
    `
      }
    )
}

這邊採用 RegExp 把 img src 拿出來,然後用字串拼接的方式直接拼成 HTML,再往下看 toSizedImageURL

export const toSizedImageURL = ({ url, size, ext }: ToSizedImageURLProps) => {
  const assetDomain = process.env.NEXT_PUBLIC_ASSET_DOMAIN
    ? `https://${process.env.NEXT_PUBLIC_ASSET_DOMAIN}`
    : ''
  const isOutsideLink = url.indexOf(assetDomain) < 0
  const isGIF = /gif/i.test(url)

  if (!assetDomain || isOutsideLink || isGIF) {
    return url
  }

  const key = url.replace(assetDomain, ``)
  const extedUrl = changeExt({ key, ext })
  const prefix = size ? '/' + PROCESSED_PREFIX + '/' + size : ''

  return assetDomain + prefix + extedUrl
}

只要 domain 是 assets 的 domain 並符合其他條件,就會經過一些字串處理之後回傳。

看到這邊,就大致上了解整個文章的 render 過程了。

會在 server side 用 js-xss 這套 library 進行過濾,在 client side 這邊則是用 dangerouslySetInnerHTML 來 render,其中會先對 img tag 做一些處理,把 img 改成用 picture + source 的方式針對不同解析度或是螢幕尺寸載入不同的圖片。

以上就是這個網站 render 文章的整個過程,再繼續往下看之前你可以想一下,有沒有什麼地方有問題?

== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==

第一個問題:錯誤的屬性過濾

你有發現這邊的過濾有問題嗎?

const CUSTOM_WHITE_LISTS = {
  a: [...(xss.whiteList.a || []), 'class'],
  figure: [],
  figcaption: [],
  source: ['src', 'type'],
  iframe: ['src', 'frameborder', 'allowfullscreen', 'sandbox'],
}

開放 iframe 應該是因為要讓使用者可以嵌入 YouTube 影片之類的東西,但問題是這個網站並沒有用 CSP 指定合法的 domain,因此這邊的 src 可以隨意亂填,我可以自己做一個網站然後用 iframe 嵌入。如果網頁內容設計得好,看起來就會是這個網站本身的一部分:

以上只是隨便填的一個範例,主要是讓大家看個感覺,如果真的有心想攻擊的話可以弄得更精緻,內容更吸引人。

如果只是這樣的話,攻擊能否成功取決與內容是否能夠取信於使用者。但其實可以做到的不只這樣,大家知道在 iframe 裡面是可以操控外面的網站嗎?

cross origin 的 window 之間能存取的東西有限,唯一能夠改變的是 location 這個東西,意思就是我們可以在 iframe 裡面,把嵌入你的網站重新導向:

<script>
  top.location = 'https://google.com'
</script>

這樣做的話,我就可以把整個網站重新導向到任何地方,一個最簡單能想到的應用就是重新導向到釣魚網站。這樣的釣魚網站成功機率是比較高的,因為使用者可能根本沒有意識到他被重新導向到其他網站了。

其實瀏覽器針對這樣的重新導向是有防禦的,上面的程式碼會出現錯誤:

Unsafe attempt to initiate navigation for frame with origin ‘https://matters.news‘ from frame with URL ‘https://53469602917d.ngrok.io/‘. The frame attempting navigation is targeting its top-level window, but is neither same-origin with its target nor has it received a user gesture. See https://www.chromestatus.com/features/5851021045661696.

Uncaught DOMException: Failed to set the ‘href’ property on ‘Location’: The current window does not have permission to navigate the target frame to ‘https://google.com

因為不是 same origin,所以會阻止 iframe 對 top level window 做導向。

但是呢!這個東西是可以繞過的,會運用到 sandbox 這個屬性。這個屬性其實就是在指定嵌入的 iframe 有什麼權限,所以只要改成:<iframe sandbox="allow-top-navigation allow-scripts allow-same-origin" src=example.com></iframe>,就可以成功對 top level window 重新導向,把整個網站給導走。

這個漏洞在 GitLabcodimd 都有出現過。

這邊的修正方式有幾個,第一個是可以先把 sandbox 這個屬性拿掉,讓這個屬性不能被使用。如果真的有地方需要用到的話,就需要檢查裡面的值,把比較危險的 allow-top-navigation 給拿掉。

再來的話也可以限制 iframe src 的位置,可以在不同層面做掉,例如說在程式碼裡面自己過濾 src,只允許特定 domain,或者是用 CSP:frame-src 讓瀏覽器把這些不符合的 domain 自己擋掉。

第二個問題:未過濾的 HTML

第一個問題能造成最大的危險大概就是重新導向了(codimd 那一篇是說在 Safari 可以做出 XSS 啦,只是我做不出來 QQ),但是除了這個之外,還有一個更大的問題,那就是這邊:

<>
  <div
    className={classNames({ 'u-content': true, translating })}
    dangerouslySetInnerHTML={{
      __html: optimizeEmbed(translation || article.content),
    }}
    onClick={captureClicks}
    ref={contentContainer}
  />

  <style jsx>{styles}</style>
</>

article.content 是經過 js-xss 過濾後的 HTML 字串,所以是安全的,但這邊經過了一個 optimizeEmbed 去做自訂的轉換,在過濾以後還去改變內容其實是一件比較危險的事,因為如果處理的過程有疏忽,就會造成 XSS 的漏洞。

在轉換裡面有一段程式碼為:

<source
  type="image/webp"
  media="(min-width: 768px)"
  srcSet=${toSizedImageURL({ url: src, size: '1080w', ext: 'webp' })}
  onerror="this.srcset='${src}'"
/>

仔細看這段程式碼,如果 ${toSizedImageURL({ url: src, size: '1080w', ext: 'webp' })} 或是 src 我們可以控制的話,就有機會能夠改變屬性的內容,或者是新增屬性上去。

我原本想插入一個惡意的 src 讓 onerror 變成 onerror="this.srcset='test';alert(1)" 之類的程式碼,但我後來發現 picture 底下的 source 的 onerror 事件好像是無效的,就算 srcset 有錯也不會觸發,所以是沒用的。

因此我就把焦點轉向 srcSet 以及插入新的屬性,這邊可以用 onanimationstart 這個屬性,在 animation 開始時會觸發的一個事件,而 animation 的名字可以去 CSS 裡面找,很幸運地找到了一個 keyframe 叫做spinning

因此如果 img src 為:https://assets.matters.news/processed/1080w/embed/test style=animation-name:spinning onanimationstart=console.log(1337)

結合後的程式碼就是:

<source
  type="image/webp"
  media="(min-width: 768px)"   
  srcSet=https://assets.matters.news/processed/1080w/embed/test 
  style=animation-name:spinning 
  onanimationstart=console.log(1337)
  onerror="this.srcset='${src}'"
/>

如此一來,就製造了一個 XSS 的漏洞:


修補方式也有幾個:

  1. 新增 CSP header 阻止 inline script 的執行(這比較難做到,因為可能會跟現有東西牴觸,需要較多時間處理)
  2. 過濾傳進來的 img url(如果過濾不好一樣有風險)
  3. 先改變 HTML,才去呼叫 js-xss 幫你濾掉不該存在的屬性

總結

我們找到了兩個漏洞:

  1. 透過 <iframe> 把使用者導到任意位置
  2. 透過 <source> 執行文章頁面的 XSS 攻擊

那實際上到底可以做到什麼樣的攻擊呢?

可以先用第二個漏洞發表一篇有 XSS 攻擊的文章,再寫一個機器人去所有文章底下留言,利用 <iframe> 把使用者導到具有 XSS 的文章。如此一來,只要使用者點擊任何一篇文章都會被攻擊到。

不過網站本身其他地方的防禦做得不錯,儘管有 XSS 但 Cookie 是 HttpOnly 的所以偷不走,修改密碼是用寄信的所以也沒辦法修改密碼,似乎沒辦法做到真的太嚴重的事情。

許多過濾 XSS 的 library 本身是安全的(雖然有些時候其實還是會被發現漏洞),但使用 library 的人可能忽略了一些設定或者是額外做了一些事情,導致最後產生出來的 HTML 依然是不安全的。

在處理與使用者輸入相關的地方時,應該對於每一個環節都重新檢視一遍,看看是否有疏忽的地方。

CSP 的 header 也建議設定一下,至少在真的被 XSS 時還有最後一道防線擋住。雖然說 CSP 有些規則也可以被繞過,但至少比什麼都沒有好。

Matters 有自己的 Bug Bounty Program,只要找到能證明危害的漏洞都有獎金可以拿,這篇找到的 XSS 漏洞被歸類在 High,價值 150 元美金。他們團隊相信開源能惠及技術人員,也能讓網站更安全,因此希望大家知道這個計畫的存在。

最後,感謝 Matters 團隊快速的回覆以及處理,也感謝 Cymetrics 的同事們。

時間軸:

  • 2021–05–07 回報漏洞
  • 2021–05–12 收到 Matters 團隊確認信,正在修補漏洞
  • 2021–05–12 詢問修補完是否能發表文章,獲得許可
  • 2021–05–13 修復完成
解題心得:Intigriti's 0421 XSS challenge(上) 一些不太好記卻很好用的 CSS 屬性

評論