使用不可信的數據,通過調用不安全的遞歸函數來暴露預設原型 原型污染:基礎 什麼是原型污染? 原型污染是一種針對JavaScript運行時的註入攻擊。通過原型污染,攻擊者可以控制對象屬性的預設值,從而篡改應用程式的邏輯並可能導致服務被拒絕,甚至在某些極端情況下遠程執行代碼。 現在,你是不是滿腦子充滿了 ...
使用不可信的數據,通過調用不安全的遞歸函數來暴露預設原型
原型污染:基礎
什麼是原型污染?
原型污染是一種針對JavaScript運行時的註入攻擊。通過原型污染,攻擊者可以控制對象屬性的預設值,從而篡改應用程式的邏輯並可能導致服務被拒絕,甚至在某些極端情況下遠程執行代碼。
現在,你是不是滿腦子充滿了各種疑問。到底什麼是“在運行時改寫對象的屬性”?它如何影響應用程式的安全?而且,更重要的是,我如何保護我的代碼免受這種攻擊?
關於本文
原型污染可以很複雜,所以本文將分三部分進行介紹。
- 使用原型污染危害易受攻擊的API。
- 瞭解更多有關JavaScript原型的知識以及原型污染是如何工作的。
- 如何修複和防止應用程式中的原型污染。
事實上,原型污染漏洞在許多流行的JavaScript庫中都被髮現過並修複了,包括jQuery、lodash、express、minimist、hoek等等。在jQuery中發現原型污染時,當時有74%的網站都在使用jQuery,聽起來有多可怕!
原型污染攻擊示例
讓我們來演示一下在真實場景中原型污染是如何進行攻擊的。假設一個名為startup.io的公司決定發佈一個API,允許用戶通過app來管理公司的數據。
不幸的是,由於開發過程中時間緊任務急,startup.io的工程師們根本來不及考慮API的安全問題,以至於他們忽略了安全掃描報告中發現的所有安全漏洞。於是,隨著該API緊急發佈,其中包含了許多bug和安全漏洞——其中之一就是原型污染。
對startup.io來說這顯然是一個壞消息,但對攻擊者來說卻再好不過了,他們可以通過這些bug和安全漏洞對API進行攻擊。讓我們來看看startup.io發佈的API中的兩個endpoints:
- 通過HTTP POST請求https://api.startup.io/users/:userId,使用userId更新用戶的數據
- 通過HTTP GET請求https://api.startup.io/users/:userId/role,獲取指定用戶當前分配的角色(admin或者user)
易受攻擊的API
讓我們嘗試通過篡改應用程式的邏輯來將我們提升到管理員的許可權。之後,我們再嘗試以拒絕服務的方式來搞垮整個API。
所有的示例都假設我們已經獲得了應用授權,為了便於可讀,我們省略了所有的HTTP授權header。
我們發送一個有效的請求,來看看那個HTTP POST的endpoint是如何工作的。我們從API提供的文檔中可以瞭解到,這個endpoint允許我們修改顯示在用戶個人資料頁面上的“about”部分的內容。我們打算將這部分內容修改成“Database sanitization expert”。
複製並粘貼下麵的內容到終端然後執行:
curl -H "Content-Type: application/json" -X POST -d '{"about": "Database sanitization expert"}' https://api.startup.io/users/1337
我們應該會收到一個JSON格式的響應,其中存儲了有關該用戶的數據,我們可以看到“about”的內容被更新了:
{ name: "Robert", surname: "Tables", about: "Database sanitization expert" }
接下來,讓我們發送另一個請求,來看看那個HTTP GET的endpoint是如何工作的。
複製並粘貼下麵的內容到終端然後執行:
curl -X GET https://api.startup.io/users/1337/role
我們應該會得到一個JSON數據,其中包含該用戶預設分配的角色。
攻擊1:失敗的嘗試
現在我們知道API是如何工作的了,讓我們看看能否修改用戶的角色並將其設置為admin。我們試著通過POST請求直接將role改成admin。
複製並粘貼下麵的內容到終端然後執行:
curl -H "Content-Type: application/json" -X POST -d '{"role": "admin"}' https://api.startup.io/users/1337 && curl -X GET https://api.startup.io/users/1337/role
我們應該得到以下輸出:
{ role: "user" }
顯然,這種簡單的嘗試未能奏效。role的值依然是user。不過,我們的嘗試並不止於此!
攻擊2:使用原型污染提升許可權
通過前面的內容,我們發現原型污染可以允許我們改寫應用程式中在任何對象上定義的任何屬性的值。也許我們可以借用這個漏洞來更改角色?讓我們再試一次——但是這次我們在要設置的屬性前添加了神奇的__proto__首碼。
複製並粘貼下麵的內容到終端然後執行:
curl -H "Content-Type: application/json" -X POST -d '{"about": {"__proto__": {"role": "admin"}}}' https://api.startup.io/users/1337 && curl -X GET https://api.startup.io/users/1337/role
然後我們得到:
{ role: "admin" }
哈哈!通過向後端發送這樣一個神奇的內容{"about": {"__proto__": {"role": "admin"}}},我們成功地將自己的許可權提升為管理員。
不過,等一下,這個神奇的__proto__首碼到底是什麼?為什麼它在這裡能起作用?別擔心,接下來我們會詳細討論它。但先讓我們把這個有bug的API給整癱瘓掉。
攻擊3:搞垮整個API
在前面的攻擊中,我們設法將role的值改成任何我們想要的內容。但是,JavaScript函數不是也作為屬性存儲在它們各自的對象上嗎?那麼我們可以使用相同的方式來改寫一個函數嗎?
讓我們試一下看看!在JavaScript中哪個函數最有可能被其它程式調用?答案是toString函數!讓我們試著將該函數改寫成一段毫無意義的內容,改成一段程式員的笑話怎麼樣?
複製並粘貼下麵的內容到終端然後執行:
curl -H "Content-Type: application/json" -X POST -d '{"about": {"__proto__": {"toString": "Two bytes meet. The first byte asks: Are you ill? The second byte replies: No, just feeling a bit off."}}}' https://api.startup.io/users/1337
我們應該得到以下輸出:
500 Internal Server Error
API掛掉了。看來我們完全可以改寫一個函數!
究竟發生了什麼?稍後我們會深入研究這其中的代碼。現在,我們已經得知我們能夠改寫toString方法,就像前面我們對role屬性所做的那樣。當JavaScript運行時,toString()總是被當作一個函數來調用,但是當我們修改之後它就不再是一個函數了(現在它是一段笑話,是一個字元串),因此整個web伺服器掛了,返回500錯誤。
原型污染工作原理
JavaScript中的原型是什麼?
為了便於理解我們上面的攻擊過程,我們需要首先解釋一下什麼是JavaScript原型。
當我們在JavaScript中創建一個空對象時(例如,const obj = {}),此時所創建的對象已經具有了一些屬性和方法,例如toString方法。你是否想過這些屬性和方法來自於哪裡?答案就是原型。
許多面向對象語言,例如Java,都基於類來創建對象。每個對象都屬於一個類,這些類按照父子層級的結構組織在一起。當我們在一個對象上調用toString方法時,底層運行庫將會在該對象所屬的類中查找toString方法的定義。如果沒有找到,則在父類中進行查找,一直查到最頂層的類。
相反,JavaScript是一種基於原型的面向對象編程語言,每個對象都鏈接到一個“prototype”(原型)。當我們在對象上調用toString方法時,JavaScript首先查看該對象上是否定義了這個方法,如果沒有,則在對象的原型上進行查找。
普通的JavaScript對象
const a = {}; console.log(typeof a.__proto__); // Output: object
來自原型上的屬性
const a = {}; a.__proto__.someFunction = function () { console.log("Hello from the prototype!") }; a.someFunction(); // Output: Hello from the prototype!
共用預設的原型
const a = {}; const b = new Object(); console.log(a.__proto__ === b.__proto__); // Output: true
在共用的原型上設置屬性
const a = {}; const b = new Object(); a.__proto__.x = 1337; console.log(b.x); // Output: 1337
有關原型污染的解釋
總之,如果我們修改了被多個對象共用的原型,那麼所有對象都會受到影響!這些對象甚至不需要處於同一作用域或其它相關的範圍中。記住,絕大多數對象預設都共用同一個原型——所以如果我們修改了其中一個對象的原型,其它的對象也會被改變!
如果有人惡意修改(或者污染)了被多個對象共用的原型怎麼辦?事實上,這就是前面我們對start.io公司的API所做的操作。記住,我們發送給伺服器的內容是:
{"about": {"__proto__":"{"role": "admin"}}}
{"about": {"__proto__": {"toString": "Two bytes meet. The first byte asks: Are you ill? The second byte replies: No, just feeling a bit off."}}}
通過發送一個特定的HTTP POST請求,我們污染了預設共用原型上的role和toString屬性。要瞭解這種攻擊是如何工作的,我們可以看下GET和POST的HTTP請求處理程式代碼:
一種原型污染攻擊,黑客向後端伺服器發送惡意數據,然後通過一個不安全的合併函數將該數據與對象進行合併操作
1 async function updateUser(userId, requestBody) { 2 const userData = await db.loadUserData(userId); 3 merge(userData, requestBody); 4 5 log("Saving userData " + userData.toString()); 6 await db.saveUserData(userId, userData); 7 return userData; 8 } 9 10 async function getRole(userId) { 11 const userPermissions = await db.loadUserPermissions(userId); 12 13 let role = "user"; 14 if (userPermissions.role) { 15 role = userPermissions.role; 16 } 17 18 return { role }; 19 } 20 21 /** 22 * Sets or updates all attributes of the source object on the target object. 23 * 24 * For example if `target` is {a: 1, b: 2} and `source` is {a: 3, c: 4}, 25 * after calling this function `target` becomes {a: 3, b: 2, c: 4}. 26 */ 27 function merge(target, source) { 28 for (const attr in source) { 29 if ( 30 typeof target[attr] === "object" && 31 typeof source[attr] === "object" 32 ) { 33 merge(target[attr], source[attr]) 34 } else { 35 target[attr] = source[attr] 36 } 37 } 38 }
- 第1行,updateUser方法用來處理HTTP POST請求。參數requestBody的值是我們要發送給伺服器的數據。
- 第2行,從資料庫獲取指定user的數據。
- 第3行,merge函數將requestBody對象的所有屬性合併到userData對象中。這裡就是原型被污染的地方,我們將深入探討一下merge函數。
- merge函數的定義在第27行。target是userData,source是我們通過HTTP Post請求發送給伺服器的數據:
target: { "about": "Database sanitization expert" ...}
source: { "about": { "__proto__": { "role": "admin" } } }
- 第28行,遍歷source的所有屬性,第一個屬性是about。
attr: "about"
- 第33行,about屬性在target和source中都存在,於是我們通過遞歸調用merge函數。
- 代碼回到第27行,此時target和source的值為:
target: "Database sanitization expert"
source: { "__proto__": { "role": "admin" } }
- 第28行,遍歷source的所有屬性,此時第一個屬性是__proto__。
attr: "__proto__"
- 第33行,屬性__proto__在target和source中都存在,所以我們再次通過遞歸調用merge函數。這一步是問題的關鍵所在,因為target.__proto__是被大多數對象預設共用的原型!
- 代碼再一次回到第27行,此時target和source的值為:
target: 預設原型
source: { "role": "admin" }
- 第28行,遍歷source的所有屬性,第一個屬性是role。
attr: "role"
- 由於屬性role沒有在target中定義,所以代碼會走到第35行,通過語句target[attr] = target.role將{"role": "admin"}設置給target。啊哈!我們成功地通過一段惡意代碼污染了全局原型。現在,對於所有共用預設原型的對象來說,屬性role的值即為"admin"。
- 代碼回到第3行,現在預設原型已經被role="admin"污染了。
- 然後,第10行,我們通過HTTP GET請求查詢我們分配給用戶的的角色。
- 第11行,對象userPermissions用來接收從資料庫返回的值,在本例中它是一個空對象({},因為指定的userId根本不存在),並且共用了預設原型。
- 第15行,由於userPermissions是一個空對象,所以它沒有role屬性。正常情況下,role預設為"user"。但是,由於我們通過role屬性污染了原型,userPermissions.role等同於userPermissions.__prop__.role,即"admin"。
- 第18行,{role: "admin" }被作為HTTP GET請求的返回值。
減少原型污染
方案1:在通過遞歸設置對象的屬性時使用安全的開源庫
在startup.io公司的案例中,merge函數的作用是將一個對象的所有屬性更新到另一個對象中。正如我們在上面的代碼分析過程中所看到的那樣,merge函數以遞歸的方式將第二個參數的所有屬性合併到第一個參數中——甚至包含那些不可信的內容,如__proto__。
並不是只有合併兩個對象的功能才會使源代碼可能受到原型污染攻擊——任何其它以遞歸調用的方式對嵌套的屬性進行設置的函數都有可能受到攻擊。在JavaScript生態中,其它常見的例子包括:深拷貝(如lodash中的cloneDeep方法),設置嵌套的屬性(如lodash中的set方法),或者以遞歸的方式“壓縮”屬性的值來創建一個新對象(如lodash中的zipObjectDeep方法)。
在以遞歸的方式設置嵌套的屬性時,需要始終確保將那些不可信的內容排除在外。不要自己實現!即使最優秀的程式員也會很容易犯錯。我們應該使用如lodash這樣的開源庫,它非常受歡迎,並且擁有出色的社區支持和及時的安全更新。
一個避免原型污染的例子,黑客嘗試發送一條惡意數據來攻擊伺服器,在使用安全的合併函數後,阻止了對原型的影響
1 import safeMerge from 'lodash.merge'; 2 3 async function updateUser(userId, requestBody) { 4 const userData = await db.loadUserData(userId); 5 safeMerge(userData, requestBody); 6 7 await db.saveUserData(userId, userData); 8 return userData; 9 }
想知道哪些庫是可信的,可以使用Snyk Advisor,它提供了給定package的受歡迎程度、社區支持和安全性等信息。除此之外,也可以使用漏洞掃面工具來檢查你要使用的開源庫,如Snyk,它會告訴你使用的庫中所有發現的安全漏洞,並幫助你如何輕鬆地解決這些問題。
事實上我們很難做到完全避免原型污染攻擊。在實現遞歸合併函數的過程中,lodash的開發人員確保值為__prop__的鍵不會從一個對象複製到另一個對象。不幸的是,後來發現原型污染也可以通過其它的屬性產生,例如constructor.prototype(查看這篇文章以瞭解lodash的開發人員如何修複這個問題)。因此我們得到的教訓是,要正確處理用戶的輸入是非常困難的,我們應該儘可能地使用那些經過實踐檢驗過的庫來幫助我們完成工作。
方案2:創建沒有prototype的對象:Object.create(null)
另一種避免原型污染的方法是在創建新對象時考慮使用object.create()方法,而不是通過對象字面量{}或者構造函數new Object()來創建。這樣,我們可以通過傳遞給Object.create()的第一個參數直接設置所創建對象的prototype。如果參數的值為null,那麼所創建的對象就沒有prototype,因此就不會產生原型污染。
1 async function updateUser(userId, requestBody) { 2 const userData = await db.loadUserData(userId); 3 4 const saveToDatabase = Object.create(null); 5 merge(saveToDatabase, userData); 6 merge(saveToDatabase, requestBody); 7 8 await db.saveUserData(userId, saveToDatabase); 9 return saveToDatabase; 10 }
方案3:阻止對prototype的任何修改:使用Object.freeze()
JavaScript提供了Object.freeze()方法,我們可以使用它來阻止對對象屬性的任何修改。由於prototype也是一個object,所以我們可以freeze它。我們可以使用Object.freeze(Object.prototype)來凍結預設原型,這樣可以防止對象的預設原型受到污染。
或者,你也可以安裝nopp包,它會自動凍結所有常見的對象原型。
1 // call once in ‘main.js’ or similar 2 Object.freeze(Object.prototype); 3 4 async function updateUser(userId, requestBody) { 5 const userData = await db.loadUserData(userId); 6 merge(userData, requestBody); 7 8 await db.saveUserData(userId, userData); 9 return userData; 10 }
如何減少原型污染?
當需要通過遞歸在對象上設置嵌套屬性時,使用流行的開源庫可以減少代碼庫中的原型污染漏洞。使用Snyk Advisor檢查你要使用的庫,並確保通過Snyk掃描後沒有安全漏洞。為了進一步強化代碼,可以使用Object.create(null)方法來避免使用prototype,或者使用Object.freeze(Object.prototype)來阻止對共用原型的任何修改。
更多有關原型污染的內容:
最後,如果你想對原型污染有更深入的瞭解,請閱讀由Olivier Arteau撰寫的有關原型污染的詳細報告。他在很多常見的JavaScript庫中發現並披露了許多原型污染漏洞。
原文地址:Prototype Pollution