原型链污染及Race漏洞

文章目录

  • 原型链污染
    • 原型对象
    • 原型链
    • 原型链污染必会属性及方法
      • prototype属性
      • constructor属性
      • instanceof运算符
      • _\_proto\__属性
    • 什么是原型链污染
    • 实验复现
  • Race竞争性漏洞
    • 运行环境的搭建
    • **User字段**
    • 环境测试

原型链污染

原型对象

​ 在说原型对象之前不得不先提一提“继承”,继承在很多面向对象的编程语言中都有着非常重要的地位,即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

在上述代码中,cat1cat2cat(){}构造函数的两个实例,它们两个都具有meow()方法。由于meow方法是生成在个实例对象上的,所以在每个实例对象执行的时候,meow方法都会被调用,每调用一次就会新建一个meow方法,这样的会浪费系统资源,如果meow方法可以直接与cat1cat2进行共享就能够解决避免这种浪费了,而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属性,就是实例对象cat1cat2的原型对象,cat1cat2会共享该属性。

constructor属性

prototype对象有一个constructor属性,默认指向prototype对象所在的构造函数。

function A() {}
var a = new P();//应该有这个属性吗
a.constructor === A // truea.constructor === a.prototype.constructor // truea.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

上述代码中,xConstr函数的实例对象,所以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 = 1this.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

总结:

  1. prototype是一个类的属性,所有类对象在实例化的时候将会拥有prototype中的属性和方法
  2. 一个对象的__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 = 2foo的原型对象进行了修改,由于访问顺序的原因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 enginefs.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提供的两个工具:

  1. lodash.template 一个简单的模板引擎
  2. 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_namedef __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.WithdrawFormdef get_form_kwargs(self):kwargs = super().get_form_kwargs()kwargs['user'] = self.request.userreturn kwargsclass WithdrawView1(BaseWithdrawView):success_url = reverse_lazy('ucenter:withdraw1')//form表单验证//已经通过验证def form_valid(self, form):amount = form.cleaned_data['amount']self.request.user.money -= amountself.request.user.save()models.WithdrawLog.objects.create(user=self.request.user, amount=amount)return redirect(self.get_success_url())

以上 WithdrawView1()是创建的视图,即是最后页面钟返回的页面,因为使用了djangogeneric.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元钱,就会造成我们的财力损失。

解决方法
我们就会问,这个问题既然发生了,那应该如何进行解决呢?

这里我们就会聊到锁的机制,在你执行提取现金的业务时,我们将你的这个线程进行锁住,不让它被进行干扰,当你提取完成后,我们在将其放开。这样是否就解决了这个问题了。

DjangoORM里提供了对数据库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.userreturn 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 -= amountself.user.save()models.WithdrawLog.objects.create(user=self.user, amount=amount)return redirect(self.get_success_url())

这是通过悲观锁来写的一个 WithdrawView2

在进行同理测试。
在这里插入图片描述

乐观锁的意思就是,我们不假设其他进程会修改数据,所以不加锁,而是到需要更新数据的时候,再使用数据库自身的UPDATE操作来更新数据库。因为UPDATE语句本身是原子操作,所以也可以用来防御并发问题。可见出现上述页面时问题已经基本解决。


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部