深入理解 JavaScript Prototype 汙染攻擊
本來是想發在 程式碼審計知識星球 裡的一篇科普文章,因為最近知識星球似乎在和神祕組織對接,無法發表內容,所以發在部落格裡。Code-Breaking官方writeup拖了很久,主要是沒時間,不過外面已經有很多同學的writeup了,所以問題不大。
JavaScript是一門非常靈活的語言,我感覺在某些方面可能比PHP更加靈活。所以,除了傳統的SQL注入、程式碼執行等注入型漏洞外,也會有一些獨有的安全問題,比如今天要說這個prototype汙染。
0x01 prototype
和 __proto__
分別是什麼?
JavaScript中,我們如果要定義一個類,需要以定義“建構函式”的方式來定義:
function Foo() { this.bar = 1 } new Foo()
Foo
函式的內容,就是 Foo
類的建構函式,而 this.bar
就是 Foo
類的一個屬性。
為了簡化編寫JavaScript程式碼,ECMAScript 6後增加了 class
語法,但 class
其實只是一個語法糖。
一個類必然有一些方法,類似屬性 this.bar
,我們也可以將方法定義在建構函式內部:
function Foo() { this.bar = 1 this.show = function() { console.log(this.bar) } } (new Foo()).show()
但這樣寫有一個問題,就是每當我們新建一個Foo物件時, this.show = function...
就會執行一次,這個 show
方法實際上是繫結在物件上的,而不是繫結在“類”中。
我希望在建立類的時候只建立一次 show
方法,這時候就則需要使用原型(prototype)了:
function Foo() { this.bar = 1 } Foo.prototype.show = function show() { console.log(this.bar) } let foo = new Foo() foo.show()
我們可以認為原型 prototype
是類 Foo
的一個屬性,而所有用 Foo
類例項化的物件,都將擁有這個屬性中的所有內容,包括變數和方法。比如上圖中的 foo
物件,其天生就具有 foo.show()
方法。
我們可以通過 Foo.prototype
來訪問 Foo
類的原型,但 Foo
例項化出來的物件,是不能通過prototype訪問原型的。這時候,就該 __proto__
登場了。
一個Foo類例項化出來的foo物件,可以通過 foo.__proto__
屬性來訪問Foo類的原型,也就是說:
foo.__proto__ == Foo.prototype
所以,總結一下:
-
prototype
是一個類的屬性,所有類物件在例項化的時候將會擁有prototype
中的屬性和方法 - 一個物件的
__proto__
屬性,指向這個物件所在的類的prototype
屬性
0x02 JavaScript原型鏈繼承
所有類物件在例項化的時候將會擁有 prototype
中的屬性和方法,這個特性被用來實現JavaScript中的繼承機制。
比如:
function Father() { this.first_name = 'Donald' this.last_name = 'Trump' } function Son() { this.first_name = 'Melania' } Son.prototype = new Father() let son = new Son() console.log(`Name: ${son.first_name} ${son.last_name}`)
Son類繼承了Father類的 last_name
屬性,最後輸出的是 Name: Melania Trump
。
總結一下,對於物件son,在呼叫 son.last_name
的時候,實際上JavaScript引擎會進行如下操作:
- 在物件son中尋找last_name
- 如果找不到,則在
son.__proto__
中尋找show - 如果仍然找不到,則繼續在
son.__proto__.__proto__
中尋找show - 依次尋找,直到找到
null
結束。比如,Object.prototype
的__proto__
就是null
JavaScript的這個查詢的機制,被運用在面向物件的繼承中,被稱作prototype繼承鏈。
以上就是最基礎的JavaScript面向物件程式設計,我們並不深入研究更細節的內容,只要牢記以下幾點即可:
- 每個建構函式(constructor)都有一個原型物件(prototype)
- 物件的
__proto__
屬性,指向類的原型物件prototype
- JavaScript使用prototype鏈實現繼承機制
0x03 原型鏈汙染是什麼
第一章中說到, foo.__proto__
指向的是 Foo
類的 prototype
。那麼,如果我們修改了 foo.__proto__
中的值,是不是就可以修改Foo類呢?
做個簡單的實驗:
// foo是一個簡單的JavaScript物件 let foo = {bar: 1} // foo.bar 此時為1 console.log(foo.bar) // 修改foo的原型(即Object) foo.__proto__.bar = 2 // 由於查詢順序的原因,foo.bar仍然是1 console.log(foo.bar) // 此時再用Object建立一個空的zoo物件 let zoo = {} // 檢視zoo.bar console.log(zoo.bar)
最後,雖然zoo是一個 空 物件 {}
,但 zoo.bar
的結果居然是2:
原因也顯而易見:因為前面我們修改了foo的原型 foo.__proto__.bar = 2
,而foo是一個Object類的例項,所以實際上是修改了Object這個類,給這個類增加了一個屬性bar,值為2。
後來,我們又用Object類建立了一個zoo物件 let zoo = {}
,zoo物件自然也有一個bar屬性了。
那麼,在一個應用中,如果攻擊者控制並修改了一個物件的原型,那麼將可以影響所有和這個物件來自同一個類、父祖類的物件。這種攻擊方式就是 原型鏈汙染 。
0x03 哪些情況下原型鏈會被汙染?
在實際應用中,哪些情況下可能存在原型鏈能被攻擊者修改的情況呢?
我們思考一下,哪些情況下我們可以設定 __proto__
的值呢?其實找找能夠控制陣列(物件)的“鍵名”的操作即可:
- 物件merge
- 物件clone(其實核心就是將待操作的物件merge到一個空物件中)
以物件merge為例,我們想象一個簡單的merge函式:
function merge(target, source) { for (let key in source) { if (key in source && key in target) { merge(target[key], source[key]) } else { target[key] = source[key] } } }
在合併的過程中,存在賦值的操作 target[key] = source[key]
,那麼,這個key如果是 __proto__
,是不是就可以原型鏈汙染呢?
我們用如下程式碼實驗一下:
let o1 = {} let o2 = {a: 1, "__proto__": {b: 2}} merge(o1, o2) console.log(o1.a, o1.b) o3 = {} console.log(o3.b)
結果是,合併雖然成功了,但原型鏈沒有被汙染:
這是因為,我們用JavaScript建立o2的過程( let o2 = {a: 1, "__proto__": {b: 2}}
)中, __proto__
已經代表o2的原型了,此時遍歷o2的所有鍵名,你拿到的是 [a, b]
, __proto__
並不是一個key,自然也不會修改Object的原型。
那麼,如何讓 __proto__
被認為是一個鍵名呢?
我們將程式碼改成如下:
let o1 = {} let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}') merge(o1, o2) console.log(o1.a, o1.b) o3 = {} console.log(o3.b)
可見,新建的o3物件,也存在b屬性,說明Object已經被汙染:
這是因為,JSON解析的情況下, __proto__
會被認為是一個真正的“鍵名”,而不代表“原型”,所以在遍歷o2的時候會存在這個鍵。
merge操作是最常見可能控制鍵名的操作,也最能被原型鏈攻擊,很多常見的庫都存在這個問題。
0x04 Code-Breaking 2018 Thejs 分析
我在Code-Breaking 2018中出了一道原型鏈汙染的CTF題目,為了更加貼合真實環境,我沒有刻意加太多自己的程式碼,後端主要程式碼如下(完整程式碼可參考 這裡 ):
// ... const lodash = require('lodash') // ... app.engine('ejs', function (filePath, options, callback) { // define the template engine fs.readFile(filePath, (err, content) => { if (err) return callback(new Error(err)) let compiled = lodash.template(content) let rendered = compiled({...options}) return callback(null, rendered) }) }) //... app.all('/', (req, res) => { let data = req.session.data || {language: [], category: []} if (req.method == 'POST') { data = lodash.merge(data, req.body) req.session.data = data } res.render('index', { language: data.language, category: data.category }) })
lodash是為了彌補JavaScript原生函式功能不足而提供的一個輔助功能集,其中包含字串、陣列、物件等操作。這個Web應用中,使用了lodash提供的兩個工具:
lodash.template lodash.merge
其實整個應用邏輯很簡單,使用者提交的資訊,用merge方法合併到session裡,多次提交,session裡最終儲存你提交的所有資訊。
而這裡的 lodash.merge
操作實際上就存在原型鏈汙染漏洞。
在汙染原型鏈後,我們相當於可以給Object物件插入任意屬性,這個插入的屬性反應在最後的 lodash.template
中。我們看到 lodash.template
的程式碼: https://github.com/lodash/lodash/blob/4.17.4-npm/template.js#L165
// Use a sourceURL for easier debugging. var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : ''; // ... var result = attempt(function() { return Function(importsKeys, sourceURL + 'return ' + source) .apply(undefined, importsValues); });
options是一個物件,sourceURL取到了其 options.sourceURL
屬性。這個屬性原本是沒有賦值的,預設取空字串。
但因為原型鏈汙染,我們可以給所有Object物件中都插入一個 sourceURL
屬性。最後,這個 sourceURL
被拼接進 new Function
的第二個引數中,造成任意程式碼執行漏洞。
我將帶有 __ptoto__
的Payload以json的形式傳送給後端,因為express框架支援根據Content-Type來解析請求Body,這裡給我們注入原型提供了很大方便:
可見,我們程式碼執行成功,返回了id命令的結果。
整個案例我覺得是一個非常經典的原型鏈汙染漏洞教程,程式碼不多,邏輯不復雜,沒有刻意創造漏洞,真正觸發漏洞的程式碼在庫中。
我一直覺得,出題不要刻意創造漏洞,而是找找你的知識點是否能在真實環境下找到應用。