Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

防止 XSS 可能比想像中困難 #77

Open
aszx87410 opened this issue May 25, 2021 · 0 comments
Open

防止 XSS 可能比想像中困難 #77

aszx87410 opened this issue May 25, 2021 · 0 comments
Labels
Front-End Front-End Security Security

Comments

@aszx87410
Copy link
Owner

前言

如果你不知道什麼是 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 修復完成
@aszx87410 aszx87410 added Front-End Front-End Security Security labels May 25, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Front-End Front-End Security Security
Projects
None yet
Development

No branches or pull requests

1 participant