Lodash 嚴重安全漏洞背後 你不得不知道的 JavaScript 知識
摘要: 詳解原型汙染。
Fundebug經授權轉載,版權歸原作者所有。
可能有資訊敏感的同學已經瞭解到:Lodash 庫爆出嚴重安全漏洞,波及 400萬+ 專案。這個漏洞使得 lodash “連夜”發版以解決潛在問題,並強烈建議開發者升級版本。
我們在忙著“看熱鬧”或者“”升級版本”的同時,靜下心來想:真的有理解這個漏洞產生的原因,明白漏洞修復背後的原理了嗎?
這篇短文將從原理層面分析這一事件,相信“小白”讀者會有所收穫。
漏洞原因
其實漏洞很簡單,舉一個例子:lodash 中 defaultsDeep 方法,
_.defaultsDeep({ 'a': { 'b': 2 } }, { 'a': { 'b': 1, 'c': 3 } })
輸出:
{ 'a': { 'b': 2, 'c': 3 } }
如上例,該方法:
分配來源物件(該方法的第二個引數)的可列舉屬性到目標物件(該方法的第一個引數)所有解析為 undefined 的屬性上
這樣的操作存在的隱患:
const payload = '{"constructor": {"prototype": {"toString": true}}}' _.defaultsDeep({}, JSON.parse(payload))
如此一來,就觸發了原型汙染。原型汙染是指:
攻擊者通過某種手段修改 JavaScript 物件的原型(prototype)
對應上例,Object.prototype.toString
就會非常不安全了。
詳解原型汙染
理解原型汙染,需要讀者理解 JavaScript 當中的原型、原型鏈的知識。我們先來看一個例子:
// person 是一個簡單的 JavaScript 物件 let person = {name: 'lucas'} // 輸出 lucas console.log(person.name) // 修改 person 的原型 person.__proto__.name = 'messi' // 由於原型鏈順序查詢的原因,person.name 仍然是 lucas console.log(person.name) // 再建立一個空的 person2 物件 let person2 = {} // 檢視 person2.name,輸出 messi console.log(person2.name)
把危害擴大化:
let person = {name: 'lucas'}
console.log(person.name)
person.__proto__.toString = () => {alert('evil')}
console.log(person.name)
let person2 = {}
console.log(person2.toString())
這段程式碼執行將會 alert 出 evil 文字。同時 Object.prototype.toString
這個方法會在隱式轉換以及型別判斷中經常被用到:
Object.prototype.toString 方法返回一個表示該物件的字串
每個物件都有一個 toString()
方法,當該物件被表示為一個文字值時,或者一個物件以預期的字串方式引用時自動呼叫。預設情況下,toString()
方法被每個 Object 物件繼承。如果此方法在自定義物件中未被覆蓋,toString()
返回 [object type]
,其中 type 是物件的型別。
如果 Object 原型上的 toString 被汙染,後果可想而知。以此為例,可見 lodash 這次漏洞算是比較嚴重了。
再談原型汙染(NodeJS 漏洞案例)
由上分析,我們知道原型汙染並不是什麼新鮮的漏洞,它“隨時可見”,“隨處可見”。在 Nullcon HackIM 比賽中就有一個類似的 hack 題目:
'use strict';
const express = require('express');
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser');
const path = require('path');
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
function merge(a, b) {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
function clone(a) {
return merge({}, a);
}
// Constants
const PORT = 8080;
const HOST = '0.0.0.0';
const admin = {};
// App
const app = express();
app.use(bodyParser.json())
app.use(cookieParser());
app.use('/', express.static(path.join(__dirname, 'views')));
app.post('/signup', (req, res) => {
var body = JSON.parse(JSON.stringify(req.body));
var copybody = clone(body)
if (copybody.name) {
res.cookie('name', copybody.name).json({
"done": "cookie set"
});
} else {
res.json({
"error": "cookie not set"
})
}
});
app.get('/getFlag', (req, res) => {
var аdmin = JSON.parse(JSON.stringify(req.cookies))
if (admin.аdmin == 1) {
res.send("hackim19{}");
} else {
res.send("You are not authorized");
}
});
app.listen(PORT, HOST);
console.log(`Running on http://${HOST}:${PORT}`);
這段程式碼的漏洞就在於 merge 函式上,我們可以這樣攻擊:
curl -vv --header 'Content-type: application/json' -d '{"__proto__": {"admin": 1}}' 'http://0.0.0.0:4000/signup';
curl -vv 'http://0.0.0.0:4000/getFlag'
首先請求 /signup
介面,在 NodeJS 服務中,我們呼叫了有漏洞的 merge
方法,並通過 __proto__
為 Object.prototype
(因為 {}.__proto__ === Object.prototype
) 新增上一個新的屬性 admin
,且值為 1。
再次請求 getFlag
介面,條件語句 admin.аdmin == 1
為 true
,服務被攻擊。
攻擊案例出自:Prototype pollution attacks in NodeJS applications
這樣的漏洞在 jQuery $.extend
中也經常見到:
對於 jQuery:如果擔心安全問題,建議升級至最新版本 jQuery 3.4.0,如果還在使用 jQuery 的 1.x 和 2.x 版本,那麼你的應用程式和網站仍有可能遭受攻擊。
防範原型汙染
瞭解了漏洞潛在問題以及攻擊手段,那麼如何防範呢?
在 lodash “連夜”發版的修復中:
我們可以清晰的看到,在遍歷 merge 時,當遇見 constructor
以及 __proto__
敏感屬性,則退出程式。
那麼作為業務開發者,我們需要注意些什麼,防止攻擊出現呢?總結一下有:
- 凍結
Object.prototype
,使原型不能擴充屬性
我們可以採用 Object.freeze
達到目的:
Object.freeze() 方法可以凍結一個物件。一個被凍結的物件再也不能被修改;凍結了一個物件則不能向這個物件新增新的屬性,不能刪除已有屬性,不能修改該物件已有屬性的可列舉性、可配置性、可寫性,以及不能修改已有屬性的值。此外,凍結一個物件後該物件的原型也不能被修改。freeze() 返回和傳入的引數相同的物件。
看程式碼:
Object.freeze(Object.prototype);
Object.prototype.toString = 'evil'
consoel.log(Object.prototype.toString)
ƒ toString() { [native code] }
對比:
Object.prototype.toString = 'evil'
console.log(Object.prototype.toString)
"evil"
- 建立 JSON schema 在解析使用者輸入內容是,通過 JSON schema 過濾敏感鍵名。
- 規避不安全的遞迴性合併 這一點類似 lodash 修復手段,完善了合併操作的安全性,對敏感鍵名跳過處理
- 使用無原型物件
在建立物件時,不採用字面量方式,而是使用
Object.create(null)
:
Object.create()方法建立一個新物件,使用現有的物件來提供新建立的物件的
__proto__
Object.create(null)
的返回值不會連結到 Object.prototype
:
let foo = Object.create(null)
console.log(foo.__proto__)
// undefined
這樣一來,無論如何擴充物件,都不會干擾到原型了。
- 採用新的 Map 資料型別,代替 Object 型別
Map 物件儲存鍵/值對,是鍵/值對的集合。任何值(物件或者原始值)都可以作為一個鍵或一個值。使用 Map 資料結構,不會存在 Object 原型汙染狀況。
這裡總結一下 Map 和 Object 不同點::
- Object 的鍵只支援 String 或者 Symbols 兩種型別,Map 的鍵可以是任意值,包括函式、物件、基本型別
- Map 中的鍵值是有序的,而 Object 中的鍵則不是
- 具體 API 上的差異:比如,通過 size 屬性直接獲取一個 Map 的鍵值對個數,而 Object 的鍵值無法獲取;再比如迭代一個 Map 和 Object 差異也比較明顯
- Map 在頻繁增刪鍵值對的場景下會有些效能優勢
補充:V8,chromium 的小機靈
同樣存在風險的是我們常用的 JSON.parse
方法,但是如果你執行:
JSON.parse('{ "a":1, "__proto__": { "b": 2 }}')
你會發現返回的結果如圖:
複寫 Object.prototype
失敗了,__proto__
屬性還是我們熟悉的那個有安全感的 __proto__
。這是因為:
V8 ignores keys named proto in JSON.parse
這個相關討論 Doug Crockford,Brendan Eich,反正 chromium 和 JS 發明人討論過很多次。相關 issue 和 PR:
相關 ES 語言設計的討論:ES 語言設計的討論:proto-and-json
在上面連結中,你能發現 JavaScript 發明人等一眾大佬哦~
總之你可以記住,V8 預設使用 JSON.parse
時候會忽略 __proto__
,原因當然是之前分析的安全性了。
總結
通過分析 lodash 的漏洞,以及解決方案,我們瞭解了原型汙染的方方面面。涉及到的知識點包括但不限於:
- Object 原型
- 原型、原型鏈
- NodeJS 相關問題
- Object.create 方法
- Object.freeze 方法
- Map 資料結構
- 深拷貝
- 以及其他問題
這麼來看,全是基礎知識。也正是基礎,構成了前端知識體系的方方面面。
關於Fundebug
Fundebug專注於JavaScript、微信小程式、微信小遊戲、支付寶小程式、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了10億+錯誤事件,付費客戶有陽光保險、核桃程式設計、荔枝FM、掌門1對1、微脈、青團社等眾多品牌企業。歡迎