文章目录
原型链污染
原型对象
在说原型对象之前不得不先提一提“继承”,继承在很多面向对象的编程语言中都有着非常重要的地位,即A对象通过继承B对象,就能直接拥有B对象的所有属性或方法。在大多数编程语言中都是通过(class)进行继承,而在JavaScript中则是通过原型对象(prototype)实现继承的。
原型对象解决的问题
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
// false
在上述代码中,cat1
与cat2
是cat(){}
构造函数的两个实例,它们两个都具有meow()
方法。由于meow方法是生成在个实例对象上的,所以在每个实例对象执行的时候,meow
方法都会被调用,每调用一次就会新建一个meow
方法,这样的会浪费系统资源,如果meow
方法可以直接与cat1
,cat2
进行共享就能够解决避免这种浪费了,而prototype
解决得就是这个问题。
原型链
JavaScript 规定,所有对象都有自己的原型对象(prototype)。一方面,任何一个对象,都可以充当其他对象的原型;另一方面,由于原型对象也是对象,所以它也有自己的原型。因此,就会形成一个“原型链”(prototype chain):对象到原型,再到原型的原型……
a---->b------>c------>d-------->Object
注:在JavaScript中,所有对象的最终原型都是Object,Object的原型对象的NULL。NULL没有任何属性和方法,也没有自己的原型
读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的Object.prototype
还是找不到,则返回undefined
。如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做“覆盖”(overriding)。
注意,一级级向上,在整个原型链上寻找某个属性,对性能是有影响的。所寻找的属性在越上层的原型对象,对性能的影响越大。如果寻找某个不存在的属性,将会遍历整个原型链。
原型链污染必会属性及方法
prototype属性
在JavaScript中规定每个函数都有一个prototype
属性,指向一个对象。
function f() {}
typeof f.prototype // "object"
如上述代码中prototype
属性就是指向对象object
,对于普通函数来说,该属性无太大实际作用,但是对于构造函数来说,生成实例的时候,该属性就会自动成为实例对象的原型。
例
function Animal(name) {
this.name = name;
}
Animal.prototype.color = 'white';
var cat1 = new Animal('大毛');
var cat2 = new Animal('二毛');
cat1.color // 'white'
cat2.color // 'white'
上述代码中Animal
的属性prototype
属性,就是实例对象cat1
,cat2
的原型对象,cat1
和cat2
会共享该属性。
constructor属性
prototype对象有一个constructor属性,默认指向prototype对象所在的构造函数。
function A() {}
var a = new P();
//应该有这个属性吗
a.constructor === A // true
a.constructor === a.prototype.constructor // true
a.hasOwnProperty('constructor') // false
如上述代码,a
是函数A
的实例对象,但是a
自身没有constructor
属性,JavaScript引擎就会向上读取原型链上的A.prototype.constructor
属性。所以a.constructor
就等同于a.prototype.constructor
,而a.prototype.constructor
指向A
,所以a.constructor
指向A
。
综上可以体现constructor
的一个特性是可以确定一个实例对象是由那个构造函数产生的
function Constr() {}
var x = new Constr();
var y = new x.constructor();
y instanceof Constr // true
上述代码中,x
是Constr
函数的实例对象,所以x.constructor
就是值函数Constr
,所以对象y
,就是通过对象a
产生的函数Constr
函数的新的实例对象
可见constructor
的另一个特性是可以通过一个实例对象去创建另外一个新的实例对象
instanceof运算符
instanceof
运算符返回一个布尔值,表示对象是否为某个构造函数的实例。
var v = new Vehicle();
v instanceof Vehicle // true
上面代码中,对象v
是构造函数Vehicle
的实例,所以返回true
。
__proto__属性
在JavaScript中,定义一个类需要用构造函数的方法去定义
function Foo() {
this.bar = 1
}
new Foo()
但是在实际应用中,一个类中经常会有一些方法,类似属性this.bar
,我们也可以将方法定义在构造函数内部:
function Foo() {
this.bar = 1
this.show = function() {
console.log(this.bar)
}
}
(new Foo()).show()
但这样写有一个问题,就是每当我们新建一个Foo
对象时,this.show = function...
就会执行一次,这个show
方法实际上是绑定在对象上的,而不是绑定在“类”中。要实现this.show = function...
只执行一次,就要用到前面提到的prototype
属性了,代码如下
function Foo() {
this.bar = 1
}
Foo.prototype.show = function show() {
console.log(this.bar)
}
let foo = new Foo()
foo.show()
我们可以通过Foo.prototype
来访问Foo
类的原型,但Foo
实例化出来的对象,是不能通过prototype访问原型的。这时候,就该__proto__
登场了。
一个Foo类实例化出来的foo对象,可以通过foo.__proto__
属性来访问Foo类的原型,也就是说:
foo.__proto__ == Foo.prototype
总结:
prototype
是一个类的属性,所有类对象在实例化的时候将会拥有prototype
中的属性和方法- 一个对象的
__proto__
属性,指向这个对象所在的类的prototype
属性
什么是原型链污染
通俗点说:原型链污染就是一个函数下的一个实例对象对该函数的原型进行了修改,然后让该函数下的其他对象访问这个函数时,就都会得到被修改后的原型对象。
例
// foo是一个简单的JavaScript对象
let foo = {bar: 1}
// foo.bar 此时为1
console.log(foo.bar)
// 修改foo的原型(即Object)
foo.__proto__.bar = 2
// 由于查找顺序的原因,foo.bar仍然是1
console.log(foo.bar)
// 此时再用Object创建一个空的zoo对象
let zoo = {}
// 查看zoo.bar
console.log(zoo.bar)
如上述代码,foo
是一个简单的js对象,在这个对象中bar
的值为1
,所以在第二条代码中访问foo.bar
是返回的值为1
,但是foo.__proto__.bar = 2
对foo
的原型对象进行了修改,由于访问顺序的原因console.log(foo.bar)
访问的仍是修改前的bar
,所以返回的值认为1
,但是当新来的zoo
空对象再去访问bar
时,这时候的bar
就已经被污染了,所以此时返回的值就变成了2
了。
实验复现
这里以code-breaking-master的2018的题目为例
// ...
const lodash = require('lodash')
// ...
app.engine('ejs', function (filePath, options, callback) {
// define the template engine
fs.readFile(filePath, (err, content) => {
if (err) return callback(new Error(err))
let compiled = lodash.template(content)
let rendered = compiled({...options})
return callback(null, rendered)
})
})
//...
app.all('/', (req, res) => {
let data = req.session.data || {language: [], category: []}
if (req.method == 'POST') {
data = lodash.merge(data, req.body)
req.session.data = data
}
res.render('index', {
language: data.language,
category: data.category
})
})
lodash是为了弥补JavaScript原生函数功能不足而提供的一个辅助功能集,其中包含字符串、数组、对象等操作。这个Web应用中,使用了lodash提供的两个工具:
lodash.template
一个简单的模板引擎lodash.merge
函数或对象的合并
其实整个应用逻辑很简单,用户提交的信息,用merge方法合并到session里,多次提交,session里最终保存你提交的所有信息。
而这里的lodash.merge
操作实际上就存在原型链污染漏洞。
在污染原型链后,我们相当于可以给Object对象插入任意属性,这个插入的属性反应在最后的lodash.template
中。
// Use a sourceURL for easier debugging.
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : '';
// ...
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});
options是一个对象,sourceURL取到了其options.sourceURL
属性。这个属性原本是没有赋值的,默认取空字符串。
但因为原型链污染,我们可以给所有Object对象中都插入一个sourceURL
属性。最后,这个sourceURL
被拼接进new Function
的第二个参数中,造成任意代码执行漏洞。
我将带有__proto__
的Payload以json的形式发送给后端,因为express框架支持根据Content-Type来解析请求Body,这里给我们注入原型提供了很大方便:
Race竞争性漏洞
运行环境的搭建
使用Django-Cookiecutter脚手架创建一个项目,项目名是Race Condition Playground。
然后在创建两个新的model。
class User(AbstractUser):
username = models.CharField('username', max_length=256)
email = models.EmailField('email', blank=True, unique=True)
money = models.IntegerField('money', default=0)
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username']
class Meta(AbstractUser.Meta):
swappable = 'AUTH_USER_MODEL'
verbose_name = 'user'
verbose_name_plural = verbose_name
def __str__(self):
return self.username
class WithdrawLog(models.Model):
user = models.ForeignKey('User', verbose_name='user', on_delete=models.SET_NULL, null=True)
amount = models.IntegerField('amount')
created_time = models.DateTimeField('created time', auto_now_add=True)
last_modify_time = models.DateTimeField('last modify time', auto_now=True)
class Meta:
verbose_name = 'withdraw log'
verbose_name_plural = 'withdraw logs'
def __str__(self):
return str(self.created_time)
User
类继承自 AbstractUser:User
模型类继承了 Django
内置的 AbstractUser
模型类,它提供了常见的用户身份认证和权限相关的字段和方法。
User字段
username
:用户名,是一个字符型字段,最大长度为 256。
email
:电子邮件,是一个字符型字段,允许为空,必须唯一。
money
:钱,是一个整数型字段,用于存储用户的金钱数额,默认值为 0。
USERNAME_FIELD 和 REQUIRED_FIELDS
:这两个字段用于配置用户登录认证时所使用的字段。在这里,email
字段被设置为 USERNAME_FIELD
,这意味着用户登录时将使用邮箱来进行认证。username
字段被添加到 REQUIRED_FIELDS
,这意味着在创建用户时,必须提供用户名。
str()
方法:这个方法定义了提现记录对象在使用 str()
函数时返回的字符串表示。
一个是User
表,用以储存用户,其中money
字段是这个用户的余额;一个是WithdrawLog
表,用以储存提取的日志。我们假设公司财务会根据这个日志表来向用户打款,那么只要成功在这个表中插入记录,则说明攻击成功。
我们编写一个WithdrawForm
,其字段amount
,表示用户此时想提取的余额。
class WithdrawForm(forms.Form):
amount = forms.IntegerField(min_value=1)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
def clean_amount(self):
amount = self.cleaned_data['amount']
if amount > self.user.money:
raise forms.ValidationError('insufficient user balance')
return amount
如果发现用户要提取的金额大于用户的余额,则抛出一个forms.ValidationError异常。
最后,在写一个用于用户体现的View。
class BaseWithdrawView(LoginRequiredMixin, generic.FormView):
template_name = 'form.html'
form_class = forms.WithdrawForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
class WithdrawView1(BaseWithdrawView):
success_url = reverse_lazy('ucenter:withdraw1')
//form表单验证
//已经通过验证
def form_valid(self, form):
amount = form.cleaned_data['amount']
self.request.user.money -= amount
self.request.user.save()
models.WithdrawLog.objects.create(user=self.request.user, amount=amount)
return redirect(self.get_success_url())
以上 WithdrawView1()
是创建的视图,即是最后页面钟返回的页面,因为使用了django
的generic.FormView
,所以Django
在接收到POST
请求后会正常使用form
的方法进行检查(包含上面提到的余额充足的检查)。
执行函数完成操作后,会对request.user.money
进行减少,在WithdrawLog
中添加一条新的交易记录。
环境测试
讲这个程序运行起来,我们讲这个后台的账户余额设置为10。
然后去到前端,提交50,然后会因为余额不足进行报错。
在经过amount <= user.money
检查后,服务端执行提现操作,会正常进行余额的比较以及查询余额对比是否不足。
但如果某个用户同时发起两次提现请求,在第一个请求经过检查到达Withdraw
handler
之前,此时该用户的user.money
是还没有减少的;此时第二个请求如果也经过了检查,两个请求同时到达Withdraw handler
,就会导致user.money -= amount
执行两次。这样这个余额执行的错误就发生了。
这时,测试需要用到我们的Yakit
工具,新建一个Web Fuzzer
,贴入提现的数据包。
然后我在数据包中添加{{repeat(100)}}
,并把并发线程调高到100发送,此时Yakit
就会使用100个线程重复发送100次这个数据包。结果如下:
我们可以清楚的看到POST请求返回302,说明我们的请求成功了。在后台我们可以发现,我们的余额只有10,但是由于请求成功了3次,所以,体现记录达到了3次,这样我们就损失了20元钱,就会造成我们的财力损失。
解决方法
我们就会问,这个问题既然发生了,那应该如何进行解决呢?
这里我们就会聊到锁的机制,在你执行提取现金的业务时,我们将你的这个线程进行锁住,不让它被进行干扰,当你提取完成后,我们在将其放开。这样是否就解决了这个问题了。
Django
在ORM
里提供了对数据库Select for Update
的支持,在PostgreSQL、Mysql、Oracle
三个数据库中都可以使用,结合Where
语句,可以实现行级的锁。
START transation;
SELECT * FROM user WHERE id = 1 FOR UPDATE;
COMMIT;
以上代码就是悲观锁的体现。在执行语句后面我们加上FOR UPDATE的字眼,将其锁住。这样就可以保证我们在同一个事务内执行的操作的原子性。
class WithdrawView2(BaseWithdrawView):
success_url = reverse_lazy('ucenter:withdraw3')
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.user
return kwargs
@transaction.atomic
def dispatch(self, request, *args, **kwargs):
self.user = get_object_or_404(models.User.objects.select_for_update().all(), pk=self.request.user.pk)
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
amount = form.cleaned_data['amount']
self.user.money -= amount
self.user.save()
models.WithdrawLog.objects.create(user=self.user, amount=amount)
return redirect(self.get_success_url())
这是通过悲观锁来写的一个 WithdrawView2
。
在进行同理测试。
乐观锁的意思就是,我们不假设其他进程会修改数据,所以不加锁,而是到需要更新数据的时候,再使用数据库自身的UPDATE操作来更新数据库。因为UPDATE语句本身是原子操作,所以也可以用来防御并发问题。可见出现上述页面时问题已经基本解决。